diff --git a/.config/CredScanSuppressions.json b/.config/CredScanSuppressions.json index 9e26dfeeb6e..3bbc3c4e0b2 100644 --- a/.config/CredScanSuppressions.json +++ b/.config/CredScanSuppressions.json @@ -1 +1,9 @@ -{} \ No newline at end of file +{ + "tool": "Credential Scanner", + "suppressions": [ + { + "file": "\\test\\Libraries\\Microsoft.Extensions.Compliance.Redaction.Tests\\HmacRedactorTest.cs", + "_justification": "Tests" + } + ] +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..e5e77b86217 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,183 @@ +# .NET Extensions Repository + +**Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.** + +The .NET Extensions repository contains a suite of libraries providing facilities commonly needed when creating production-ready applications. Major areas include AI abstractions, compliance mechanisms, diagnostics, contextual options, resilience (Polly), telemetry, AspNetCore extensions, static analysis, and testing utilities. + +## Working Effectively + +### Prerequisites and Bootstrap +- **CRITICAL**: Ensure you have access to the appropriate Azure DevOps feeds. If build fails with "Name or service not known" for `pkgs.dev.azure.com`, this indicates network/authentication issues with required internal feeds. +- Install .NET SDK 9.0.109 (as specified in global.json) if you do not already have it: + ```bash + curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 9.0.109 --install-dir ~/.dotnet + export PATH="$HOME/.dotnet:$PATH" + ``` +- Verify installation: `dotnet --version` should show `9.0.109` + +### Essential Build Commands +- **Restore dependencies**: `./restore.sh` (Linux/Mac) or `restore.cmd` (Windows) + - **CRITICAL - NEVER CANCEL**: Takes 5-10 minutes on first run. Always set timeout to 30+ minutes. + - Downloads .NET SDK, tools, and packages from Azure DevOps feeds + - Equivalent to `./build.sh --restore` + +- **Build the entire solution**: `./build.sh` (Linux/Mac) or `build.cmd` (Windows) + - **CRITICAL - NEVER CANCEL**: Takes 45-60 minutes for full build. Always set timeout to 90+ minutes. + - Repository has 124 total projects - build times are expected to be substantial + - Without parameters: equivalent to `./build.sh --restore --build` + - Individual actions: `--restore`, `--build`, `--test`, `--pack` + +- **Run tests**: `./build.sh --test` + - **CRITICAL - NEVER CANCEL**: Takes 20-30 minutes. Always set timeout to 60+ minutes. + - Runs all unit tests across the solution + +### Solution Generation (Key Workflow) +This repository does NOT contain a single solution file. Instead, use the slngen tool to generate filtered solutions: + +- **Generate filtered solution**: `./build.sh --vs ` + - Examples: + - `./build.sh --vs Http,Fakes,AspNetCore` + - `./build.sh --vs Polly` (for resilience libraries) + - `./build.sh --vs AI` (for AI-related projects) + - `./build.sh --vs '*'` (all projects - escape asterisk) + - Creates `SDK.sln` in repository root + - Also performs restore operation + - **NEVER CANCEL**: Takes 5-15 minutes depending on filter scope. Set timeout to 30+ minutes. + +- **Open in Visual Studio Code**: `./start-code.sh` (Linux/Mac) or `start-code.cmd` (Windows) + - Sets up environment variables for proper .NET SDK resolution + - Opens repository in VS Code with correct configuration + +### Build Validation and CI Requirements +- **Always run before committing**: + ```bash + ./build.sh --restore --build --test + ``` +- **Check for API changes**: If you modify public APIs, run `./scripts/MakeApiBaselines.ps1` to update API baseline manifest files +- **NEVER CANCEL** long-running builds or tests - this repository has hundreds of projects and build times are expected to be lengthy + +### Common Build Issues and Workarounds + +1. **"Workload manifest microsoft.net.sdk.aspire not installed"**: + - Run `git clean -xdf` then restore again + - Caused by multiple SDK installations + +2. **"Could not resolve SDK Microsoft.DotNet.Arcade.Sdk"** or feed access errors: + - Indicates no access to internal Microsoft Azure DevOps feeds + - **NOT A CODE ISSUE** - environment/network limitation + - Document as "Build requires access to internal Microsoft feeds" + +3. **SSL connection errors during restore**: + - Try disabling IPv6 on network adapter + - May indicate network/firewall restrictions + +### Project Structure and Navigation + +#### Key Directories +- `src/` - Main source code: + - `src/Analyzers/` - Code analyzers + - `src/Generators/` - Source generators + - `src/Libraries/` - Core extension libraries + - `src/Packages/` - NuGet package definitions + - `src/ProjectTemplates/` - Project templates +- `test/` - Test projects (organized to match src/ structure) +- `docs/` - Documentation including `building.md` +- `scripts/` - PowerShell automation scripts +- `eng/` - Build engineering and configuration + +#### Build Outputs +- `artifacts/bin/` - Compiled binaries +- `artifacts/log/` - Build logs (including `Build.binlog` for MSBuild Structured Log Viewer) +- `artifacts/packages/` - Generated NuGet packages + +#### Key Files +- `global.json` - Specifies required .NET SDK version (9.0.109) +- `Directory.Build.props` - MSBuild properties for entire repository +- `NuGet.config` - Package source configuration (internal Microsoft feeds) + +## Validation Scenarios + +**After making changes, always execute these validation steps**: +1. **Generate relevant filtered solution**: `./build.sh --vs ` + - For AI libraries: `./build.sh --vs AI` + - For AspNetCore: `./build.sh --vs AspNetCore` + - For telemetry: `./build.sh --vs Telemetry,Logging,Metrics` + - For resilience: `./build.sh --vs Polly,Resilience` +2. **Build and test**: `./build.sh --build --test` (remember: NEVER CANCEL, 60+ minute timeout) +3. **For public API changes**: Run `./scripts/MakeApiBaselines.ps1` to update API baseline manifest files +4. **Manual verification**: + - Check that your changes compile across target frameworks (net8.0, net9.0) + - Review generated packages in `artifacts/packages/` if applicable + - Verify no new build warnings in `artifacts/log/Build.binlog` + +**Cannot run applications interactively** - this is a library repository. Validation is primarily through unit tests and integration tests. + +**Common validation patterns by library type**: +- **Source generators** (Microsoft.Gen.*): Build consumer projects that use the generator +- **AspNetCore extensions**: Build test web applications that reference the extensions +- **Testing utilities**: Use them in test projects to verify functionality +- **Analyzers**: Build projects that trigger the analyzer rules + +## Time Expectations and Timeouts + +**CRITICAL - NEVER CANCEL BUILD OR TEST COMMANDS**: +- **First-time setup**: 15-20 minutes (SDK download + initial restore) - timeout: 45+ minutes +- **Restore operation**: 5-10 minutes - **ALWAYS set timeout to 30+ minutes, NEVER CANCEL** +- **Full build**: 45-60 minutes - **ALWAYS set timeout to 90+ minutes, NEVER CANCEL** +- **Test execution**: 20-30 minutes - **ALWAYS set timeout to 60+ minutes, NEVER CANCEL** +- **Filtered solution generation**: 5-15 minutes - **ALWAYS set timeout to 30+ minutes** + +**Repository has 124 total projects** - build times are substantial by design. If commands appear to hang, wait at least 60 minutes before considering alternatives. + +## Advanced Usage + +### Custom Solution Generation +```bash +# Using PowerShell script directly with options +./scripts/Slngen.ps1 -Keywords "Http","Fakes" -Folders -OutSln "MyCustom.sln" +``` + +### Build Configuration Options +- `--configuration Debug|Release` - Build configuration +- `--verbosity minimal|normal|detailed|diagnostic` - MSBuild verbosity +- `--onlyTfms "net8.0;net9.0"` - Build specific target frameworks only + +### Code Coverage +```bash +./build.sh --restore --build --configuration Release --testCoverage +``` +Results available at: `artifacts/TestResults/Release/CoverageResultsHtml/index.html` + +## Common Tasks Reference + +**Key library areas and their keywords for filtered solutions**: +- **AI libraries**: `./build.sh --vs AI` (Microsoft.Extensions.AI.*, embedding, chat completion) +- **Telemetry**: `./build.sh --vs Telemetry,Logging,Metrics` (logging, metrics, tracing) +- **AspNetCore**: `./build.sh --vs AspNetCore` (middleware, diagnostics, testing) +- **Resilience**: `./build.sh --vs Polly,Resilience` (Polly integration, resilience patterns) +- **Compliance**: `./build.sh --vs Compliance,Audit` (data classification, audit reports) +- **Hosting**: `./build.sh --vs Hosting,Options` (contextual options, ambient metadata) +- **Testing utilities**: `./build.sh --vs Testing,Fakes` (mocking, fake implementations) + +**Find projects by pattern**: +```bash +# AI-related projects +find src/Libraries -name "*AI*" -name "*.csproj" + +# All AspNetCore extensions +find src/Libraries -name "*AspNetCore*" -name "*.csproj" + +# Source generators +find src/Generators -name "*.csproj" + +# Test projects for a specific library +find test/Libraries -name "*[LibraryName]*" -name "*.csproj" +``` + +**Working with specific libraries workflow**: +1. Identify library area: `ls src/Libraries/` or use find commands above +2. Generate focused solution: `./build.sh --vs ` +3. Navigate to library directory: `cd src/Libraries/Microsoft.Extensions.[Area]` +4. Check corresponding tests: `cd test/Libraries/Microsoft.Extensions.[Area].Tests` +5. Review library README: `cat src/Libraries/Microsoft.Extensions.[Area]/README.md` +6. Build and test: `./build.sh --build --test` (with appropriate timeouts) \ No newline at end of file diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe new file mode 100644 index 00000000000..ed048fe8892 Binary files /dev/null and b/.nuget/nuget.exe differ diff --git a/Directory.Build.props b/Directory.Build.props index 0c0fcf22bfd..213d4b272b6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ net - 9 + 10 0 $(TargetFrameworkMajorVersion).$(TargetFrameworkMinorVersion) @@ -13,7 +13,7 @@ $(TargetFrameworkName)$(TargetFrameworkVersion) $(LatestTargetFramework) - $(SupportedNetCoreTargetFrameworks);net8.0 + $(SupportedNetCoreTargetFrameworks);net9.0;net8.0 net8.0 diff --git a/Directory.Build.targets b/Directory.Build.targets index 5fcf797523c..31c6cd27154 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -8,6 +8,11 @@ + + + false + + $(MSBuildWarningsAsMessages);NETSDK1138;MSB3270 @@ -35,6 +40,9 @@ $(NoWarn);CA1062 + + + $(NoWarn);CS1998 @@ -59,6 +67,13 @@ + + + <_Parameter1>OriginalRepoCommitHash + <_Parameter2>$(RepoOriginalSourceRevisionId) + + + diff --git a/NuGet.config b/NuGet.config index 0d98374a4f3..4e7655f10c7 100644 --- a/NuGet.config +++ b/NuGet.config @@ -3,49 +3,20 @@ + + + - - - - - - - - - - + + - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - + + @@ -58,49 +29,20 @@ + + + - - - - - - - - - - + + - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - + + diff --git a/azure-pipelines-public.yml b/azure-pipelines-public.yml index 30fa2c85fa5..9668b4ed6fb 100644 --- a/azure-pipelines-public.yml +++ b/azure-pipelines-public.yml @@ -237,10 +237,10 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2004.amd64.open + demands: ImageOverride -equals windows.vs2022preview.amd64.open variables: - - _buildScript: $(Build.SourcesDirectory)/build.sh --ci + - _buildScript: $(Build.SourcesDirectory)/build.cmd -ci -NativeToolsOnMachine preSteps: - checkout: self @@ -257,4 +257,4 @@ stages: repoTestResultsPath: $(Build.Arcade.TestResultsPath) skipTests: true skipQualityGates: true - isWindows: false + isWindows: true diff --git a/azure-pipelines-unofficial.yml b/azure-pipelines-unofficial.yml new file mode 100644 index 00000000000..57594fd5ef1 --- /dev/null +++ b/azure-pipelines-unofficial.yml @@ -0,0 +1,256 @@ +trigger: none + +pr: none + +variables: + - name: _TeamName + value: dotnet-r9 + - name: NativeToolsOnMachine + value: true + - name: DOTNET_SKIP_FIRST_TIME_EXPERIENCE + value: true + + - name: SkipQualityGates + value: false + + - name: runAsPublic + value: ${{ eq(variables['System.TeamProject'], 'public') }} + + - name: _BuildConfig + value: Release + - name: isOfficialBuild + value: ${{ and(ne(variables['runAsPublic'], 'true'), notin(variables['Build.Reason'], 'PullRequest')) }} + - name: Build.Arcade.ArtifactsPath + value: $(Build.SourcesDirectory)/artifacts/ + - name: Build.Arcade.LogsPath + value: $(Build.Arcade.ArtifactsPath)log/$(_BuildConfig)/ + - name: Build.Arcade.TestResultsPath + value: $(Build.Arcade.ArtifactsPath)TestResults/$(_BuildConfig)/ + - name: Build.Arcade.VSIXOutputPath + value: $(Build.Arcade.ArtifactsPath)VSIX + + - ${{ if or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/validation/'), eq(variables['Build.Reason'], 'Manual')) }}: + - name: PostBuildSign + value: false + - ${{ else }}: + - name: PostBuildSign + value: true + + - name: _PublishArgs + value: >- + /p:DotNetPublishUsingPipelines=true + - name: _OfficialBuildIdArgs + value: /p:OfficialBuildId=$(BUILD.BUILDNUMBER) + # needed for signing + - name: _SignType + value: test + - name: _SignArgs + value: /p:DotNetSignType=$(_SignType) /p:TeamName=$(_TeamName) /p:Sign=$(_Sign) /p:DotNetPublishUsingPipelines=true + - name: _Sign + value: true + + - name: enableSourceIndex + value: false + +resources: + repositories: + - repository: 1ESPipelineTemplates + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1ESPipelineTemplates + parameters: + sdl: + sourceAnalysisPool: + name: NetCore1ESPool-Internal + image: windows.vs2022preview.amd64 + os: windows + customBuildTags: + - ES365AIMigrationTooling + + stages: + - stage: build + displayName: Build + variables: + - template: /eng/common/templates-official/variables/pool-providers.yml@self + jobs: + - template: /eng/common/templates-official/jobs/jobs.yml@self + parameters: + enableMicrobuild: true + enableTelemetry: true + enableSourceIndex: ${{ variables['enableSourceIndex'] }} + runAsPublic: ${{ variables['runAsPublic'] }} + # Publish build logs + enablePublishBuildArtifacts: false + # Publish test logs + enablePublishTestResults: true + # Publish NuGet packages using v3 + # https://github.com/dotnet/arcade/blob/main/Documentation/CorePackages/Publishing.md#basic-onboarding-scenario-for-new-repositories-to-the-current-publishing-version-v3 + enablePublishUsingPipelines: false + enablePublishBuildAssets: false + workspace: + clean: all + + jobs: + + # ---------------------------------------------------------------- + # This job build and run tests on Windows + # ---------------------------------------------------------------- + - job: Windows + timeoutInMinutes: 180 + testResultsFormat: VSTest + pool: + name: NetCore1ESPool-Internal + image: windows.vs2022preview.amd64 + os: windows + + variables: + - _buildScript: $(Build.SourcesDirectory)/build.cmd -ci -NativeToolsOnMachine + + templateContext: + outputs: + - output: pipelineArtifact + displayName: 'Publish Azure DevOps extension artifacts' + condition: succeeded() + targetPath: '$(Build.Arcade.VSIXOutputPath)' + artifactName: 'VSIXArtifacts' + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - template: /eng/pipelines/templates/BuildAndTest.yml + parameters: + buildScript: $(_buildScript) + buildConfig: $(_BuildConfig) + repoLogPath: $(Build.Arcade.LogsPath) + repoTestResultsPath: $(Build.Arcade.TestResultsPath) + skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} + isWindows: true + + # ---------------------------------------------------------------- + # This job build and run tests on Ubuntu + # ---------------------------------------------------------------- + - job: Ubuntu + timeoutInMinutes: 180 + testResultsFormat: VSTest + pool: + name: NetCore1ESPool-Internal + image: 1es-mariner-2 + os: linux + + variables: + - _buildScript: $(Build.SourcesDirectory)/build.sh --ci + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - template: /eng/pipelines/templates/BuildAndTest.yml + parameters: + buildScript: $(_buildScript) + buildConfig: $(_BuildConfig) + repoLogPath: $(Build.Arcade.LogsPath) + repoTestResultsPath: $(Build.Arcade.TestResultsPath) + skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} + isWindows: false + + # ---------------------------------------------------------------- + # This stage performs quality gates enforcements + # ---------------------------------------------------------------- + - stage: codecoverage + displayName: CodeCoverage + dependsOn: + - build + condition: and(succeeded('build'), ne(variables['SkipQualityGates'], 'true')) + variables: + - template: /eng/common/templates-official/variables/pool-providers.yml@self + jobs: + - template: /eng/common/templates-official/jobs/jobs.yml@self + parameters: + enableMicrobuild: true + enableTelemetry: true + runAsPublic: ${{ variables['runAsPublic'] }} + workspace: + clean: all + + # ---------------------------------------------------------------- + # This stage downloads the code coverage reports from the build jobs, + # merges those and validates the combined test coverage. + # ---------------------------------------------------------------- + jobs: + - job: CodeCoverageReport + timeoutInMinutes: 180 + + pool: + name: NetCore1ESPool-Internal + image: 1es-mariner-2 + os: linux + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - script: $(Build.SourcesDirectory)/build.sh --ci --restore + displayName: Init toolset + + - template: /eng/pipelines/templates/VerifyCoverageReport.yml + + + # ---------------------------------------------------------------- + # This stage only performs a build treating warnings as errors + # to detect any kind of code style violations + # ---------------------------------------------------------------- + - stage: correctness + displayName: Correctness + dependsOn: [] + variables: + - template: /eng/common/templates-official/variables/pool-providers.yml@self + jobs: + - template: /eng/common/templates-official/jobs/jobs.yml@self + parameters: + enableMicrobuild: true + enableTelemetry: true + runAsPublic: ${{ variables['runAsPublic'] }} + workspace: + clean: all + + jobs: + - job: WarningsCheck + timeoutInMinutes: 180 + + pool: + name: NetCore1ESPool-Internal + image: 1es-mariner-2 + os: linux + + variables: + - _buildScript: $(Build.SourcesDirectory)/build.sh --ci + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - template: '\eng\pipelines\templates\BuildAndTest.yml' + parameters: + buildScript: $(_buildScript) + buildConfig: $(_BuildConfig) + repoLogPath: $(Build.Arcade.LogsPath) + repoTestResultsPath: $(Build.Arcade.TestResultsPath) + skipTests: true + skipQualityGates: true + isWindows: false diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0052dc9f706..59fbfbf7405 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -75,6 +75,13 @@ variables: - name: Build.Arcade.VSIXOutputPath value: $(Build.Arcade.ArtifactsPath)VSIX + # Enable extraction of published outputs for analysis + - name: GDN_EXTRACT_TOOLS + value: 'binskim,bandit,roslynanalyzers' + + - name: GDN_EXTRACT_FILTER + value: 'f|**/*.zip;f|**/*.nupkg;f|**/*.vsix;f|**/*.cspkg;f|**/*.sfpkg;f|**/*.package' + - ${{ if or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/validation/'), eq(variables['Build.Reason'], 'Manual')) }}: - name: PostBuildSign value: false @@ -117,6 +124,8 @@ variables: - ${{ if and(ne(variables['runAsPublic'], 'true'), notin(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/main')) }}: - name: enableSourceIndex value: true + - name: sourceIndexBuildCommand + value: $(Build.SourcesDirectory)/build.cmd -ci -NativeToolsOnMachine - ${{ else }}: - name: enableSourceIndex value: false @@ -131,6 +140,8 @@ resources: extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates parameters: + featureFlags: + binskimScanAllExtensions: true sdl: policheck: enabled: true @@ -152,18 +163,19 @@ extends: jobs: - template: /eng/common/templates-official/jobs/jobs.yml@self parameters: + artifacts: + publish: + logs: true + manifests: true enableMicrobuild: true enableTelemetry: true enableSourceIndex: ${{ variables['enableSourceIndex'] }} runAsPublic: ${{ variables['runAsPublic'] }} - # Publish build logs - enablePublishBuildArtifacts: true # Publish test logs enablePublishTestResults: true # Publish NuGet packages using v3 # https://github.com/dotnet/arcade/blob/main/Documentation/CorePackages/Publishing.md#basic-onboarding-scenario-for-new-repositories-to-the-current-publishing-version-v3 enablePublishUsingPipelines: true - enablePublishBuildAssets: true workspace: clean: all @@ -187,9 +199,16 @@ extends: outputs: - output: pipelineArtifact displayName: 'Publish Azure DevOps extension artifacts' - condition: succeeded() targetPath: '$(Build.Arcade.VSIXOutputPath)' artifactName: 'VSIXArtifacts' + condition: always() + continueOnError: true + - output: pipelineArtifact + displayName: 'Publish Packages' + targetPath: '$(Build.Arcade.ArtifactsPath)packages' + artifactName: 'PackageArtifacts_Windows' + condition: always() + continueOnError: true preSteps: - checkout: self @@ -206,7 +225,6 @@ extends: repoTestResultsPath: $(Build.Arcade.TestResultsPath) skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} isWindows: true - warnAsError: 0 # ---------------------------------------------------------------- # This job build and run tests on Ubuntu @@ -237,7 +255,6 @@ extends: repoTestResultsPath: $(Build.Arcade.TestResultsPath) skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} isWindows: false - warnAsError: 0 # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors diff --git a/bench/.editorconfig b/bench/.editorconfig index f66bffda880..c86f7f34899 100644 --- a/bench/.editorconfig +++ b/bench/.editorconfig @@ -481,11 +481,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 6b110acaaa1..9e83541b0d8 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -2,7 +2,7 @@ - + @@ -47,10 +47,6 @@ - - - - @@ -74,4 +70,8 @@ + + + + diff --git a/eng/MSBuild/Packaging.targets b/eng/MSBuild/Packaging.targets index fdde9aa70d1..0e1a385d685 100644 --- a/eng/MSBuild/Packaging.targets +++ b/eng/MSBuild/Packaging.targets @@ -37,7 +37,7 @@ true - $(ApiCompatBaselineVersion) + 9.10.0 @@ -93,7 +93,7 @@ !@(_PackageBuildFile->AnyHaveMetadataValue('PackagePathWithoutFilename', '$(_NETStandardCompatErrorPlaceholderFilePackagePath)'))" /> - + @@ -102,4 +102,15 @@ + + + + <_PackageVersionInfo Include="$(MSBuildProjectFullPath)"> + $(PackageVersion) + $(PackageId) + + + + diff --git a/eng/Publishing.props b/eng/Publishing.props index 0bcb04d4f32..a39ae2df5ac 100644 --- a/eng/Publishing.props +++ b/eng/Publishing.props @@ -1,5 +1,9 @@ + + + + 3 true diff --git a/eng/Signing.props b/eng/Signing.props index d4b1ec3e6cf..133e317085a 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/eng/Tools/.editorconfig b/eng/Tools/.editorconfig index be77d5da2f3..8b26ad2938d 100644 --- a/eng/Tools/.editorconfig +++ b/eng/Tools/.editorconfig @@ -479,11 +479,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 859de22a83f..154e91caeb2 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,210 +1,222 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + fa7cdded37981a97cec9a3e233c4a6af58a91c57 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - e36e4d1a8f8dfb08d7e3a6041459c9791d732c01 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - ed74665e773dd1ebea3289c5662d71c590305932 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 6765359588e8b38bab2a7974db9398432703828f + f55fe13550b5f821336abb63ef5ac454ce4de5fa - + https://github.com/dotnet/arcade - bfbc858ba868b60fffaf7b2150f1d2165b01e786 + 6666973b629b24e259162dba03486c23af464bab - + https://github.com/dotnet/arcade - bfbc858ba868b60fffaf7b2150f1d2165b01e786 + 6666973b629b24e259162dba03486c23af464bab - + https://github.com/dotnet/arcade - bfbc858ba868b60fffaf7b2150f1d2165b01e786 + 6666973b629b24e259162dba03486c23af464bab diff --git a/eng/Versions.props b/eng/Versions.props index 607e12c2c6d..3c01e6cdc7b 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,12 +1,11 @@ - 9 - 5 + 10 + 0 0 preview 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) - 9.4.0 $(MajorVersion).$(MinorVersion).0.0 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 - 9.0.5 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 - 9.0.5 + 9.0.11 - 9.0.0-beta.25225.6 + 9.0.0-beta.25515.2 + + + + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + + 10.0.0 + + 10.0.0-beta.25523.111 @@ -108,8 +167,9 @@ 8.0.1 8.0.0 8.0.2 - 8.0.16 - 8.0.16 + 8.0.0 + 8.0.22 + 8.0.22 8.0.0 8.0.1 8.0.1 @@ -122,21 +182,22 @@ 8.0.1 8.0.2 8.0.0 - 8.0.0 - 8.0.5 + 8.0.1 + 8.0.6 8.0.0 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 - 8.0.16 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 - 8.0.16 + 8.0.22 4.8.0 3.3.4 - - 9.2.1 - 9.2.1-preview.1.25222.1 - 1.0.0-beta.6 - 2.2.0-beta.4 - 1.13.2 - 11.6.0 - 9.4.1-beta.277 - 9.4.1-beta.277 - 9.4.1-beta.277 - 9.4.1-beta.277 - 9.2.0 - 1.47.0-preview - 1.47.0-preview - 1.47.0 - 5.1.13 - 1.9.0 - 0.1.9 - 6.0.1 2.9.3 + + 2.8.2 + + 9.7.0 + 1.67.0-preview + 0.43.0 diff --git a/eng/common/SetupNugetSources.ps1 b/eng/common/SetupNugetSources.ps1 index 5db4ad71ee2..792b60b49d4 100644 --- a/eng/common/SetupNugetSources.ps1 +++ b/eng/common/SetupNugetSources.ps1 @@ -10,8 +10,8 @@ # displayName: Setup Private Feeds Credentials # condition: eq(variables['Agent.OS'], 'Windows_NT') # inputs: -# filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 -# arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $Env:Token +# filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 +# arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $Env:Token # env: # Token: $(dn-bot-dnceng-artifact-feeds-rw) # diff --git a/eng/common/SetupNugetSources.sh b/eng/common/SetupNugetSources.sh index 4604b61b032..facb415ca6f 100755 --- a/eng/common/SetupNugetSources.sh +++ b/eng/common/SetupNugetSources.sh @@ -11,8 +11,8 @@ # - task: Bash@3 # displayName: Setup Internal Feeds # inputs: -# filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.sh -# arguments: $(Build.SourcesDirectory)/NuGet.config +# filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.sh +# arguments: $(System.DefaultWorkingDirectory)/NuGet.config # condition: ne(variables['Agent.OS'], 'Windows_NT') # - task: NuGetAuthenticate@1 # diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index ba53ebfbd51..8da43d3b583 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -19,6 +19,7 @@ parameters: # publishing defaults artifacts: '' enableMicrobuild: false + microbuildUseESRP: true enablePublishBuildArtifacts: false enablePublishBuildAssets: false enablePublishTestResults: false @@ -134,6 +135,11 @@ jobs: signType: $(_SignType) zipSources: false feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ${{ else }}: + ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca env: TeamName: $(_TeamName) MicroBuildOutputFolderOverride: '$(Agent.TempDirectory)' @@ -160,7 +166,7 @@ jobs: inputs: languages: ${{ coalesce(parameters.richCodeNavigationLanguage, 'csharp') }} environment: ${{ coalesce(parameters.richCodeNavigationEnvironment, 'internal') }} - richNavLogOutputDirectory: $(Build.SourcesDirectory)/artifacts/bin + richNavLogOutputDirectory: $(System.DefaultWorkingDirectory)/artifacts/bin uploadRichNavArtifacts: ${{ coalesce(parameters.richCodeNavigationUploadArtifacts, false) }} continueOnError: true @@ -183,7 +189,7 @@ jobs: inputs: testResultsFormat: 'xUnit' testResultsFiles: '*.xml' - searchFolder: '$(Build.SourcesDirectory)/artifacts/TestResults/$(_BuildConfig)' + searchFolder: '$(System.DefaultWorkingDirectory)/artifacts/TestResults/$(_BuildConfig)' testRunTitle: ${{ coalesce(parameters.testRunTitle, parameters.name, '$(System.JobName)') }}-xunit mergeTestResults: ${{ parameters.mergeTestResults }} continueOnError: true @@ -194,7 +200,7 @@ jobs: inputs: testResultsFormat: 'VSTest' testResultsFiles: '*.trx' - searchFolder: '$(Build.SourcesDirectory)/artifacts/TestResults/$(_BuildConfig)' + searchFolder: '$(System.DefaultWorkingDirectory)/artifacts/TestResults/$(_BuildConfig)' testRunTitle: ${{ coalesce(parameters.testRunTitle, parameters.name, '$(System.JobName)') }}-trx mergeTestResults: ${{ parameters.mergeTestResults }} continueOnError: true @@ -238,7 +244,7 @@ jobs: - task: CopyFiles@2 displayName: Gather buildconfiguration for build retry inputs: - SourceFolder: '$(Build.SourcesDirectory)/eng/common/BuildConfiguration' + SourceFolder: '$(System.DefaultWorkingDirectory)/eng/common/BuildConfiguration' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/eng/common/BuildConfiguration' continueOnError: true diff --git a/eng/common/core-templates/job/onelocbuild.yml b/eng/common/core-templates/job/onelocbuild.yml index 00feec8ebbc..edefa789d36 100644 --- a/eng/common/core-templates/job/onelocbuild.yml +++ b/eng/common/core-templates/job/onelocbuild.yml @@ -8,7 +8,7 @@ parameters: CeapexPat: $(dn-bot-ceapex-package-r) # PAT for the loc AzDO instance https://dev.azure.com/ceapex GithubPat: $(BotAccount-dotnet-bot-repo-PAT) - SourcesDirectory: $(Build.SourcesDirectory) + SourcesDirectory: $(System.DefaultWorkingDirectory) CreatePr: true AutoCompletePr: false ReusePr: true @@ -68,7 +68,7 @@ jobs: - ${{ if ne(parameters.SkipLocProjectJsonGeneration, 'true') }}: - task: Powershell@2 inputs: - filePath: $(Build.SourcesDirectory)/eng/common/generate-locproject.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/generate-locproject.ps1 arguments: $(_GenerateLocProjectArguments) displayName: Generate LocProject.json condition: ${{ parameters.condition }} @@ -115,7 +115,7 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} args: displayName: Publish LocProject.json - pathToPublish: '$(Build.SourcesDirectory)/eng/Localize/' + pathToPublish: '$(System.DefaultWorkingDirectory)/eng/Localize/' publishLocation: Container artifactName: Loc condition: ${{ parameters.condition }} \ No newline at end of file diff --git a/eng/common/core-templates/job/publish-build-assets.yml b/eng/common/core-templates/job/publish-build-assets.yml index 3d3356e3196..a58c8a418e8 100644 --- a/eng/common/core-templates/job/publish-build-assets.yml +++ b/eng/common/core-templates/job/publish-build-assets.yml @@ -32,6 +32,10 @@ parameters: is1ESPipeline: '' + repositoryAlias: self + + officialBuildId: '' + jobs: - job: Asset_Registry_Publish @@ -54,6 +58,11 @@ jobs: value: false # unconditional - needed for logs publishing (redactor tool version) - template: /eng/common/core-templates/post-build/common-variables.yml + - name: OfficialBuildId + ${{ if ne(parameters.officialBuildId, '') }}: + value: ${{ parameters.officialBuildId }} + ${{ else }}: + value: $(Build.BuildNumber) pool: # We don't use the collection uri here because it might vary (.visualstudio.com vs. dev.azure.com) @@ -72,7 +81,7 @@ jobs: - 'Illegal entry point, is1ESPipeline is not defined. Repository yaml should not directly reference templates in core-templates folder.': error - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - - checkout: self + - checkout: ${{ parameters.repositoryAlias }} fetchDepth: 3 clean: true @@ -93,12 +102,12 @@ jobs: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Build.SourcesDirectory)/eng/common/sdk-task.ps1 + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/sdk-task.ps1 arguments: -task PublishBuildAssets -restore -msbuildEngine dotnet /p:ManifestsPath='$(Build.StagingDirectory)/Download/AssetManifests' /p:MaestroApiEndpoint=https://maestro.dot.net /p:PublishUsingPipelines=${{ parameters.publishUsingPipelines }} - /p:OfficialBuildId=$(Build.BuildNumber) + /p:OfficialBuildId=$(OfficialBuildId) condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} @@ -113,7 +122,7 @@ jobs: Add-Content -Path $filePath -Value "$(DefaultChannels)" Add-Content -Path $filePath -Value $(IsStableBuild) - $symbolExclusionfile = "$(Build.SourcesDirectory)/eng/SymbolPublishingExclusionsFile.txt" + $symbolExclusionfile = "$(System.DefaultWorkingDirectory)/eng/SymbolPublishingExclusionsFile.txt" if (Test-Path -Path $symbolExclusionfile) { Write-Host "SymbolExclusionFile exists" @@ -142,7 +151,7 @@ jobs: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Build.SourcesDirectory)/eng/common/post-build/publish-using-darc.ps1 + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion 3 diff --git a/eng/common/core-templates/job/source-build.yml b/eng/common/core-templates/job/source-build.yml index d47f09d58fd..5baedac1e03 100644 --- a/eng/common/core-templates/job/source-build.yml +++ b/eng/common/core-templates/job/source-build.yml @@ -33,6 +33,9 @@ parameters: # container and pool. platform: {} + # Optional list of directories to ignore for component governance scans. + componentGovernanceIgnoreDirectories: [] + is1ESPipeline: '' # If set to true and running on a non-public project, @@ -93,3 +96,4 @@ jobs: parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} platform: ${{ parameters.platform }} + componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} diff --git a/eng/common/core-templates/job/source-index-stage1.yml b/eng/common/core-templates/job/source-index-stage1.yml index 8b833332b3e..662b9fcce15 100644 --- a/eng/common/core-templates/job/source-index-stage1.yml +++ b/eng/common/core-templates/job/source-index-stage1.yml @@ -66,7 +66,7 @@ jobs: - script: ${{ parameters.sourceIndexBuildCommand }} displayName: Build Repository - - script: $(Agent.TempDirectory)/.source-index/tools/BinLogToSln -i $(BinlogPath) -r $(Build.SourcesDirectory) -n $(Build.Repository.Name) -o .source-index/stage1output + - script: $(Agent.TempDirectory)/.source-index/tools/BinLogToSln -i $(BinlogPath) -r $(System.DefaultWorkingDirectory) -n $(Build.Repository.Name) -o .source-index/stage1output displayName: Process Binlog into indexable sln - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: diff --git a/eng/common/core-templates/jobs/codeql-build.yml b/eng/common/core-templates/jobs/codeql-build.yml index f2144252cc6..4571a7864df 100644 --- a/eng/common/core-templates/jobs/codeql-build.yml +++ b/eng/common/core-templates/jobs/codeql-build.yml @@ -25,7 +25,7 @@ jobs: - name: DefaultGuardianVersion value: 0.109.0 - name: GuardianPackagesConfigFile - value: $(Build.SourcesDirectory)\eng\common\sdl\packages.config + value: $(System.DefaultWorkingDirectory)\eng\common\sdl\packages.config - name: GuardianVersion value: ${{ coalesce(parameters.overrideGuardianVersion, '$(DefaultGuardianVersion)') }} diff --git a/eng/common/core-templates/jobs/jobs.yml b/eng/common/core-templates/jobs/jobs.yml index ea69be4341c..bf33cdc2cc7 100644 --- a/eng/common/core-templates/jobs/jobs.yml +++ b/eng/common/core-templates/jobs/jobs.yml @@ -43,6 +43,8 @@ parameters: artifacts: {} is1ESPipeline: '' + repositoryAlias: self + officialBuildId: '' # Internal resources (telemetry, microbuild) can only be accessed from non-public projects, # and some (Microbuild) should only be applied to non-PR cases for internal builds. @@ -117,3 +119,5 @@ jobs: enablePublishBuildArtifacts: ${{ parameters.enablePublishBuildArtifacts }} artifactsPublishingAdditionalParameters: ${{ parameters.artifactsPublishingAdditionalParameters }} signingValidationAdditionalParameters: ${{ parameters.signingValidationAdditionalParameters }} + repositoryAlias: ${{ parameters.repositoryAlias }} + officialBuildId: ${{ parameters.officialBuildId }} diff --git a/eng/common/core-templates/jobs/source-build.yml b/eng/common/core-templates/jobs/source-build.yml index a10ccfbee6d..0b408a67bd5 100644 --- a/eng/common/core-templates/jobs/source-build.yml +++ b/eng/common/core-templates/jobs/source-build.yml @@ -21,6 +21,9 @@ parameters: # one job runs on 'defaultManagedPlatform'. platforms: [] + # Optional list of directories to ignore for component governance scans. + componentGovernanceIgnoreDirectories: [] + is1ESPipeline: '' # If set to true and running on a non-public project, @@ -47,6 +50,7 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} jobNamePrefix: ${{ parameters.jobNamePrefix }} platform: ${{ platform }} + componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} enableInternalSources: ${{ parameters.enableInternalSources }} - ${{ if eq(length(parameters.platforms), 0) }}: @@ -55,4 +59,5 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} jobNamePrefix: ${{ parameters.jobNamePrefix }} platform: ${{ parameters.defaultManagedPlatform }} + componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} enableInternalSources: ${{ parameters.enableInternalSources }} diff --git a/eng/common/core-templates/post-build/post-build.yml b/eng/common/core-templates/post-build/post-build.yml index 454fd75c7af..2ee8bbfff54 100644 --- a/eng/common/core-templates/post-build/post-build.yml +++ b/eng/common/core-templates/post-build/post-build.yml @@ -44,6 +44,11 @@ parameters: displayName: Publish installers and checksums type: boolean default: true + + - name: requireDefaultChannels + displayName: Fail the build if there are no default channel(s) registrations for the current build + type: boolean + default: false - name: SDLValidationParameters type: object @@ -144,7 +149,7 @@ stages: - task: PowerShell@2 displayName: Validate inputs: - filePath: $(Build.SourcesDirectory)/eng/common/post-build/nuget-validation.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ - job: @@ -201,7 +206,7 @@ stages: filePath: eng\common\sdk-task.ps1 arguments: -task SigningValidation -restore -msbuildEngine vs /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' - /p:SignCheckExclusionsFile='$(Build.SourcesDirectory)/eng/SignCheckExclusionsFile.txt' + /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' ${{ parameters.signingValidationAdditionalParameters }} - template: /eng/common/core-templates/steps/publish-logs.yml @@ -251,7 +256,7 @@ stages: - task: PowerShell@2 displayName: Validate inputs: - filePath: $(Build.SourcesDirectory)/eng/common/post-build/sourcelink-validation.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ -ExtractPath $(Agent.BuildDirectory)/Extract/ -GHRepoName $(Build.Repository.Name) @@ -306,11 +311,12 @@ stages: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Build.SourcesDirectory)/eng/common/post-build/publish-using-darc.ps1 + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion ${{ parameters.publishingInfraVersion }} -AzdoToken '$(System.AccessToken)' -WaitPublishingFinish true + -RequireDefaultChannels ${{ parameters.requireDefaultChannels }} -ArtifactsPublishingAdditionalParameters '${{ parameters.artifactsPublishingAdditionalParameters }}' -SymbolPublishingAdditionalParameters '${{ parameters.symbolPublishingAdditionalParameters }}' diff --git a/eng/common/core-templates/post-build/setup-maestro-vars.yml b/eng/common/core-templates/post-build/setup-maestro-vars.yml index f7602980dbe..a7abd58c4bb 100644 --- a/eng/common/core-templates/post-build/setup-maestro-vars.yml +++ b/eng/common/core-templates/post-build/setup-maestro-vars.yml @@ -36,7 +36,7 @@ steps: $AzureDevOpsBuildId = $Env:Build_BuildId } else { - . $(Build.SourcesDirectory)\eng\common\tools.ps1 + . $(System.DefaultWorkingDirectory)\eng\common\tools.ps1 $darc = Get-Darc $buildInfo = & $darc get-build ` --id ${{ parameters.BARBuildId }} ` diff --git a/eng/common/core-templates/steps/enable-internal-sources.yml b/eng/common/core-templates/steps/enable-internal-sources.yml index 64f881bffc3..4085512b690 100644 --- a/eng/common/core-templates/steps/enable-internal-sources.yml +++ b/eng/common/core-templates/steps/enable-internal-sources.yml @@ -17,8 +17,8 @@ steps: - task: PowerShell@2 displayName: Setup Internal Feeds inputs: - filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 - arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $Env:Token + filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $Env:Token env: Token: ${{ parameters.legacyCredential }} # If running on dnceng (internal project), just use the default behavior for NuGetAuthenticate. @@ -29,8 +29,8 @@ steps: - task: PowerShell@2 displayName: Setup Internal Feeds inputs: - filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 - arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config + filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config - ${{ else }}: - template: /eng/common/templates/steps/get-federated-access-token.yml parameters: @@ -39,8 +39,8 @@ steps: - task: PowerShell@2 displayName: Setup Internal Feeds inputs: - filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 - arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $(dnceng-artifacts-feeds-read-access-token) + filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $(dnceng-artifacts-feeds-read-access-token) # This is required in certain scenarios to install the ADO credential provider. # It installed by default in some msbuild invocations (e.g. VS msbuild), but needs to be installed for others # (e.g. dotnet msbuild). diff --git a/eng/common/core-templates/steps/generate-sbom.yml b/eng/common/core-templates/steps/generate-sbom.yml index 56a09009482..7f5b84c4cb8 100644 --- a/eng/common/core-templates/steps/generate-sbom.yml +++ b/eng/common/core-templates/steps/generate-sbom.yml @@ -6,7 +6,7 @@ parameters: PackageVersion: 9.0.0 - BuildDropPath: '$(Build.SourcesDirectory)/artifacts' + BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' PackageName: '.NET' ManifestDirPath: $(Build.ArtifactStagingDirectory)/sbom IgnoreDirectories: '' diff --git a/eng/common/core-templates/steps/publish-logs.yml b/eng/common/core-templates/steps/publish-logs.yml index 80788c52319..0623ac6e112 100644 --- a/eng/common/core-templates/steps/publish-logs.yml +++ b/eng/common/core-templates/steps/publish-logs.yml @@ -12,22 +12,22 @@ steps: inputs: targetType: inline script: | - New-Item -ItemType Directory $(Build.SourcesDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ - Move-Item -Path $(Build.SourcesDirectory)/artifacts/log/Debug/* $(Build.SourcesDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ + New-Item -ItemType Directory $(System.DefaultWorkingDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ + Move-Item -Path $(System.DefaultWorkingDirectory)/artifacts/log/Debug/* $(System.DefaultWorkingDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ continueOnError: true condition: always() - task: PowerShell@2 displayName: Redact Logs inputs: - filePath: $(Build.SourcesDirectory)/eng/common/post-build/redact-logs.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/redact-logs.ps1 # For now this needs to have explicit list of all sensitive data. Taken from eng/publishing/v3/publish.yml - # Sensitive data can as well be added to $(Build.SourcesDirectory)/eng/BinlogSecretsRedactionFile.txt' + # Sensitive data can as well be added to $(System.DefaultWorkingDirectory)/eng/BinlogSecretsRedactionFile.txt' # If the file exists - sensitive data for redaction will be sourced from it # (single entry per line, lines starting with '# ' are considered comments and skipped) - arguments: -InputPath '$(Build.SourcesDirectory)/PostBuildLogs' + arguments: -InputPath '$(System.DefaultWorkingDirectory)/PostBuildLogs' -BinlogToolVersion ${{parameters.BinlogToolVersion}} - -TokensFilePath '$(Build.SourcesDirectory)/eng/BinlogSecretsRedactionFile.txt' + -TokensFilePath '$(System.DefaultWorkingDirectory)/eng/BinlogSecretsRedactionFile.txt' '$(publishing-dnceng-devdiv-code-r-build-re)' '$(MaestroAccessToken)' '$(dn-bot-all-orgs-artifact-feeds-rw)' @@ -42,7 +42,7 @@ steps: - task: CopyFiles@2 displayName: Gather post build logs inputs: - SourceFolder: '$(Build.SourcesDirectory)/PostBuildLogs' + SourceFolder: '$(System.DefaultWorkingDirectory)/PostBuildLogs' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/PostBuildLogs' diff --git a/eng/common/core-templates/steps/source-build.yml b/eng/common/core-templates/steps/source-build.yml index 37133b55b75..0718e4ba902 100644 --- a/eng/common/core-templates/steps/source-build.yml +++ b/eng/common/core-templates/steps/source-build.yml @@ -11,6 +11,10 @@ parameters: # for details. The entire object is described in the 'job' template for simplicity, even though # the usage of the properties on this object is split between the 'job' and 'steps' templates. platform: {} + + # Optional list of directories to ignore for component governance scans. + componentGovernanceIgnoreDirectories: [] + is1ESPipeline: false steps: @@ -97,7 +101,7 @@ steps: - task: CopyFiles@2 displayName: Prepare BuildLogs staging directory inputs: - SourceFolder: '$(Build.SourcesDirectory)' + SourceFolder: '$(System.DefaultWorkingDirectory)' Contents: | **/*.log **/*.binlog @@ -126,5 +130,8 @@ steps: parameters: displayName: Component Detection (Exclude upstream cache) is1ESPipeline: ${{ parameters.is1ESPipeline }} - componentGovernanceIgnoreDirectories: '$(Build.SourcesDirectory)/artifacts/sb/src/artifacts/obj/source-built-upstream-cache' + ${{ if eq(length(parameters.componentGovernanceIgnoreDirectories), 0) }}: + componentGovernanceIgnoreDirectories: '$(System.DefaultWorkingDirectory)/artifacts/sb/src/artifacts/obj/source-built-upstream-cache' + ${{ else }}: + componentGovernanceIgnoreDirectories: ${{ join(',', parameters.componentGovernanceIgnoreDirectories) }} disableComponentGovernance: ${{ eq(variables['System.TeamProject'], 'public') }} diff --git a/eng/common/internal/NuGet.config b/eng/common/internal/NuGet.config index 19d3d311b16..f70261ed689 100644 --- a/eng/common/internal/NuGet.config +++ b/eng/common/internal/NuGet.config @@ -4,4 +4,7 @@ + + + diff --git a/eng/common/post-build/nuget-verification.ps1 b/eng/common/post-build/nuget-verification.ps1 index a365194a938..ac5c69ffcac 100644 --- a/eng/common/post-build/nuget-verification.ps1 +++ b/eng/common/post-build/nuget-verification.ps1 @@ -30,7 +30,7 @@ [CmdletBinding(PositionalBinding = $false)] param( [string]$NuGetExePath, - [string]$PackageSource = "https://api.nuget.org/v3/index.json", + [string]$PackageSource = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json", [string]$DownloadPath, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$args diff --git a/eng/common/post-build/publish-using-darc.ps1 b/eng/common/post-build/publish-using-darc.ps1 index 90b58e32a87..a261517ef90 100644 --- a/eng/common/post-build/publish-using-darc.ps1 +++ b/eng/common/post-build/publish-using-darc.ps1 @@ -5,7 +5,8 @@ param( [Parameter(Mandatory=$false)][string] $MaestroApiEndPoint = 'https://maestro.dot.net', [Parameter(Mandatory=$true)][string] $WaitPublishingFinish, [Parameter(Mandatory=$false)][string] $ArtifactsPublishingAdditionalParameters, - [Parameter(Mandatory=$false)][string] $SymbolPublishingAdditionalParameters + [Parameter(Mandatory=$false)][string] $SymbolPublishingAdditionalParameters, + [Parameter(Mandatory=$false)][string] $RequireDefaultChannels ) try { @@ -33,6 +34,10 @@ try { if ("false" -eq $WaitPublishingFinish) { $optionalParams.Add("--no-wait") | Out-Null } + + if ("true" -eq $RequireDefaultChannels) { + $optionalParams.Add("--default-channels-required") | Out-Null + } & $darc add-build-to-channel ` --id $buildId ` diff --git a/eng/common/template-guidance.md b/eng/common/template-guidance.md index 98bbc1ded0b..4bf4cf41bd7 100644 --- a/eng/common/template-guidance.md +++ b/eng/common/template-guidance.md @@ -50,7 +50,7 @@ extends: - task: CopyFiles@2 displayName: Gather build output inputs: - SourceFolder: '$(Build.SourcesDirectory)/artifacts/marvel' + SourceFolder: '$(System.DefaultWorkingDirectory)/artifacts/marvel' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/artifacts/marvel' ``` diff --git a/eng/common/templates-official/job/job.yml b/eng/common/templates-official/job/job.yml index 817555505aa..81ea7a261f2 100644 --- a/eng/common/templates-official/job/job.yml +++ b/eng/common/templates-official/job/job.yml @@ -3,7 +3,7 @@ parameters: enableSbom: true runAsPublic: false PackageVersion: 9.0.0 - BuildDropPath: '$(Build.SourcesDirectory)/artifacts' + BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' jobs: - template: /eng/common/core-templates/job/job.yml diff --git a/eng/common/templates-official/variables/sdl-variables.yml b/eng/common/templates-official/variables/sdl-variables.yml index dbdd66d4a4b..f1311bbb1b3 100644 --- a/eng/common/templates-official/variables/sdl-variables.yml +++ b/eng/common/templates-official/variables/sdl-variables.yml @@ -4,4 +4,4 @@ variables: - name: DefaultGuardianVersion value: 0.109.0 - name: GuardianPackagesConfigFile - value: $(Build.SourcesDirectory)\eng\common\sdl\packages.config \ No newline at end of file + value: $(System.DefaultWorkingDirectory)\eng\common\sdl\packages.config \ No newline at end of file diff --git a/eng/common/templates/job/job.yml b/eng/common/templates/job/job.yml index d1aeb92fcea..5bdd3dd85fd 100644 --- a/eng/common/templates/job/job.yml +++ b/eng/common/templates/job/job.yml @@ -6,7 +6,7 @@ parameters: enableSbom: true runAsPublic: false PackageVersion: 9.0.0 - BuildDropPath: '$(Build.SourcesDirectory)/artifacts' + BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' jobs: - template: /eng/common/core-templates/job/job.yml @@ -75,7 +75,7 @@ jobs: parameters: is1ESPipeline: false args: - targetPath: '$(Build.SourcesDirectory)\eng\common\BuildConfiguration' + targetPath: '$(System.DefaultWorkingDirectory)\eng\common\BuildConfiguration' artifactName: 'BuildConfiguration' displayName: 'Publish build retry configuration' continueOnError: true diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 22b49e09d09..9b3ad8840fd 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -416,7 +416,7 @@ function InitializeVisualStudioMSBuild([bool]$install, [object]$vsRequirements = # Locate Visual Studio installation or download x-copy msbuild. $vsInfo = LocateVisualStudio $vsRequirements - if ($vsInfo -ne $null) { + if ($vsInfo -ne $null -and $env:ForceUseXCopyMSBuild -eq $null) { # Ensure vsInstallDir has a trailing slash $vsInstallDir = Join-Path $vsInfo.installationPath "\" $vsMajorVersion = $vsInfo.installationVersion.Split('.')[0] diff --git a/eng/packages/General-LTS.props b/eng/packages/General-LTS.props index 884d874c5e1..54b54eff5ae 100644 --- a/eng/packages/General-LTS.props +++ b/eng/packages/General-LTS.props @@ -2,8 +2,9 @@ - + + @@ -17,6 +18,7 @@ + @@ -28,6 +30,7 @@ + diff --git a/eng/packages/General-net10.props b/eng/packages/General-net10.props new file mode 100644 index 00000000000..e40b1c44c90 --- /dev/null +++ b/eng/packages/General-net10.props @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/packages/General-net9.props b/eng/packages/General-net9.props index 341f69458a8..fecfa5b84e2 100644 --- a/eng/packages/General-net9.props +++ b/eng/packages/General-net9.props @@ -2,8 +2,9 @@ - + + @@ -17,6 +18,7 @@ + @@ -28,6 +30,7 @@ + @@ -35,6 +38,7 @@ + diff --git a/eng/packages/General.props b/eng/packages/General.props index d62332f9999..503e2c1c321 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -1,26 +1,32 @@ - - + + - - + + + + + + + - + + @@ -33,6 +39,7 @@ + @@ -52,5 +59,6 @@ + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index be1cb37a609..47097ab042a 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -2,33 +2,41 @@ - + + + + - + - + + + $(NoWarn);NU1903 + + @@ -39,8 +47,13 @@ - + + + + + + diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index 6c54a911e84..8df9a2bede6 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -48,6 +48,7 @@ steps: - script: ${{ parameters.buildScript }} -restore + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/restore.binlog displayName: Restore @@ -57,6 +58,7 @@ steps: - script: ${{ parameters.buildScript }} -restore + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/restore2.binlog displayName: Restore solution @@ -78,7 +80,7 @@ steps: - script: ${{ parameters.buildScript }} -pack -configuration ${{ parameters.buildConfig }} - -warnAsError 1 + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/pack.binlog /p:Restore=false /p:Build=false $(_OfficialBuildIdArgs) @@ -176,6 +178,7 @@ steps: # Publishing will happen in a subsequent step - script: ${{ parameters.buildScript }} -projects $(Build.SourcesDirectory)/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj + -warnAsError ${{ parameters.warnAsError }} -pack -configuration ${{ parameters.buildConfig }} /bl:${{ parameters.repoLogPath }}/transport.binlog @@ -187,11 +190,12 @@ steps: displayName: Build Azure DevOps plugin - script: ${{ parameters.buildScript }} + -restore -sign $(_SignArgs) -publish $(_PublishArgs) -configuration ${{ parameters.buildConfig }} - -warnAsError 1 + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/publish.binlog - /p:Restore=false /p:Build=false + /p:Build=false $(_OfficialBuildIdArgs) displayName: Sign and publish diff --git a/eng/sdl-tsa-vars.config b/eng/sdl-tsa-vars.config new file mode 100644 index 00000000000..76961251412 --- /dev/null +++ b/eng/sdl-tsa-vars.config @@ -0,0 +1,14 @@ +-SourceToolsList @("policheck","credscan") +-ArtifactToolsList @("binskim") +-TsaInstanceURL https://devdiv.visualstudio.com/ +-TsaProjectName DEVDIV +-TsaNotificationEmail aspnetcore-build@microsoft.com +-TsaCodebaseAdmin REDMOND\aspnetcore-build +-TsaBugAreaPath "DevDiv\ASP.NET Core\Policy Violations" +-TsaIterationPath DevDiv +-TsaRepositoryName dotnetextensions +-TsaCodebaseName dotnetextensions +-TsaOnboard $True +-TsaPublish $True +-PoliCheckAdditionalRunConfigParams @("UserExclusionPath < $(Build.SourcesDirectory)/.config/PoliCheckExclusions.xml") +-CrScanAdditionalRunConfigParams @("SuppressionsPath < $(Build.SourcesDirectory)/.config/CredScanSuppressions.json") diff --git a/es-metadata.yml b/es-metadata.yml new file mode 100644 index 00000000000..9061e11812c --- /dev/null +++ b/es-metadata.yml @@ -0,0 +1,8 @@ +schemaVersion: 0.0.1 +isProduction: true +accountableOwners: + service: 4db45fa9-fb0f-43ce-b523-ad1da773dfbc +routing: + defaultAreaPath: + org: devdiv + path: DevDiv\ASP.NET Core diff --git a/global.json b/global.json index 6dc143e2fb7..b0500c7f83e 100644 --- a/global.json +++ b/global.json @@ -1,24 +1,24 @@ { "sdk": { - "version": "9.0.105" + "version": "10.0.100-rc.1.25451.107" }, "tools": { - "dotnet": "9.0.105", + "dotnet": "10.0.100-rc.1.25451.107", "runtimes": { "dotnet": [ "8.0.0", - "9.0.0-rc.1.24431.7" + "9.0.0" ], "aspnetcore": [ "8.0.0", - "9.0.0-rc.1.24452.1" + "9.0.0" ] } }, "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25225.6", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25225.6" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25515.2", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25515.2" } } diff --git a/scripts/ConfigureEvaluationTests.ps1 b/scripts/ConfigureEvaluationTests.ps1 index d58cb1352db..b00624ac29d 100644 --- a/scripts/ConfigureEvaluationTests.ps1 +++ b/scripts/ConfigureEvaluationTests.ps1 @@ -31,7 +31,7 @@ if ($Configure -and $Unconfigure) { Exit 1 } -if (!(Test-Path $ConfigRoot)) { +if (-not $ConfigRoot -or -not (Test-Path $ConfigRoot)) { $ConfigRoot = "$HOME/.config/dotnet-extensions" } diff --git a/scripts/DiffBranches.ps1 b/scripts/DiffBranches.ps1 new file mode 100644 index 00000000000..3224f5fac17 --- /dev/null +++ b/scripts/DiffBranches.ps1 @@ -0,0 +1,101 @@ +<# +.SYNOPSIS + A script to diff the contents of folders matching a specified pattern between two specified branches. + +.DESCRIPTION + The script uses git to determine the set of files (under folders matching a specified pattern) that are different + between the specified branches. It can also optionally display the line diffs for these files. + +.PARAMETER baseline + The baseline branch against which the specified target branch is to be compared. (Defaults to 'main' if omitted.) +.PARAMETER target + The target branch which is to be compared against the specified baseline branch. +.PARAMETER folderPattern + The pattern that selects the folders that are to be compared. (Defaults to '*.AI.* if omitted.) +.PARAMETER showDiff + Determines whether or not line diffs should be displayed for the differing files. (Defaults to 'false' if omitted.) + +.EXAMPLE + PS> .\DiffBranches -target "release/9.5" -folderPattern "*.Evaluation.*" +.EXAMPLE + PS> .\DiffBranches -baseline "release/9.4" -target "release/9.5" -folderPattern "*.Evaluation.*" +.EXAMPLE + PS> .\DiffBranches -target "release/9.5" -showDiff +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory=$false)] + [string]$baseline = "main", + + [Parameter(Mandatory=$true)] + [string]$target, + + [Parameter(Mandatory=$false)] + [string]$folderPattern = "*.AI.*", + + [Parameter(Mandatory=$false)] + [switch]$showDiff +) + +function Invoke-GitCommand { + param( + [Parameter(Mandatory=$true)] + [string]$Command, + + [Parameter(Mandatory=$false)] + [switch]$UseCmd + ) + + if ($UseCmd) { + $Command = "cmd.exe /c $Command" + } + + Write-Host "Executing $Command" -ForegroundColor Blue + $result = Invoke-Expression $Command + return $result +} + +# Save the current directory +$originalLocation = Get-Location + +try { + # Get the root directory of the git repository + $gitRootCommand = "git rev-parse --show-toplevel" + $repoRoot = Invoke-GitCommand -Command $gitRootCommand + Write-Host "Repo root is $repoRoot" -ForegroundColor Blue + + # Change to the repository root directory + Set-Location $repoRoot + + # Get all changed files between the two branches + $gitFilesCommand = "git diff --name-only $baseline..$target" + $changedFiles = Invoke-GitCommand -Command $gitFilesCommand + + # Filter for files under folders containing the specified pattern + $matchedFiles = $changedFiles | Where-Object { + $path = $_ + $folders = $path -split '/' + $folders | Where-Object { $_ -like $folderPattern } | Select-Object -First 1 + } + + if ($matchedFiles.Count -eq 0) { + Write-Host "No changes detected." -ForegroundColor Green + } else { + Write-Host "Changes detected in following files:" -ForegroundColor Yellow + $matchedFiles | ForEach-Object { Write-Host " $_" } + + if ($showDiff) { + Write-Host "File diffs:" -ForegroundColor Yellow + + $gitDiffCommand = "git -C `"$repoRoot`" diff --color $baseline..$target -- $($matchedFiles -join ' ')" + + # Use the -UseCmd switch to run the command with cmd.exe (preserves color and paging) + Invoke-GitCommand -Command $gitDiffCommand -UseCmd + } + } +} +finally { + # Return to the original directory even if an error occurs + Set-Location $originalLocation +} diff --git a/src/Analyzers/.editorconfig b/src/Analyzers/.editorconfig index c46aa61b062..4f73565695b 100644 --- a/src/Analyzers/.editorconfig +++ b/src/Analyzers/.editorconfig @@ -482,11 +482,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs index 76973c7fc30..6424a114757 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; diff --git a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs index 27a2e6a5493..0fa9fb224f3 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.LocalAnalyzers.Json; namespace Microsoft.Extensions.LocalAnalyzers.Json; diff --git a/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs b/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs index 46af17c1e0b..ae718238cca 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using Microsoft.CodeAnalysis; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] diff --git a/src/Generators/.editorconfig b/src/Generators/.editorconfig index c46aa61b062..4f73565695b 100644 --- a/src/Generators/.editorconfig +++ b/src/Generators/.editorconfig @@ -482,11 +482,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs index 68549c11540..de0252657b7 100644 --- a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs +++ b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using Microsoft.Gen.Logging.Model; using Microsoft.Gen.Shared; @@ -91,7 +90,9 @@ private void GenLogMethod(LoggingMethod lm) } else { - OutLn($"new({GetNonRandomizedHashCode(eventName)}, {eventName}),"); + var eventNameToCalcId = string.IsNullOrWhiteSpace(lm.EventName) ? lm.Name : lm.EventName!; + var calculatedEventId = GetNonRandomizedHashCode(eventNameToCalcId); + OutLn($"new({calculatedEventId}, {eventName}),"); } OutLn($"{stateName},"); diff --git a/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs b/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs index 7f90823e9a4..b14ce4dc4e5 100644 --- a/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs +++ b/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Diagnostics; diff --git a/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs b/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs index f20dd59f52b..ab351819b57 100644 --- a/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs +++ b/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using Microsoft.CodeAnalysis; namespace Microsoft.Gen.Logging.Parsing; diff --git a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs index 3a8981c4918..23aebcdb00f 100644 --- a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs +++ b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; diff --git a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs index c5f16008de7..484361d7347 100644 --- a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs +++ b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Threading; using Microsoft.CodeAnalysis; diff --git a/src/Generators/Microsoft.Gen.Metrics/Parser.cs b/src/Generators/Microsoft.Gen.Metrics/Parser.cs index da05c6a51ec..680bf579f2c 100644 --- a/src/Generators/Microsoft.Gen.Metrics/Parser.cs +++ b/src/Generators/Microsoft.Gen.Metrics/Parser.cs @@ -422,7 +422,7 @@ private void GetTagDescription( if (!methodAttribute.ConstructorArguments.IsDefaultOrEmpty && methodAttribute.ConstructorArguments[0].Kind == TypedConstantKind.Type) { - KeyValuePair namedArg; + KeyValuePair namedArg = default; var ctorArg = methodAttribute.ConstructorArguments[0]; if (!methodAttribute.NamedArguments.IsDefaultOrEmpty) @@ -574,7 +574,7 @@ private bool CheckMethodReturnType(IMethodSymbol methodSymbol) returnType.TypeKind != TypeKind.Error) { // Make sure return type is not from existing known type - Diag(DiagDescriptors.ErrorInvalidMethodReturnType, methodSymbol.ReturnType.GetLocation(), methodSymbol.Name); + Diag(DiagDescriptors.ErrorInvalidMethodReturnType, returnType.GetLocation(), returnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); return false; } diff --git a/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs b/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs index ee610c0c032..0354528f810 100644 --- a/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs +++ b/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs @@ -6,6 +6,7 @@ using Microsoft.Gen.Metrics.Model; namespace Microsoft.Gen.MetricsReports; + internal static class MetricsReportsHelpers { internal static ReportedMetricClass[] MapToCommonModel(IReadOnlyList meteringClasses, string? rootNamespace) diff --git a/src/Generators/Shared/RoslynExtensions.cs b/src/Generators/Shared/RoslynExtensions.cs index 82860a09f59..ef4f7e07911 100644 --- a/src/Generators/Shared/RoslynExtensions.cs +++ b/src/Generators/Shared/RoslynExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -103,33 +102,6 @@ internal static class RoslynExtensions ? throw new ArgumentException("The input type must correspond to a named type symbol.") : GetBestTypeByMetadataName(compilation, type.FullName); - public static ImmutableArray ToImmutableArray(this ReadOnlySpan span) - { -#pragma warning disable S109 // Magic numbers should not be used - switch (span.Length) - { - case 0: - return ImmutableArray.Empty; - case 1: - return ImmutableArray.Create(span[0]); - case 2: - return ImmutableArray.Create(span[0], span[1]); - case 3: - return ImmutableArray.Create(span[0], span[1], span[2]); - case 4: - return ImmutableArray.Create(span[0], span[1], span[2], span[3]); - default: - var builder = ImmutableArray.CreateBuilder(span.Length); - foreach (var item in span) - { - builder.Add(item); - } - - return builder.MoveToImmutable(); - } -#pragma warning restore S109 // Magic numbers should not be used - } - public static SimpleNameSyntax GetUnqualifiedName(this NameSyntax name) => name switch { diff --git a/src/LegacySupport/.editorconfig b/src/LegacySupport/.editorconfig index bc980461f04..757d371f53c 100644 --- a/src/LegacySupport/.editorconfig +++ b/src/LegacySupport/.editorconfig @@ -482,11 +482,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/src/LegacySupport/CollectionBuilder/CollectionBuilderAttribute.cs b/src/LegacySupport/CollectionBuilder/CollectionBuilderAttribute.cs new file mode 100644 index 00000000000..569daa70dff --- /dev/null +++ b/src/LegacySupport/CollectionBuilder/CollectionBuilderAttribute.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] +internal sealed class CollectionBuilderAttribute : Attribute +{ + public CollectionBuilderAttribute(Type builderType, string methodName) + { + BuilderType = builderType; + MethodName = methodName; + } + + public Type BuilderType { get; } + public string MethodName { get; } +} diff --git a/src/LegacySupport/CollectionBuilder/README.md b/src/LegacySupport/CollectionBuilder/README.md new file mode 100644 index 00000000000..15e9274d433 --- /dev/null +++ b/src/LegacySupport/CollectionBuilder/README.md @@ -0,0 +1,7 @@ +To use this source in your project, add the following to your `.csproj` file: + +```xml + + true + +``` diff --git a/src/Libraries/.editorconfig b/src/Libraries/.editorconfig index b74b979edaf..e6feaee1f0f 100644 --- a/src/Libraries/.editorconfig +++ b/src/Libraries/.editorconfig @@ -208,7 +208,7 @@ dotnet_code_quality.CA1030.api_surface = public # Title : Do not catch general exception types # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031 -dotnet_diagnostic.CA1031.severity = warning +dotnet_diagnostic.CA1031.severity = suggestion # Title : Implement standard exception constructors # Category : Design @@ -223,7 +223,7 @@ dotnet_diagnostic.CA1033.severity = warning # Title : Nested types should not be visible # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1034 -dotnet_diagnostic.CA1034.severity = warning +dotnet_diagnostic.CA1034.severity = suggestion # Title : Override methods on comparable types # Category : Design @@ -293,19 +293,19 @@ dotnet_code_quality.CA1052.api_surface = all # Title : URI-like parameters should not be strings # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1054 -dotnet_diagnostic.CA1054.severity = warning +dotnet_diagnostic.CA1054.severity = suggestion dotnet_code_quality.CA1054.api_surface = public # Title : URI-like return values should not be strings # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1055 -dotnet_diagnostic.CA1055.severity = warning +dotnet_diagnostic.CA1055.severity = suggestion dotnet_code_quality.CA1055.api_surface = public # Title : URI-like properties should not be strings # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1056 -dotnet_diagnostic.CA1056.severity = warning +dotnet_diagnostic.CA1056.severity = suggestion dotnet_code_quality.CA1056.api_surface = public # Title : Types should not extend certain base types @@ -497,12 +497,7 @@ dotnet_diagnostic.CA1507.severity = warning # Title : Avoid dead conditional code # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning +dotnet_diagnostic.CA1508.severity = suggestion # Title : Invalid entry in code metrics rule specification file # Category : Maintainability @@ -578,7 +573,7 @@ dotnet_diagnostic.CA1715.severity = none # Title : Identifiers should not match keywords # Category : Naming # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1716 -dotnet_diagnostic.CA1716.severity = warning +dotnet_diagnostic.CA1716.severity = suggestion dotnet_code_quality.CA1716.api_surface = all dotnet_code_quality.CA1716.analyzed_symbol_kinds = all @@ -1137,7 +1132,7 @@ dotnet_diagnostic.CA2226.severity = none # Title : Collection properties should be read only # Category : Usage # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2227 -dotnet_diagnostic.CA2227.severity = warning +dotnet_diagnostic.CA2227.severity = suggestion # Title : Implement serialization constructors # Category : Usage @@ -1783,7 +1778,7 @@ dotnet_diagnostic.EA0001.severity = warning # Title : Use 'System.TimeProvider' to make the code easier to test # Category : Reliability # Help Link: https://aka.ms/dotnet-extensions-warnings/EA0002 -dotnet_diagnostic.EA0002.severity = warning +dotnet_diagnostic.EA0002.severity = suggestion # Title : Use the character-based overloads of 'String.StartsWith' or 'String.EndsWith' # Category : Performance @@ -1828,7 +1823,7 @@ dotnet_diagnostic.EA0010.severity = warning # Title : Consider removing unnecessary conditional access operator (?) # Category : Performance # Help Link: https://aka.ms/dotnet-extensions-warnings/EA0011 -dotnet_diagnostic.EA0011.severity = warning +dotnet_diagnostic.EA0011.severity = suggestion # Title : Consider removing unnecessary null coalescing assignment (??=) # Category : Performance @@ -2936,12 +2931,12 @@ dotnet_diagnostic.S101.severity = none # Title : Lines should not be too long # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-103 -dotnet_diagnostic.S103.severity = warning +dotnet_diagnostic.S103.severity = suggestion # Title : Files should not have too many lines of code # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-104 -dotnet_diagnostic.S104.severity = warning +dotnet_diagnostic.S104.severity = none # Title : Finalizers should not throw exceptions # Category : Blocker Bug @@ -2968,12 +2963,12 @@ dotnet_diagnostic.S1066.severity = none # Title : Expressions should not be too complex # Category : Critical Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1067 -dotnet_diagnostic.S1067.severity = warning +dotnet_diagnostic.S1067.severity = suggestion # Title : Methods should not have too many parameters # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-107 -dotnet_diagnostic.S107.severity = warning +dotnet_diagnostic.S107.severity = suggestion # Title : URIs should not be hardcoded # Category : Minor Code Smell @@ -2988,7 +2983,7 @@ dotnet_diagnostic.S108.severity = warning # Title : Magic numbers should not be used # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-109 -dotnet_diagnostic.S109.severity = warning +dotnet_diagnostic.S109.severity = suggestion # Title : Inheritance tree of classes should not be too deep # Category : Major Code Smell @@ -3039,7 +3034,7 @@ dotnet_diagnostic.S112.severity = none # Title : Assignments should not be made from within sub-expressions # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1121 -dotnet_diagnostic.S1121.severity = warning +dotnet_diagnostic.S1121.severity = suggestion # Title : "Obsolete" attributes should include explanations # Category : Major Code Smell @@ -3288,12 +3283,12 @@ dotnet_diagnostic.S1656.severity = warning # Title : Multiple variables should not be declared on the same line # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1659 -dotnet_diagnostic.S1659.severity = warning +dotnet_diagnostic.S1659.severity = suggestion # Title : An abstract class should have both abstract and concrete methods # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1694 -dotnet_diagnostic.S1694.severity = warning +dotnet_diagnostic.S1694.severity = suggestion # Title : NullReferenceException should not be caught # Category : Major Code Smell @@ -3380,7 +3375,7 @@ dotnet_diagnostic.S1944.severity = warning # Title : "for" loop increment clauses should modify the loops' counters # Category : Critical Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1994 -dotnet_diagnostic.S1994.severity = warning +dotnet_diagnostic.S1994.severity = suggestion # Title : Hashes should include an unpredictable salt # Category : Critical Vulnerability @@ -3617,7 +3612,7 @@ dotnet_diagnostic.S2330.severity = warning # Title : Redundant modifiers should not be used # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-2333 -dotnet_diagnostic.S2333.severity = warning +dotnet_diagnostic.S2333.severity = suggestion # Title : Public constant members should not be used # Category : Critical Code Smell @@ -4021,7 +4016,7 @@ dotnet_diagnostic.S3251.severity = warning # Title : Constructor and destructor declarations should not be redundant # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3253 -dotnet_diagnostic.S3253.severity = warning +dotnet_diagnostic.S3253.severity = suggestion # Title : Default parameter values should not be passed as arguments # Category : Minor Code Smell @@ -4104,7 +4099,7 @@ dotnet_diagnostic.S3353.severity = warning # Title : Ternary operators should not be nested # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3358 -dotnet_diagnostic.S3358.severity = warning +dotnet_diagnostic.S3358.severity = suggestion # Title : Date and time should not be used as a type for primary keys # Category : Minor Bug @@ -4270,7 +4265,7 @@ dotnet_diagnostic.S3603.severity = warning # Title : Member initializer values should not be redundant # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3604 -dotnet_diagnostic.S3604.severity = warning +dotnet_diagnostic.S3604.severity = suggestion # Title : Nullable type comparison should not be redundant # Category : Major Bug @@ -4538,12 +4533,12 @@ dotnet_diagnostic.S3994.severity = none # Title : URI return values should not be strings # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3995 -dotnet_diagnostic.S3995.severity = warning +dotnet_diagnostic.S3995.severity = suggestion # Title : URI properties should not be strings # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3996 -dotnet_diagnostic.S3996.severity = warning +dotnet_diagnostic.S3996.severity = suggestion # Title : String URI overloads should call "System.Uri" overloads # Category : Major Code Smell @@ -5201,7 +5196,7 @@ dotnet_diagnostic.S881.severity = suggestion # Title : "goto" statement should not be used # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-907 -dotnet_diagnostic.S907.severity = warning +dotnet_diagnostic.S907.severity = suggestion # Title : Parameter names should match base declaration and other partial definitions # Category : Critical Code Smell @@ -5648,12 +5643,12 @@ dotnet_diagnostic.SA1201.severity = none # Title : Elements should be ordered by access # Category : StyleCop.CSharp.OrderingRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1202.md -dotnet_diagnostic.SA1202.severity = warning +dotnet_diagnostic.SA1202.severity = suggestion # Title : Constants should appear before fields # Category : StyleCop.CSharp.OrderingRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1203.md -dotnet_diagnostic.SA1203.severity = warning +dotnet_diagnostic.SA1203.severity = suggestion # Title : Static elements should appear before instance elements # Category : StyleCop.CSharp.OrderingRules @@ -5812,7 +5807,7 @@ dotnet_diagnostic.SA1314.severity = none # Title : Tuple element names should use correct casing # Category : StyleCop.CSharp.NamingRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1316.md -dotnet_diagnostic.SA1316.severity = warning +dotnet_diagnostic.SA1316.severity = suggestion # Title : Access modifier should be declared # Category : StyleCop.CSharp.MaintainabilityRules @@ -5894,7 +5889,7 @@ dotnet_diagnostic.SA1414.severity = warning # Title : Braces for multi-line statements should not share line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1500.md -dotnet_diagnostic.SA1500.severity = warning +dotnet_diagnostic.SA1500.severity = suggestion # rule does not work well with field-based property initializers # Title : Statement should not be on a single line # Category : StyleCop.CSharp.LayoutRules @@ -5962,7 +5957,7 @@ dotnet_diagnostic.SA1512.severity = none # Title : Closing brace should be followed by blank line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1513.md -dotnet_diagnostic.SA1513.severity = warning +dotnet_diagnostic.SA1513.severity = suggestion # rule does not work well with field-based property initializers # Title : Element documentation header should be preceded by blank line # Category : StyleCop.CSharp.LayoutRules @@ -6292,7 +6287,7 @@ dotnet_diagnostic.VSTHRD002.severity = warning # Title : Avoid awaiting foreign Tasks # Category : Usage # Help Link: https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD003.md -dotnet_diagnostic.VSTHRD003.severity = warning +dotnet_diagnostic.VSTHRD003.severity = suggestion # Title : Await SwitchToMainThreadAsync # Category : Usage @@ -6402,3 +6397,7 @@ dotnet_diagnostic.VSTHRD114.severity = error # Help Link: https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD200.md dotnet_diagnostic.VSTHRD200.severity = warning +# Title : Async method lacks 'await' operators and will run synchronously +# Category : Reliability +# Help Link: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs1998 +dotnet_diagnostic.CS1998.severity = suggestion diff --git a/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs b/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs index 83bd97fb515..876ace53876 100644 --- a/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs +++ b/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs @@ -14,8 +14,6 @@ namespace Microsoft.AspNetCore.AsyncState; /// Note that is not public, so nobody else can use it. /// /// The type of the value to store into . -#pragma warning disable S1694 // Convert this 'abstract' class to a concrete type with protected constructor. internal abstract class TypeWrapper -#pragma warning restore S1694 // Convert this 'abstract' class to a concrete type with protected constructor. { } diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerIncomingRequestLoggingBuilderExtensions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerIncomingRequestLoggingBuilderExtensions.cs index 8d3f7411367..840fc80c6cd 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerIncomingRequestLoggingBuilderExtensions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerIncomingRequestLoggingBuilderExtensions.cs @@ -3,7 +3,6 @@ #if NET9_0_OR_GREATER using System; -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Diagnostics.Buffering; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -11,7 +10,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.Buffering; using Microsoft.Extensions.Options; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging; @@ -19,7 +17,6 @@ namespace Microsoft.Extensions.Logging; /// /// Lets you register per incoming request log buffering in a dependency injection container. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public static class PerIncomingRequestLoggingBuilderExtensions { /// @@ -39,7 +36,10 @@ public static ILoggingBuilder AddPerIncomingRequestBuffer(this ILoggingBuilder b _ = Throw.IfNull(configuration); _ = builder.Services - .AddSingleton>(new PerRequestLogBufferingConfigureOptions(configuration)) + .AddSingleton>( + new PerRequestLogBufferingConfigureOptions(configuration)) + .AddSingleton>( + new ConfigurationChangeTokenSource(configuration)) .AddOptionsWithValidateOnStart() .Services.AddOptionsWithValidateOnStart(); @@ -64,8 +64,8 @@ public static ILoggingBuilder AddPerIncomingRequestBuffer(this ILoggingBuilder b _ = Throw.IfNull(builder); _ = Throw.IfNull(configure); - _ = builder.Services - .AddOptionsWithValidateOnStart() + _ = builder + .Services.AddOptionsWithValidateOnStart() .Services.AddOptionsWithValidateOnStart() .Configure(configure); @@ -92,8 +92,8 @@ public static ILoggingBuilder AddPerIncomingRequestBuffer(this ILoggingBuilder b { _ = Throw.IfNull(builder); - _ = builder.Services - .AddOptionsWithValidateOnStart() + _ = builder + .Services.AddOptionsWithValidateOnStart() .Services.AddOptionsWithValidateOnStart() .Configure(options => { diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferManager.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferManager.cs index de51e26eb58..60dafd1e177 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferManager.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferManager.cs @@ -12,10 +12,11 @@ namespace Microsoft.AspNetCore.Diagnostics.Buffering; internal sealed class PerRequestLogBufferManager : PerRequestLogBuffer { + internal readonly IOptionsMonitor Options; + private readonly GlobalLogBuffer _globalBuffer; private readonly IHttpContextAccessor _httpContextAccessor; private readonly LogBufferingFilterRuleSelector _ruleSelector; - private readonly IOptionsMonitor _options; public PerRequestLogBufferManager( GlobalLogBuffer globalBuffer, @@ -26,7 +27,7 @@ public PerRequestLogBufferManager( _globalBuffer = globalBuffer; _httpContextAccessor = httpContextAccessor; _ruleSelector = ruleSelector; - _options = options; + Options = options; } public override void Flush() @@ -47,7 +48,7 @@ public override bool TryEnqueue(IBufferedLogger bufferedLogger, in LogEn IncomingRequestLogBufferHolder? bufferHolder = httpContext.RequestServices.GetService(); IncomingRequestLogBuffer? buffer = bufferHolder?.GetOrAdd(category, _ => - new IncomingRequestLogBuffer(bufferedLogger, category, _ruleSelector, _options)); + new IncomingRequestLogBuffer(bufferedLogger, category, _ruleSelector, Options)); if (buffer is null) { diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs index 0b281f06edb..5e0cc99f779 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs @@ -5,17 +5,14 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Diagnostics.Buffering; using Microsoft.Shared.Data.Validation; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.AspNetCore.Diagnostics.Buffering; /// /// The options for log buffering per each incoming request. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public class PerRequestLogBufferingOptions { private const int DefaultPerRequestBufferSizeInBytes = 500 * 1024 * 1024; // 500 MB. @@ -61,7 +58,6 @@ public class PerRequestLogBufferingOptions [Range(MinimumPerRequestBufferSizeInBytes, MaximumPerRequestBufferSizeInBytes)] public int MaxPerRequestBufferSizeInBytes { get; set; } = DefaultPerRequestBufferSizeInBytes; -#pragma warning disable CA2227 // Collection properties should be read only - setter is necessary for options pattern /// /// Gets or sets the collection of used for filtering log messages for the purpose of further buffering. /// @@ -74,6 +70,5 @@ public class PerRequestLogBufferingOptions /// If a log entry size is greater than , it will not be buffered and will be emitted normally. /// public IList Rules { get; set; } = []; -#pragma warning restore CA2227 } #endif diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs index 31a253f68db..f69b711a206 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs @@ -160,7 +160,6 @@ public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) { foreach (var enricher in _enrichers) { -#pragma warning disable CA1031 // Do not catch general exception types try { enricher.Enrich(loggerMessageState, context); @@ -169,7 +168,6 @@ public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) { _logger.EnricherFailed(ex, enricher.GetType().Name); } -#pragma warning restore CA1031 // Do not catch general exception types } foreach (var pair in loggerMessageState) diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs index d2cb34ac547..0310d59a0d7 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs @@ -55,9 +55,7 @@ public class LoggingRedactionOptions /// If you don't want a parameter to be redacted, mark it as . /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary RouteParameterDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a map between request headers to be logged and their data classification. @@ -66,9 +64,7 @@ public class LoggingRedactionOptions /// The default value is an empty dictionary, which means that no request header is logged by default. /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary RequestHeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a map between response headers to be logged and their data classification. @@ -77,9 +73,7 @@ public class LoggingRedactionOptions /// The default value is an empty dictionary, which means that no response header is logged by default. /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary ResponseHeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets the set of HTTP paths that should be excluded from logging. @@ -97,9 +91,7 @@ public class LoggingRedactionOptions /// - "/probe/ready". /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public ISet ExcludePathStartsWith { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a value indicating whether to report unmatched routes. diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs index 81483224810..e18822e7ad5 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs @@ -23,7 +23,5 @@ public class RequestHeadersLogEnricherOptions /// [Required] [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary HeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj index 11f769c026a..2d6e518f1b5 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj @@ -4,13 +4,16 @@ ASP.NET Core middleware for collecting high-quality telemetry. $(PackageTags);aspnetcore Telemetry + + $(NoWarn);LA0006 $(NetCoreTargetFrameworks) true + + $(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration true - true true false false @@ -38,7 +41,6 @@ - diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json index e76e745c5f1..f445bb581f5 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.AspNetCore.Diagnostics.Middleware, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.AspNetCore.Diagnostics.Middleware, Version=9.7.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "static class Microsoft.Extensions.DependencyInjection.HttpLoggingServiceCollectionExtensions", @@ -122,6 +122,10 @@ "Member": "System.Collections.Generic.ISet Microsoft.AspNetCore.Diagnostics.Logging.LoggingRedactionOptions.ExcludePathStartsWith { get; set; }", "Stage": "Experimental" }, + { + "Member": "bool Microsoft.AspNetCore.Diagnostics.Logging.LoggingRedactionOptions.IncludeUnmatchedRoutes { get; set; }", + "Stage": "Experimental" + }, { "Member": "System.Collections.Generic.IDictionary Microsoft.AspNetCore.Diagnostics.Logging.LoggingRedactionOptions.RequestHeadersDataClasses { get; set; }", "Stage": "Experimental" @@ -144,6 +148,52 @@ } ] }, + { + "Type": "static class Microsoft.Extensions.Logging.PerIncomingRequestLoggingBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.PerIncomingRequestLoggingBuilderExtensions.AddPerIncomingRequestBuffer(this Microsoft.Extensions.Logging.ILoggingBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.PerIncomingRequestLoggingBuilderExtensions.AddPerIncomingRequestBuffer(this Microsoft.Extensions.Logging.ILoggingBuilder builder, System.Action configure);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.PerIncomingRequestLoggingBuilderExtensions.AddPerIncomingRequestBuffer(this Microsoft.Extensions.Logging.ILoggingBuilder builder, Microsoft.Extensions.Logging.LogLevel? logLevel = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.PerRequestLogBufferingOptions();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.TimeSpan Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.AutoFlushDuration { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.MaxLogRecordSizeInBytes { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.MaxPerRequestBufferSizeInBytes { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.AspNetCore.Diagnostics.Buffering.PerRequestLogBufferingOptions.Rules { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "static class Microsoft.AspNetCore.Diagnostics.Latency.RequestCheckpointConstants", "Stage": "Stable", diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/README.md b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/README.md index 0bb66555216..bf71faddf8f 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/README.md +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/README.md @@ -20,6 +20,60 @@ Or directly in the C# project file: ## Usage Example +### Log Buffering + +Provides a buffering mechanism for logs, allowing you to store logs in temporary circular buffers in memory. If the buffer is full, the oldest logs will be dropped. If you want to emit the buffered logs, you can call `Flush()` on the buffer. That way, if you don't flush buffers, all buffered logs will eventually be dropped and that makes sense - if you don't flush buffers, chances are +those logs are not important. At the same time, you can trigger a flush on the buffer when certain conditions are met, such as when an exception occurs. + +#### Per-request Buffering + +Provides HTTP request-scoped buffering for web applications: + +```csharp +// Simple configuration with log level +builder.Logging.AddPerIncomingRequestBuffer(LogLevel.Warning); // Buffer Warning and lower level logs per request + +// Configuration using options +builder.Logging.AddPerIncomingRequestBuffer(options => +{ + options.Rules.Add(new LogBufferingFilterRule(logLevel: LogLevel.Information)); // Buffer Information and lower level logs + options.Rules.Add(new LogBufferingFilterRule(categoryName: "Microsoft.*")); // Buffer logs from Microsoft namespaces +}); + +// Configuration using IConfiguration +builder.Logging.AddPerIncomingRequestBuffer(configuration.GetSection("Logging:RequestBuffering")); +``` + +Then, to flush the buffers when a bad thing happens, call the `Flush()` method on the injected `PerRequestLogBuffer` instance: + +```csharp +public class MyService +{ + private readonly PerRequestLogBuffer _perRequestLogBuffer; + + public MyService(PerRequestLogBuffer perRequestLogBuffer) + { + _perRequestLogBuffer = perRequestLogBuffer; + } + + public void DoSomething() + { + try + { + // ... + } + catch (Exception ex) + { + // Flush all buffers + _perRequestLogBuffer.Flush(); + } + } +} +``` + +Per-request buffering is especially useful for capturing all logs related to a specific HTTP request and making decisions about them collectively based on request outcomes. +Per-request buffering is tightly coupled with [Global Buffering](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.Telemetry/README.md#log-buffering). If a log entry is supposed to be buffered to a per-request buffer, but there is no active HTTP context, it will be buffered to the global buffer instead. If buffer flush is triggered, the per-request buffer will be flushed first, followed by the global buffer. + ### Tracking HTTP Request Latency These components enable tracking and reporting the latency of HTTP request processing. diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs index 34e78e3b37c..22297373047 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs @@ -10,7 +10,6 @@ namespace Microsoft.AspNetCore.HeaderParsing; /// Parses raw header value to a header type. /// /// The resulting strong type representing the header's value. -[SuppressMessage("Minor Code Smell", "S1694:An abstract class should have both abstract and concrete methods", Justification = "Want abstract class for extensibility and perf")] public abstract class HeaderParser where T : notnull { @@ -21,6 +20,5 @@ public abstract class HeaderParser /// A resulting value. /// An error if parsing failed. /// Parsing result. - [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "There is no such keyword in C#.")] public abstract bool TryParse(StringValues values, [NotNullWhen(true)] out T? result, [NotNullWhen(false)] out string? error); } diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs index b3974603efb..ca5e38f3651 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs @@ -15,7 +15,6 @@ namespace Microsoft.AspNetCore.HeaderParsing; /// public sealed partial class HeaderParsingFeature { - private readonly IHeaderRegistry _registry; private readonly ILogger _logger; private readonly HeaderParsingMetrics _metrics; @@ -26,11 +25,10 @@ public sealed partial class HeaderParsingFeature internal HttpContext? Context { get; set; } - internal HeaderParsingFeature(IHeaderRegistry registry, ILogger logger, HeaderParsingMetrics metrics) + internal HeaderParsingFeature(ILogger logger, HeaderParsingMetrics metrics) { _logger = logger; _metrics = metrics; - _registry = registry; } /// @@ -91,12 +89,11 @@ internal sealed class PoolHelper : IDisposable public PoolHelper( ObjectPool pool, - IHeaderRegistry registry, ILogger logger, HeaderParsingMetrics metrics) { _pool = pool; - Feature = new HeaderParsingFeature(registry, logger, metrics); + Feature = new HeaderParsingFeature(logger, metrics); } public void Dispose() @@ -114,7 +111,6 @@ private enum BoxState NotFound = ParsingResult.NotFound, } - [SuppressMessage("Minor Code Smell", "S1694:An abstract class should have both abstract and concrete methods", Justification = "Analyzer issue")] private abstract class Box { public abstract void Reset(); diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs index f8aa8680fb5..2c063d1aa8b 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs @@ -6,8 +6,6 @@ using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Primitives; -#pragma warning disable CA2227 // Collection properties should be read only - namespace Microsoft.AspNetCore.HeaderParsing; /// diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj index adbae73bd9e..32020fa29f9 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj @@ -10,6 +10,8 @@ $(NetCoreTargetFrameworks) true + + $(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration true true true @@ -30,10 +32,6 @@ - - - - diff --git a/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs b/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs index 1a3252df00a..136b6828537 100644 --- a/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs +++ b/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs @@ -19,7 +19,6 @@ internal static class FakeSslCertificateFactory /// Creates a self-signed instance for testing. /// /// An instance for testing. - [SuppressMessage("Reliability", "EA0002:Use System.TimeProvider when dealing with time in your code.", Justification = "declarations")] public static X509Certificate2 CreateSslCertificate() { var request = new CertificateRequest( diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs deleted file mode 100644 index ab9f010ae57..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Diagnostics; -using System.Text; -using Microsoft.Shared.Collections; - -namespace Microsoft.Extensions.AI; - -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods - -/// Represents a tool that can be specified to an AI service. -[DebuggerDisplay("{DebuggerDisplay,nq}")] -public abstract class AITool -{ - /// Initializes a new instance of the class. - protected AITool() - { - } - - /// Gets the name of the tool. - public virtual string Name => GetType().Name; - - /// Gets a description of the tool, suitable for use in describing the purpose to a model. - public virtual string Description => string.Empty; - - /// Gets any additional properties associated with the tool. - public virtual IReadOnlyDictionary AdditionalProperties => EmptyReadOnlyDictionary.Instance; - - /// - public override string ToString() => Name; - - /// Gets the string to display in the debugger for this instance. - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string DebuggerDisplay - { - get - { - StringBuilder sb = new(Name); - - if (Description is string description && !string.IsNullOrEmpty(description)) - { - _ = sb.Append(" (").Append(description).Append(')'); - } - - foreach (var entry in AdditionalProperties) - { - _ = sb.Append(", ").Append(entry.Key).Append(" = ").Append(entry.Value); - } - - return sb.ToString(); - } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index dab50ff11ee..9bd7d266a6a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -1,10 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S1144 // Unused private types or members should be removed -#pragma warning disable S2365 // Properties should not make collection or array copies -#pragma warning disable S3604 // Member initializer values should not be redundant - using System.Collections.Generic; namespace Microsoft.Extensions.AI; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs index 14125e95b76..6b40a6c0d35 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs @@ -10,9 +10,6 @@ using System.Linq; using Microsoft.Shared.Diagnostics; -#pragma warning disable S1144 // Unused private types or members should be removed -#pragma warning disable S2365 // Properties should not make collection or array copies -#pragma warning disable S3604 // Member initializer values should not be redundant #pragma warning disable S4039 // Interface methods should be callable by derived types #pragma warning disable CA1033 // Interface methods should be callable by derived types @@ -255,7 +252,9 @@ private sealed class DebugView(AdditionalPropertiesDictionary properties private readonly AdditionalPropertiesDictionary _properties = Throw.IfNull(properties); [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] +#pragma warning disable S2365 // Properties should not make collection or array copies public AdditionalProperty[] Items => (from p in _properties select new AdditionalProperty(p.Key, p.Value)).ToArray(); +#pragma warning restore S2365 [DebuggerDisplay("{Value}", Name = "[{Key}]")] public readonly struct AdditionalProperty(string key, TValue value) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index b4fe9d69a66..e8c2ea1e971 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,5 +1,98 @@ # Release History +## 9.10.2 + +- Updated `AIFunctionFactory` to respect `[DisplayName(...)]` on functions as a way to override the function name. +- Updated `AIFunctionFactory` to respect `[DefaultValue(...)]` on function parameters as a way to specify default values. +- Added `CodeInterpreterToolCallContent`/`CodeInterpreterToolResultContent` for representing code interpreter tool calls and results. +- Added `Name`, `MediaType`, and `HasTopLevelMediaType` to `HostedFileContent`. +- Fixed the serialization/deserialization of variables typed as `UserInputRequestContent`/`UserInputResponseContent`. + +## 9.10.1 + +- Updated `HostedMcpServerTool` to allow for non-`Uri` server addresses, in order to enable built-in names. +- Updated `HostedMcpServerTool` to replace the header collection with an `AuthorizationToken` property. +- Fixed `ToChatResponse{Async}` to not discard `TextReasoningContent.ProtectedData` when coalescing messages. +- Fixed `AIFunctionFactory.Create` to special-case return types of `AIContent` and `IEnumerable` to not automatically JSON serialize them. + +## 9.10.0 + +- Added protected copy constructors to options types (e.g. `ChatOptions`). +- Added `[Experimental]` support for background responses, such that non-streaming responses are allowed to be pollable and responses / response updates can be tagged with continuation tokens to support later resumption. +- Updated `AIFunctionFactory.Create` to produce better default names for lambdas and local functions. +- Fixed `AIJsonUtilities.DefaultOptions` to handle the built-in `[Experimental]` `AIContent` types, like `FunctionApprovalRequestContent`. +- Fixed `ToChatResponse{Async}` to factor `ChatResponseUpdate.AuthorName` into message boundary detection. +- Fixed `ToChatResponse{Async}` to not overwrite `ChatMessage/ChatResponse.CreatedAt` with older timestamps during coalescing. +- Fixed `EmbeddingGeneratorOptions`/`SpeechToTextOptions` `Clone` methods to correctly copy all properties. + +## 9.9.1 + +- Added new `ChatResponseFormat.ForJsonSchema` overloads that export a JSON schema from a .NET type. +- Added new `AITool.GetService` virtual method. +- Updated `TextReasoningContent` to include `ProtectedData` for representing encrypted/redacted content. +- Fixed `MinLength`/`MaxLength`/`Length` attribute mapping in nullable string properties during schema export. + +## 9.9.0 + +- Added non-invocable `AIFunctionDeclaration` (base class for `AIFunction`), `AIFunctionFactory.CreateDeclaration`, and `AIFunction.AsDeclarationOnly`. +- Added `[Experimental]` support for user approval of function invocations via `ApprovalRequiredAIFunction`, `FunctionApprovalRequestContent`, and friends. +- Added `[Experimental]` support for MCP server-hosted tools via `HostedMcpServerTool`, `HostedMcpServerToolApprovalMode`, and friends. +- Updated `AIContent` coalescing logic used by `ToChatResponse`/`ToChatResponseUpdate` to factor in `ChatMessage.Role`. +- Moved `IChatReducer` into `Microsoft.Extensions.AI.Abstractions` from `Microsoft.Extensions.AI`. + +## 9.8.0 + +- Added `AIAnnotation` and related types to represent citations and other annotations in chat messages. +- Added `ChatMessage.CreatedAt` so that chat messages can carry their timestamp. +- Added a `[Description(...)]` attribute to `DataContent.Uri` to clarify its purpose when used in schemas. +- Added `DataContent.Name` property to associate a name with the binary data, like a filename. +- Added `HostedFileContent` for representing files hosted by the service. +- Added `HostedVectorStoreContent` for representing vector stores hosted by the service. +- Added `HostedFileSearchTool` to represent server-side file search tools. +- Added `HostedCodeInterpreterTool.Inputs` to supply context about what state is available to the code interpreter tool. +- Added [Experimental] `IImageGenerator` and supporting types. +- Improved handling of function parameter data annotation attributes in `AIJsonUtilities.CreateJsonSchema`. +- Fixed schema generation to include an items keyword for arrays of objects in `AIJsonUtilities.CreateJsonSchema`. + +## 9.7.1 + +- Fixed schema generation for nullable function parameters in `AIJsonUtilities.CreateJsonSchema`. +- Added a flag for `AIFunctionFactory` to control whether return schemas are generated. +- Added `DelegatingAIFunction` to simplify creating `AIFunction`s that call other `AIFunction`s. +- Updated `AIFunctionFactory` to tolerate JSON string function parameters. +- Fixed schema generation for nullable value type parameters. + +## 9.7.0 + +- Added `ChatOptions.Instructions` property for configuring system instructions separate from chat messages. +- Added `Usage` property to `SpeechToTextResponse` to provide details about the token usage. +- Augmented `AIJsonUtilities.CreateJsonSchema` with support for data annotations. + +## 9.6.0 + +- Added `AIFunction.ReturnJsonSchema` to represent the JSON schema of the return value of a function. +- Removed title and description keywords from root-level schemas in `AIFunctionFactory`. + +## 9.5.0 + +- Moved `AIFunctionFactory` down from `Microsoft.Extensions.AI` to `Microsoft.Extensions.AI.Abstractions`. +- Added `BinaryEmbedding` type for representing bit embeddings. +- Added `TextReasoningContent` to represent reasoning content in chat messages. +- Added `ChatOptions.AllowMultipleToolCalls` for configuring parallel tool calling. +- Added a public constructor to the base `AIContent`. +- Added a missing `[DebuggerDisplay]` attribute on `AIFunctionArguments`. +- Added `ChatOptions.RawRepresentationFactory` to facilitate passing raw options to the underlying service. +- Added an `AIJsonSchemaTransformOptions` property inside `AIJsonSchemaCreateOptions`. +- Added `DataContent.Base64Data` property for easier and more efficient handling of base64-encoded data. +- Added JSON schema transformation functionality to `AIJsonUtilities`. +- Fixed `AIJsonUtilities.CreateJsonSchema` to handle `JsonSerializerOptions` that do not have a `TypeInfoResolver` configured. +- Fixed `AIFunctionFactory` handling of default struct arguments. +- Fixed schema generation to ensure the type keyword is included when generating schemas for nullable enums. +- Renamed the `GenerateXx` extension methods on `IEmbeddingGenerator<>`. +- Renamed `ChatThreadId` to `ConversationId` across the libraries. +- Replaced `Type targetType` parameter in `AIFunctionFactory.Create` with a delegate. +- Remove `[Obsolete]` members from previews. + ## 9.4.4-preview.1.25259.16 - Added `AIJsonUtilities.TransformSchema` and supporting types. @@ -22,7 +115,7 @@ - Added `MessageId` to `ChatMessage` and `ChatResponseUpdate`. - Added `AIFunctionArguments`, changing `AIFunction.InvokeAsync` to accept one and to return a `ValueTask`. - Updated `AIJsonUtilities`'s schema generation to not use `default` when `RequireAllProperties` is set to `true`. -- Added `ISpeechToTextClient` and supporting types. +- Added [Experimental] `ISpeechToTextClient` and supporting types. - Fixed several issues related to Native AOT support. ## 9.3.0-preview.1.25161.3 diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs index 18b3ec658e3..1852aa07f7c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs @@ -14,9 +14,6 @@ namespace Microsoft.Extensions.AI; [JsonConverter(typeof(Converter))] public readonly struct ChatFinishReason : IEquatable { - /// The finish reason value. If because `default(ChatFinishReason)` was used, the instance will behave like . - private readonly string? _value; - /// Initializes a new instance of the struct with a string that describes the reason. /// The reason value. /// is . @@ -24,11 +21,11 @@ namespace Microsoft.Extensions.AI; [JsonConstructor] public ChatFinishReason(string value) { - _value = Throw.IfNullOrWhitespace(value); + Value = Throw.IfNullOrWhitespace(value); } /// Gets the finish reason value. - public string Value => _value ?? Stop.Value; + public string Value => field ?? Stop.Value; /// public override bool Equals([NotNullWhen(true)] object? obj) => obj is ChatFinishReason other && Equals(other); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index 43b3e321df9..4cdf3b30f0a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -53,6 +53,7 @@ public ChatMessage Clone() => AdditionalProperties = AdditionalProperties, _authorName = _authorName, _contents = _contents, + CreatedAt = CreatedAt, RawRepresentation = RawRepresentation, Role = Role, MessageId = MessageId, @@ -65,6 +66,9 @@ public string? AuthorName set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; } + /// Gets or sets a timestamp for the chat message. + public DateTimeOffset? CreatedAt { get; set; } + /// Gets or sets the role of the author of the message. public ChatRole Role { get; set; } = ChatRole.User; @@ -103,7 +107,17 @@ public IList Contents /// Gets a object to display in the debugger display. [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private AIContent? ContentForDebuggerDisplay => _contents is { Count: > 0 } ? _contents[0] : null; + private AIContent? ContentForDebuggerDisplay + { + get + { + string text = Text; + return + !string.IsNullOrWhiteSpace(text) ? new TextContent(text) : + _contents is { Count: > 0 } ? _contents[0] : + null; + } + } /// Gets an indication for the debugger display of whether there's more content. [DebuggerBrowsable(DebuggerBrowsableState.Never)] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index f2eeffe9dbf..738f724dcd2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -11,20 +12,55 @@ namespace Microsoft.Extensions.AI; /// Provide options. public class ChatOptions { - /// Gets or sets an optional identifier used to associate a request with an existing conversation. - /// This property is obsolete. Use instead. - [System.Obsolete("Use ConversationId instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public string? ChatThreadId + /// Initializes a new instance of the class. + public ChatOptions() { - get => ConversationId; - set => ConversationId = value; + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected ChatOptions(ChatOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + AllowBackgroundResponses = other.AllowBackgroundResponses; + AllowMultipleToolCalls = other.AllowMultipleToolCalls; + ConversationId = other.ConversationId; + ContinuationToken = other.ContinuationToken; + FrequencyPenalty = other.FrequencyPenalty; + Instructions = other.Instructions; + MaxOutputTokens = other.MaxOutputTokens; + ModelId = other.ModelId; + PresencePenalty = other.PresencePenalty; + RawRepresentationFactory = other.RawRepresentationFactory; + ResponseFormat = other.ResponseFormat; + Seed = other.Seed; + Temperature = other.Temperature; + ToolMode = other.ToolMode; + TopK = other.TopK; + TopP = other.TopP; + + if (other.StopSequences is not null) + { + StopSequences = [.. other.StopSequences]; + } + + if (other.Tools is not null) + { + Tools = [.. other.Tools]; + } } /// Gets or sets an optional identifier used to associate a request with an existing conversation. /// Stateless vs. stateful clients. public string? ConversationId { get; set; } + /// Gets or sets additional per-request instructions to be provided to the . + public string? Instructions { get; set; } + /// Gets or sets the temperature for generating chat responses. /// /// This value controls the randomness of predictions made by the model. Use a lower value to decrease randomness in the response. @@ -97,24 +133,24 @@ public string? ChatThreadId public IList? StopSequences { get; set; } /// - /// Gets or sets a flag to indicate whether a single response is allowed to include multiple tool calls. - /// If , the is asked to return a maximum of one tool call per request. - /// If , there is no limit. - /// If , the provider may select its own default. + /// Gets or sets a value that indicates whether a single response is allowed to include multiple tool calls. /// + /// + /// for no limit. if the is asked to return a maximum of one tool call per request. If , the provider can select its own default. + /// /// /// /// When used with function calling middleware, this does not affect the ability to perform multiple function calls in sequence. /// It only affects the number of function calls within a single iteration of the function calling loop. /// /// - /// The underlying provider is not guaranteed to support or honor this flag. For example it may choose to ignore it and return multiple tool calls regardless. + /// The underlying provider is not guaranteed to support or honor this flag. For example it might choose to ignore it and return multiple tool calls regardless. /// /// public bool? AllowMultipleToolCalls { get; set; } /// Gets or sets the tool mode for the chat request. - /// The default value is , which is treated the same as . + /// The default is , which is treated the same as . public ChatToolMode? ToolMode { get; set; } /// Gets or sets the list of tools to include with a chat request. @@ -122,21 +158,62 @@ public string? ChatThreadId [JsonIgnore] public IList? Tools { get; set; } + /// Gets or sets a value indicating whether the background responses are allowed. + /// + /// + /// Background responses allow running long-running operations or tasks asynchronously in the background that can be resumed by streaming APIs + /// and polled for completion by non-streaming APIs. + /// + /// + /// When this property is set to , non-streaming APIs have permission to start a background operation and return an initial + /// response with a continuation token. Subsequent calls to the same API should be made in a polling manner with + /// the continuation token to get the final result of the operation. + /// + /// + /// When this property is set to , streaming APIs are also permitted to start a background operation and begin streaming + /// response updates until the operation is completed. If the streaming connection is interrupted, the + /// continuation token obtained from the last update that has one should be supplied to a subsequent call to the same streaming API + /// to resume the stream from the point of interruption and continue receiving updates until the operation is completed. + /// + /// + /// This property only takes effect if the implementation it's used with supports background responses. + /// If the implementation does not support background responses, this property will be ignored. + /// + /// + [Experimental("MEAI001")] + [JsonIgnore] + public bool? AllowBackgroundResponses { get; set; } + + /// Gets or sets the continuation token for resuming and getting the result of the chat response identified by this token. + /// + /// This property is used for background responses that can be activated via the + /// property if the implementation supports them. + /// Streamed background responses, such as those returned by default by , + /// can be resumed if interrupted. This means that a continuation token obtained from the + /// of an update just before the interruption occurred can be passed to this property to resume the stream from the point of interruption. + /// Non-streamed background responses, such as those returned by , + /// can be polled for completion by obtaining the token from the property + /// and passing it to this property on subsequent calls to . + /// + [Experimental("MEAI001")] + [JsonIgnore] + public object? ContinuationToken { get; set; } + /// - /// Gets or sets a callback responsible of creating the raw representation of the chat options from an underlying implementation. + /// Gets or sets a callback responsible for creating the raw representation of the chat options from an underlying implementation. /// /// - /// The underlying implementation may have its own representation of options. + /// The underlying implementation might have its own representation of options. /// When or - /// is invoked with a , that implementation may convert the provided options into + /// is invoked with a , that implementation might convert the provided options into /// its own representation in order to use it while performing the operation. For situations where a consumer knows /// which concrete is being used and how it represents options, a new instance of that - /// implementation-specific options type may be returned by this callback, for the - /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// implementation-specific options type can be returned by this callback for the + /// implementation to use, instead of creating a new instance. Such implementations might mutate the supplied options /// instance further based on other settings supplied on this instance or from other inputs, - /// like the enumerable of s, therefore, its **strongly recommended** to not return shared instances - /// and instead make the callback return a new instance per each call. - /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed + /// like the enumerable of s. Therefore, it is strongly recommended to not return shared instances + /// and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed /// properties on . /// [JsonIgnore] @@ -148,40 +225,14 @@ public string? ChatThreadId /// Produces a clone of the current instance. /// A clone of the current instance. /// + /// /// The clone will have the same values for all properties as the original instance. Any collections, like , /// , and , are shallow-cloned, meaning a new collection instance is created, /// but any references contained by the collections are shared with the original. + /// + /// + /// Derived types should override to return an instance of the derived type. + /// /// - public virtual ChatOptions Clone() - { - ChatOptions options = new() - { - ConversationId = ConversationId, - Temperature = Temperature, - MaxOutputTokens = MaxOutputTokens, - TopP = TopP, - TopK = TopK, - FrequencyPenalty = FrequencyPenalty, - PresencePenalty = PresencePenalty, - Seed = Seed, - ResponseFormat = ResponseFormat, - ModelId = ModelId, - AllowMultipleToolCalls = AllowMultipleToolCalls, - ToolMode = ToolMode, - RawRepresentationFactory = RawRepresentationFactory, - AdditionalProperties = AdditionalProperties?.Clone(), - }; - - if (StopSequences is not null) - { - options.StopSequences = new List(StopSequences); - } - - if (Tools is not null) - { - options.Tools = new List(Tools); - } - - return options; - } + public virtual ChatOptions Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs index 5e0e80beac9..6f7ca4eeda2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs @@ -12,10 +12,10 @@ namespace Microsoft.Extensions.AI; /// Represents the response to a chat request. /// /// provides one or more response messages and metadata about the response. -/// A typical response will contain a single message, however a response may contain multiple messages +/// A typical response will contain a single message, however a response might contain multiple messages /// in a variety of scenarios. For example, if automatic function calling is employed, such that a single -/// request to a may actually generate multiple roundtrips to an inner -/// it uses, all of the involved messages may be surfaced as part of the final . +/// request to a might actually generate multiple round-trips to an inner +/// it uses, all of the involved messages might be surfaced as part of the final . /// public class ChatResponse { @@ -69,27 +69,7 @@ public IList Messages /// the input messages supplied to need only be the additional messages beyond /// what's already stored. If this property is non-, it represents an identifier for that state, /// and it should be used in a subsequent instead of supplying the same messages - /// (and this 's message) as part of the messages parameter. Note that the value may - /// or may not differ on every response, depending on whether the underlying provider uses a fixed ID for each conversation - /// or updates it for each message. - /// - /// This method is obsolete. Use instead. - [Obsolete("Use ConversationId instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public string? ChatThreadId - { - get => ConversationId; - set => ConversationId = value; - } - - /// Gets or sets an identifier for the state of the conversation. - /// - /// Some implementations are capable of storing the state for a conversation, such that - /// the input messages supplied to need only be the additional messages beyond - /// what's already stored. If this property is non-, it represents an identifier for that state, - /// and it should be used in a subsequent instead of supplying the same messages - /// (and this 's message) as part of the messages parameter. Note that the value may - /// or may not differ on every response, depending on whether the underlying provider uses a fixed ID for each conversation + /// (and this 's message) as part of the messages parameter. Note that the value might differ on every response, depending on whether the underlying provider uses a fixed ID for each conversation /// or updates it for each message. /// /// Stateless vs. stateful clients. @@ -107,6 +87,23 @@ public string? ChatThreadId /// Gets or sets usage details for the chat response. public UsageDetails? Usage { get; set; } + /// Gets or sets the continuation token for getting result of the background chat response. + /// + /// implementations that support background responses will return + /// a continuation token if background responses are allowed in + /// and the result of the response has not been obtained yet. If the response has completed and the result has been obtained, + /// the token will be . + /// + /// This property should be used in conjunction with to + /// continue to poll for the completion of the response. Pass this token to + /// on subsequent calls to + /// to poll for completion. + /// + /// + [Experimental("MEAI001")] + [JsonIgnore] + public object? ContinuationToken { get; set; } + /// Gets or sets the raw representation of the chat response from an underlying implementation. /// /// If a is created to represent some underlying object from another object @@ -123,7 +120,7 @@ public string? ChatThreadId public override string ToString() => Text; /// Creates an array of instances that represent this . - /// An array of instances that may be used to represent this . + /// An array of instances that can be used to represent this . public ChatResponseUpdate[] ToChatResponseUpdates() { ChatResponseUpdate? extra = null; @@ -149,19 +146,20 @@ public ChatResponseUpdate[] ToChatResponseUpdates() ChatMessage message = _messages![i]; updates[i] = new ChatResponseUpdate { - ConversationId = ConversationId, - AdditionalProperties = message.AdditionalProperties, AuthorName = message.AuthorName, Contents = message.Contents, + MessageId = message.MessageId, RawRepresentation = message.RawRepresentation, Role = message.Role, - ResponseId = ResponseId, - MessageId = message.MessageId, - CreatedAt = CreatedAt, + ConversationId = ConversationId, FinishReason = FinishReason, - ModelId = ModelId + ModelId = ModelId, + ResponseId = ResponseId, + + CreatedAt = message.CreatedAt ?? CreatedAt, + ContinuationToken = ContinuationToken, }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 01ce878e79c..e7b535e6995 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -3,15 +3,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; +#if !NET +using System.Runtime.InteropServices; +#endif using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - namespace Microsoft.Extensions.AI; /// @@ -84,9 +87,10 @@ public static void AddMessages(this IList list, ChatResponseUpdate var contentsList = filter is null ? update.Contents : update.Contents.Where(filter).ToList(); if (contentsList.Count > 0) { - list.Add(new ChatMessage(update.Role ?? ChatRole.Assistant, contentsList) + list.Add(new(update.Role ?? ChatRole.Assistant, contentsList) { AuthorName = update.AuthorName, + CreatedAt = update.CreatedAt, RawRepresentation = update.RawRepresentation, AdditionalProperties = update.AdditionalProperties, }); @@ -180,59 +184,288 @@ static async Task ToChatResponseAsync( } } + /// + /// Coalesces image result content elements in the provided list of items. + /// Unlike other content coalescing methods, this will coalesce non-sequential items based on their Name property, + /// and it will replace earlier items with later ones when duplicates are found. + /// + private static void CoalesceImageResultContent(IList contents) + { + Dictionary? imageResultIndexById = null; + bool hasRemovals = false; + + for (int i = 0; i < contents.Count; i++) + { + if (contents[i] is ImageGenerationToolResultContent imageResult && !string.IsNullOrEmpty(imageResult.ImageId)) + { + // Check if there's an existing ImageGenerationToolResultContent with the same ImageId to replace + if (imageResultIndexById is null) + { + imageResultIndexById = new(StringComparer.Ordinal); + } + + if (imageResultIndexById.TryGetValue(imageResult.ImageId!, out int existingIndex)) + { + // Replace the existing imageResult with the new one + contents[existingIndex] = imageResult; + contents[i] = null!; // Mark the current one for removal, then remove in single o(n) pass + hasRemovals = true; + } + else + { + imageResultIndexById[imageResult.ImageId!] = i; + } + } + } + + // Remove all of the null slots left over from the coalescing process. + if (hasRemovals) + { + RemoveNullContents(contents); + } + } + /// Coalesces sequential content elements. - internal static void CoalesceTextContent(List contents) + internal static void CoalesceContent(IList contents) { - Coalesce(contents, static text => new(text)); - Coalesce(contents, static text => new(text)); + Coalesce( + contents, + mergeSingle: false, + canMerge: null, + static (contents, start, end) => new(MergeText(contents, start, end)) { AdditionalProperties = contents[start].AdditionalProperties?.Clone() }); + + Coalesce( + contents, + mergeSingle: false, + canMerge: static (r1, r2) => string.IsNullOrEmpty(r1.ProtectedData), // we allow merging if the first item has no ProtectedData, even if the second does + static (contents, start, end) => + { + TextReasoningContent content = new(MergeText(contents, start, end)) + { + AdditionalProperties = contents[start].AdditionalProperties?.Clone() + }; - // This implementation relies on TContent's ToString returning its exact text. - static void Coalesce(List contents, Func fromText) - where TContent : AIContent +#if DEBUG + for (int i = start; i < end - 1; i++) + { + Debug.Assert(contents[i] is TextReasoningContent { ProtectedData: null }, "Expected all but the last to have a null ProtectedData"); + } +#endif + + if (((TextReasoningContent)contents[end - 1]).ProtectedData is { } protectedData) + { + content.ProtectedData = protectedData; + } + + return content; + }); + + CoalesceImageResultContent(contents); + + Coalesce( + contents, + mergeSingle: false, + canMerge: static (r1, r2) => r1.MediaType == r2.MediaType && r1.HasTopLevelMediaType("text") && r1.Name == r2.Name, + static (contents, start, end) => + { + Debug.Assert(end - start > 1, "Expected multiple contents to merge"); + + MemoryStream ms = new(); + for (int i = start; i < end; i++) + { + var current = (DataContent)contents[i]; +#if NET + ms.Write(current.Data.Span); +#else + if (!MemoryMarshal.TryGetArray(current.Data, out var segment)) + { + segment = new(current.Data.ToArray()); + } + + ms.Write(segment.Array!, segment.Offset, segment.Count); +#endif + } + + var first = (DataContent)contents[start]; + return new DataContent(new ReadOnlyMemory(ms.GetBuffer(), 0, (int)ms.Length), first.MediaType) { Name = first.Name }; + }); + + Coalesce( + contents, + mergeSingle: true, + canMerge: static (r1, r2) => r1.CallId == r2.CallId, + static (contents, start, end) => + { + var firstContent = (CodeInterpreterToolCallContent)contents[start]; + + if (start == end - 1) + { + if (firstContent.Inputs is not null) + { + CoalesceContent(firstContent.Inputs); + } + + return firstContent; + } + + List? inputs = null; + + for (int i = start; i < end; i++) + { + (inputs ??= []).AddRange(((CodeInterpreterToolCallContent)contents[i]).Inputs ?? []); + } + + if (inputs is not null) + { + CoalesceContent(inputs); + } + + return new() + { + CallId = firstContent.CallId, + Inputs = inputs, + AdditionalProperties = firstContent.AdditionalProperties?.Clone(), + }; + }); + + Coalesce( + contents, + mergeSingle: true, + canMerge: static (r1, r2) => r1.CallId is not null && r2.CallId is not null && r1.CallId == r2.CallId, + static (contents, start, end) => + { + var firstContent = (CodeInterpreterToolResultContent)contents[start]; + + if (start == end - 1) + { + if (firstContent.Outputs is not null) + { + CoalesceContent(firstContent.Outputs); + } + + return firstContent; + } + + List? output = null; + + for (int i = start; i < end; i++) + { + (output ??= []).AddRange(((CodeInterpreterToolResultContent)contents[i]).Outputs ?? []); + } + + if (output is not null) + { + CoalesceContent(output); + } + + return new() + { + CallId = firstContent.CallId, + Outputs = output, + AdditionalProperties = firstContent.AdditionalProperties?.Clone(), + }; + }); + + static string MergeText(IList contents, int start, int end) { - StringBuilder? coalescedText = null; + Debug.Assert(end - start > 1, "Expected multiple contents to merge"); + StringBuilder sb = new(); + for (int i = start; i < end; i++) + { + _ = sb.Append(contents[i]); + } + + return sb.ToString(); + } + + static void Coalesce( + IList contents, + bool mergeSingle, + Func? canMerge, + Func, int, int, TContent> merge) + where TContent : AIContent + { // Iterate through all of the items in the list looking for contiguous items that can be coalesced. int start = 0; - while (start < contents.Count - 1) + while (start < contents.Count) { - // We need at least two TextContents in a row to be able to coalesce. - if (contents[start] is not TContent firstText) + if (!TryAsCoalescable(contents[start], out var firstContent)) { start++; continue; } - if (contents[start + 1] is not TContent secondText) + // Iterate until we find a non-coalescable item. + int i = start + 1; + TContent prev = firstContent; + while (i < contents.Count && TryAsCoalescable(contents[i], out TContent? next) && (canMerge is null || canMerge(prev, next))) { - start += 2; - continue; + i++; + prev = next; } - // Append the text from those nodes and continue appending subsequent TextContents until we run out. - // We null out nodes as their text is appended so that we can later remove them all in one O(N) operation. - coalescedText ??= new(); - _ = coalescedText.Clear().Append(firstText).Append(secondText); - contents[start + 1] = null!; - int i = start + 2; - for (; i < contents.Count && contents[i] is TContent next; i++) + // If there's only one item in the run, and we don't want to merge single items, skip it. + if (start == i - 1 && !mergeSingle) { - _ = coalescedText.Append(next); - contents[i] = null!; + start++; + continue; } - // Store the replacement node. We inherit the properties of the first text node. We don't - // currently propagate additional properties from the subsequent nodes. If we ever need to, - // we can add that here. - var newContent = fromText(coalescedText.ToString()); - contents[start] = newContent; - newContent.AdditionalProperties = firstText.AdditionalProperties?.Clone(); + // Store the replacement node and null out all of the nodes that we coalesced. + // We can then remove all coalesced nodes in one O(N) operation via RemoveAll. + // Leave start positioned at the start of the next run. + contents[start] = merge(contents, start, i); - start = i; + start++; + while (start < i) + { + contents[start++] = null!; + } + + static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent? coalescable) + { + if (content is TContent tmp && tmp.Annotations is not { Count: > 0 }) + { + coalescable = tmp; + return true; + } + + coalescable = null; + return false; + } } // Remove all of the null slots left over from the coalescing process. - _ = contents.RemoveAll(u => u is null); + RemoveNullContents(contents); + } + } + + private static void RemoveNullContents(IList contents) + where T : class + { + if (contents is List contentsList) + { + _ = contentsList.RemoveAll(u => u is null); + } + else + { + int nextSlot = 0; + int contentsCount = contents.Count; + for (int i = 0; i < contentsCount; i++) + { + if (contents[i] is { } content) + { + contents[nextSlot++] = content; + } + } + + for (int i = contentsCount - 1; i >= nextSlot; i--) + { + contents.RemoveAt(i); + } + + Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length."); } } @@ -242,7 +475,7 @@ private static void FinalizeResponse(ChatResponse response) int count = response.Messages.Count; for (int i = 0; i < count; i++) { - CoalesceTextContent((List)response.Messages[i].Contents); + CoalesceContent((List)response.Messages[i].Contents); } } @@ -252,23 +485,22 @@ private static void FinalizeResponse(ChatResponse response) private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse response) { // If there is no message created yet, or if the last update we saw had a different - // message ID than the newest update, create a new message. - ChatMessage message; - var isNewMessage = false; - if (response.Messages.Count == 0) + // identifying parts, create a new message. + bool isNewMessage = true; + if (response.Messages.Count != 0) { - isNewMessage = true; - } - else if (update.MessageId is { Length: > 0 } updateMessageId - && response.Messages[response.Messages.Count - 1].MessageId is string lastMessageId - && updateMessageId != lastMessageId) - { - isNewMessage = true; + var lastMessage = response.Messages[response.Messages.Count - 1]; + isNewMessage = + NotEmptyOrEqual(update.AuthorName, lastMessage.AuthorName) || + NotEmptyOrEqual(update.MessageId, lastMessage.MessageId) || + NotNullOrEqual(update.Role, lastMessage.Role); } + // Get the message to target, either a new one or the last ones. + ChatMessage message; if (isNewMessage) { - message = new ChatMessage(ChatRole.Assistant, []); + message = new(ChatRole.Assistant, []); response.Messages.Add(message); } else @@ -280,11 +512,17 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon // Incorporate those into the latest message; in cases where the message // stores a single value, prefer the latest update's value over anything // stored in the message. + if (update.AuthorName is not null) { message.AuthorName = update.AuthorName; } + if (message.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > message.CreatedAt)) + { + message.CreatedAt = update.CreatedAt; + } + if (update.Role is ChatRole role) { message.Role = role; @@ -325,7 +563,7 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon response.ConversationId = update.ConversationId; } - if (update.CreatedAt is not null) + if (response.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > response.CreatedAt)) { response.CreatedAt = update.CreatedAt; } @@ -352,4 +590,12 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon } } } + + /// Gets whether both strings are not null/empty and not the same as each other. + private static bool NotEmptyOrEqual(string? s1, string? s2) => + s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2; + + /// Gets whether two roles are not null and not the same as each other. + private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) => + r1.HasValue && r2.HasValue && r1.Value != r2.Value; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs index ac59cfc263e..76f8a486ad1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -1,19 +1,29 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.ComponentModel; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable + /// Represents the response format that is desired by the caller. [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(ChatResponseFormatText), typeDiscriminator: "text")] [JsonDerivedType(typeof(ChatResponseFormatJson), typeDiscriminator: "json")] -#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable -public class ChatResponseFormat -#pragma warning restore CA1052 +public partial class ChatResponseFormat { + private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() + { + IncludeSchemaKeyword = true, + }; + /// Initializes a new instance of the class. /// Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it. private protected ChatResponseFormat() @@ -33,7 +43,61 @@ private protected ChatResponseFormat() /// The instance. public static ChatResponseFormatJson ForJsonSchema( JsonElement schema, string? schemaName = null, string? schemaDescription = null) => - new(schema, - schemaName, - schemaDescription); + new(schema, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The type for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + public static ChatResponseFormatJson ForJsonSchema( + JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) => + ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + /// is . + public static ChatResponseFormatJson ForJsonSchema( + Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) + { + _ = Throw.IfNull(schemaType); + + var schema = AIJsonUtilities.CreateJsonSchema( + schemaType, + serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, + inferenceOptions: _inferenceOptions); + + return ForJsonSchema( + schema, + schemaName ?? schemaType.GetCustomAttribute()?.DisplayName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), + schemaDescription ?? schemaType.GetCustomAttribute()?.Description); + } + + /// Regex that flags any character other than ASCII digits, ASCII letters, or underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs index bdc584596b0..f1ad70cd22f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs @@ -20,9 +20,9 @@ namespace Microsoft.Extensions.AI; /// /// /// The relationship between and is -/// codified in the and +/// codified in the and /// , which enable bidirectional conversions -/// between the two. Note, however, that the provided conversions may be lossy, for example if multiple +/// between the two. Note, however, that the provided conversions might be lossy, for example, if multiple /// updates all have different objects whereas there's only one slot for /// such an object available in . Similarly, if different /// updates provide different values for properties like , @@ -35,9 +35,6 @@ public class ChatResponseUpdate /// The response update content items. private IList? _contents; - /// The name of the author of the update. - private string? _authorName; - /// Initializes a new instance of the class. [JsonConstructor] public ChatResponseUpdate() @@ -61,11 +58,34 @@ public ChatResponseUpdate(ChatRole? role, IList? contents) _contents = contents; } + /// + /// Creates a new ChatResponseUpdate instance that is a copy of the current object. + /// + /// The cloned object is a shallow copy; reference-type properties will reference the same + /// objects as the original. Use this method to duplicate the response update for further modification without + /// affecting the original instance. + /// A new ChatResponseUpdate object with the same property values as the current instance. + public ChatResponseUpdate Clone() => + new() + { + AdditionalProperties = AdditionalProperties, + AuthorName = AuthorName, + Contents = Contents, + CreatedAt = CreatedAt, + ConversationId = ConversationId, + FinishReason = FinishReason, + MessageId = MessageId, + ModelId = ModelId, + RawRepresentation = RawRepresentation, + ResponseId = ResponseId, + Role = Role, + }; + /// Gets or sets the name of the author of the response update. public string? AuthorName { - get => _authorName; - set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; + get; + set => field = string.IsNullOrWhiteSpace(value) ? null : value; } /// Gets or sets the role of the author of the response update. @@ -103,12 +123,12 @@ public IList Contents /// Gets or sets the ID of the message of which this update is a part. /// - /// A single streaming response may be composed of multiple messages, each of which may be represented + /// A single streaming response might be composed of multiple messages, each of which might be represented /// by multiple updates. This property is used to group those updates together into messages. /// - /// Some providers may consider streaming responses to be a single message, and in that case - /// the value of this property may be the same as the response ID. - /// + /// Some providers might consider streaming responses to be a single message, and in that case + /// the value of this property might be the same as the response ID. + /// /// This value is used when /// groups instances into instances. /// The value must be unique to each call to the underlying provider, and must be shared by @@ -122,25 +142,7 @@ public IList Contents /// the input messages supplied to need only be the additional messages beyond /// what's already stored. If this property is non-, it represents an identifier for that state, /// and it should be used in a subsequent instead of supplying the same messages - /// (and this streaming message) as part of the messages parameter. Note that the value may or may not differ on every - /// response, depending on whether the underlying provider uses a fixed ID for each conversation or updates it for each message. - /// - /// This method is obsolete. Use instead. - [Obsolete("Use ConversationId instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public string? ChatThreadId - { - get => ConversationId; - set => ConversationId = value; - } - - /// Gets or sets an identifier for the state of the conversation of which this update is a part. - /// - /// Some implementations are capable of storing the state for a conversation, such that - /// the input messages supplied to need only be the additional messages beyond - /// what's already stored. If this property is non-, it represents an identifier for that state, - /// and it should be used in a subsequent instead of supplying the same messages - /// (and this streaming message) as part of the messages parameter. Note that the value may or may not differ on every + /// (and this streaming message) as part of the messages parameter. Note that the value might differ on every /// response, depending on whether the underlying provider uses a fixed ID for each conversation or updates it for each message. /// public string? ConversationId { get; set; } @@ -157,9 +159,34 @@ public string? ChatThreadId /// public override string ToString() => Text; + /// Gets or sets the continuation token for resuming the streamed chat response of which this update is a part. + /// + /// implementations that support background responses return + /// a continuation token on each update if background responses are allowed in . + /// However, for the last update, the token will be . + /// + /// This property should be used for stream resumption, where the continuation token of the latest received update should be + /// passed to on subsequent calls to + /// to resume streaming from the point of interruption. + /// + /// + [Experimental("MEAI001")] + [JsonIgnore] + public object? ContinuationToken { get; set; } + /// Gets a object to display in the debugger display. [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private AIContent? ContentForDebuggerDisplay => _contents is { Count: > 0 } ? _contents[0] : null; + private AIContent? ContentForDebuggerDisplay + { + get + { + string text = Text; + return + !string.IsNullOrWhiteSpace(text) ? new TextContent(text) : + _contents is { Count: > 0 } ? _contents[0] : + null; + } + } /// Gets an indication for the debugger display of whether there's more content. [DebuggerBrowsable(DebuggerBrowsableState.Never)] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs index 05e1f28f476..73134a5d894 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs @@ -55,8 +55,7 @@ private protected ChatToolMode() /// /// Instantiates a indicating that tool usage is required, - /// and that the specified must be selected. The function name - /// must match an entry in . + /// and that the specified function name must be selected. /// /// The name of the required function. /// An instance of for the specified function name. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs index 112e846d41f..34aa665450b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs @@ -23,6 +23,7 @@ public class DelegatingChatClient : IChatClient /// Initializes a new instance of the class. /// /// The wrapped client instance. + /// is . protected DelegatingChatClient(IChatClient innerClient) { InnerClient = Throw.IfNull(innerClient); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs index b4354e22a43..f4e0141ac94 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs @@ -11,6 +11,11 @@ namespace Microsoft.Extensions.AI; /// Represents a chat client. /// /// +/// Applications must consider risks such as prompt injection attacks, data sizes, and the number of messages +/// sent to the underlying provider or returned from it. Unless a specific implementation +/// explicitly documents safeguards for these concerns, the application is expected to implement appropriate protections. +/// +/// /// Unless otherwise specified, all members of are thread-safe for concurrent use. /// It is expected that all implementations of support being used by multiple requests concurrently. /// Instances must not be disposed of while the instance is still in use. @@ -57,7 +62,7 @@ IAsyncEnumerable GetStreamingResponseAsync( /// The found object, otherwise . /// is . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , /// including itself or any services it might be wrapping. For example, to access the for the instance, /// may be used to request it. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs index 91397e67602..899ba04251e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs @@ -15,17 +15,17 @@ namespace Microsoft.Extensions.AI; public sealed class RequiredChatToolMode : ChatToolMode { /// - /// Gets the name of a specific that must be called. + /// Gets the name of a specific tool that must be called. /// /// - /// If the value is , any available function can be selected (but at least one must be). + /// If the value is , any available tool can be selected (but at least one must be). /// public string? RequiredFunctionName { get; } /// - /// Initializes a new instance of the class that requires a specific function to be called. + /// Initializes a new instance of the class that requires a specific tool to be called. /// - /// The name of the function that must be called. + /// The name of the tool that must be called. /// is empty or composed entirely of whitespace. /// /// can be . However, it's preferable to use @@ -41,12 +41,6 @@ public RequiredChatToolMode(string? requiredFunctionName) RequiredFunctionName = requiredFunctionName; } - // The reason for not overriding Equals/GetHashCode (e.g., so two instances are equal if they - // have the same RequiredFunctionName) is to leave open the option to unseal the type in the - // future. If we did define equality based on RequiredFunctionName but a subclass added further - // fields, this would lead to wrong behavior unless the subclass author remembers to re-override - // Equals/GetHashCode as well, which they likely won't. - /// Gets a string representing this instance to display in the debugger. [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => $"Required: {RequiredFunctionName ?? "Any"}"; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatReduction/IChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatReduction/IChatReducer.cs new file mode 100644 index 00000000000..5d85924f251 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatReduction/IChatReducer.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a reducer capable of shrinking the size of a list of chat messages. +/// +[Experimental("MEAI001")] +public interface IChatReducer +{ + /// Reduces the size of a list of chat messages. + /// The messages to reduce. + /// The to monitor for cancellation requests. + /// The new list of messages. + Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..8488d3969c4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml @@ -0,0 +1,88 @@ + + + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs new file mode 100644 index 00000000000..73fdff81aa2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an annotation on content. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(CitationAnnotation), typeDiscriminator: "citation")] +public class AIAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public AIAnnotation() + { + } + + /// Gets or sets any target regions for the annotation, pointing to where in the associated this annotation applies. + /// + /// The most common form of is , which provides starting and ending character indices + /// for . + /// + public IList? AnnotatedRegions { get; set; } + + /// Gets or sets the raw representation of the annotation from an underlying implementation. + /// + /// If an is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model, if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// + /// Gets or sets additional metadata specific to the provider or source type. + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 06798f10f3d..af8b19c8d84 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -11,10 +12,27 @@ namespace Microsoft.Extensions.AI; [JsonDerivedType(typeof(ErrorContent), typeDiscriminator: "error")] [JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")] [JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")] +[JsonDerivedType(typeof(HostedFileContent), typeDiscriminator: "hostedFile")] +[JsonDerivedType(typeof(HostedVectorStoreContent), typeDiscriminator: "hostedVectorStore")] [JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")] [JsonDerivedType(typeof(TextReasoningContent), typeDiscriminator: "reasoning")] [JsonDerivedType(typeof(UriContent), typeDiscriminator: "uri")] [JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")] + +// These should be added in once they're no longer [Experimental]. If they're included while still +// experimental, any JsonSerializerContext that includes AIContent will incur errors about using +// experimental types in its source generated files. When [Experimental] is removed from these types, +// these lines should be uncommented and the corresponding lines in AIJsonUtilities.CreateDefaultOptions +// as well as the [JsonSerializable] attributes for them on the JsonContext should be removed. +// [JsonDerivedType(typeof(FunctionApprovalRequestContent), typeDiscriminator: "functionApprovalRequest")] +// [JsonDerivedType(typeof(FunctionApprovalResponseContent), typeDiscriminator: "functionApprovalResponse")] +// [JsonDerivedType(typeof(McpServerToolCallContent), typeDiscriminator: "mcpServerToolCall")] +// [JsonDerivedType(typeof(McpServerToolResultContent), typeDiscriminator: "mcpServerToolResult")] +// [JsonDerivedType(typeof(McpServerToolApprovalRequestContent), typeDiscriminator: "mcpServerToolApprovalRequest")] +// [JsonDerivedType(typeof(McpServerToolApprovalResponseContent), typeDiscriminator: "mcpServerToolApprovalResponse")] +// [JsonDerivedType(typeof(CodeInterpreterToolCallContent), typeDiscriminator: "codeInterpreterToolCall")] +// [JsonDerivedType(typeof(CodeInterpreterToolResultContent), typeDiscriminator: "codeInterpreterToolResult")] + public class AIContent { /// @@ -24,6 +42,11 @@ public AIContent() { } + /// + /// Gets or sets a list of annotations on this content. + /// + public IList? Annotations { get; set; } + /// Gets or sets the raw representation of the content from an underlying implementation. /// /// If an is created to represent some underlying object from another object diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs new file mode 100644 index 00000000000..fed6dc886b7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Describes the portion of an associated to which an annotation applies. +/// +/// Details about the region is provided by derived types based on how the region is described. For example, starting +/// and ending indices into text content are provided by . +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(TextSpanAnnotatedRegion), typeDiscriminator: "textSpan")] +public class AnnotatedRegion +{ + /// + /// Initializes a new instance of the class. + /// + public AnnotatedRegion() + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs new file mode 100644 index 00000000000..0f1925f057f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an annotation that links content to source references, +/// such as documents, URLs, files, or tool outputs. +/// +public class CitationAnnotation : AIAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public CitationAnnotation() + { + } + + /// + /// Gets or sets the title or name of the source. + /// + /// + /// This value could be the title of a document, the title from a web page, the name of a file, or similarly descriptive text. + /// + public string? Title { get; set; } + + /// + /// Gets or sets a URI from which the source material was retrieved. + /// + public Uri? Url { get; set; } + + /// Gets or sets a source identifier associated with the annotation. + /// + /// This is a provider-specific identifier that can be used to reference the source material by + /// an ID. This might be a document ID, a file ID, or some other identifier for the source material + /// that can be used to uniquely identify it with the provider. + /// + public string? FileId { get; set; } + + /// Gets or sets the name of any tool involved in the production of the associated content. + /// + /// This might be a function name, such as one from , or the name of a built-in tool + /// from the provider, such as "code_interpreter" or "file_search". + /// + public string? ToolName { get; set; } + + /// + /// Gets or sets a snippet or excerpt from the source that was cited. + /// + public string? Snippet { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolCallContent.cs new file mode 100644 index 00000000000..31681b171be --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolCallContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a code interpreter tool call invocation by a hosted service. +/// +/// +/// This content type represents when a hosted AI service invokes a code interpreter tool. +/// It is informational only and represents the call itself, not the result. +/// +[Experimental("MEAI001")] +public sealed class CodeInterpreterToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public CodeInterpreterToolCallContent() + { + } + + /// + /// Gets or sets the tool call ID. + /// + public string? CallId { get; set; } + + /// + /// Gets or sets the inputs to the code interpreter tool. + /// + /// + /// Inputs can include various types of content such as for files, + /// for binary data, or other types as supported + /// by the service. Typically includes a with a "text/x-python" + /// media type representing the code for execution by the code interpreter tool. + /// + public IList? Inputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolResultContent.cs new file mode 100644 index 00000000000..486ee7072ea --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolResultContent.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the result of a code interpreter tool invocation by a hosted service. +/// +[Experimental("MEAI001")] +public sealed class CodeInterpreterToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public CodeInterpreterToolResultContent() + { + } + + /// + /// Gets or sets the tool call ID that this result corresponds to. + /// + public string? CallId { get; set; } + + /// + /// Gets or sets the output of code interpreter tool. + /// + /// + /// Outputs can include various types of content such as for files, + /// for binary data, for standard output text, + /// or other types as supported by the service. + /// + public IList? Outputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 5bbde1e1444..fbc05e14405 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -5,18 +5,18 @@ #if NET using System.Buffers; using System.Buffers.Text; +using System.ComponentModel; #endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text; #if !NET using System.Runtime.InteropServices; #endif using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3996 // URI properties should not be strings -#pragma warning disable CA1054 // URI-like parameters should not be strings -#pragma warning disable CA1056 // URI-like properties should not be strings +#pragma warning disable IDE0032 // Use auto property #pragma warning disable CA1307 // Specify StringComparison for clarity namespace Microsoft.Extensions.AI; @@ -116,6 +116,7 @@ public DataContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? /// The media type (also known as MIME type) represented by the content. /// is . /// is empty or composed entirely of whitespace. + /// represents an invalid media type. public DataContent(ReadOnlyMemory data, string mediaType) { MediaType = DataUriParser.ThrowIfInvalidMediaType(mediaType); @@ -142,6 +143,9 @@ public DataContent(ReadOnlyMemory data, string mediaType) /// or from a . /// [StringSyntax(StringSyntaxAttribute.Uri)] +#if NET + [Description("A data URI representing the content.")] +#endif public string Uri { get @@ -183,6 +187,13 @@ public string Uri [JsonIgnore] public string MediaType { get; } + /// Gets or sets an optional name associated with the data. + /// + /// A service might use this name as part of citations or to help infer the type of data + /// being represented based on a file extension. + /// + public string? Name { get; set; } + /// Gets the data represented by this instance. /// /// If the instance was constructed from a , this property returns that data. @@ -227,6 +238,16 @@ private string DebuggerDisplay { get { + if (HasTopLevelMediaType("text")) + { + return $"MediaType = {MediaType}, Text = \"{Encoding.UTF8.GetString(Data.ToArray())}\""; + } + + if ("application/json".Equals(MediaType, StringComparison.OrdinalIgnoreCase)) + { + return $"JSON = {Encoding.UTF8.GetString(Data.ToArray())}"; + } + const int MaxLength = 80; string uri = Uri; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs index cff25e9c30b..6afe1409e75 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs @@ -148,9 +148,7 @@ private static bool IsValidBase64Data(ReadOnlySpan value) #if NET8_0_OR_GREATER return Base64.IsValid(value) && !value.ContainsAny(" \t\r\n"); #else -#pragma warning disable S109 // Magic numbers should not be used if (value!.Length % 4 != 0) -#pragma warning restore S109 { return false; } @@ -171,9 +169,7 @@ private static bool IsValidBase64Data(ReadOnlySpan value) // Now traverse over characters for (var i = 0; i <= index; i++) { -#pragma warning disable S1067 // Expressions should not be too complex bool validChar = value[i] is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '+' or '/'; -#pragma warning restore S1067 if (!validChar) { return false; @@ -187,13 +183,11 @@ private static bool IsValidBase64Data(ReadOnlySpan value) /// Provides the parts of a parsed data URI. public sealed class DataUri(ReadOnlyMemory data, bool isBase64, string? mediaType) { -#pragma warning disable S3604 // False positive: Member initializer values should not be redundant public string? MediaType { get; } = mediaType; public ReadOnlyMemory Data { get; } = data; public bool IsBase64 { get; } = isBase64; -#pragma warning restore S3604 public byte[] ToByteArray() => IsBase64 ? Convert.FromBase64String(Data.ToString()) : diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs index 4588531262b..4b82c4c5e91 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs @@ -14,22 +14,19 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public class ErrorContent : AIContent { - /// The error message. - private string? _message; - /// Initializes a new instance of the class with the specified error message. /// The error message to store in this content. public ErrorContent(string? message) { - _message = message; + Message = message; } /// Gets or sets the error message. [AllowNull] public string Message { - get => _message ?? string.Empty; - set => _message = value; + get => field ?? string.Empty; + set; } /// Gets or sets an error code associated with the error. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs new file mode 100644 index 00000000000..d3ec7ab8f0b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for user approval of a function call. +/// +[Experimental("MEAI001")] +public sealed class FunctionApprovalRequestContent : UserInputRequestContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the function approval request/response pair. + /// The function call that requires user approval. + /// is . + /// is empty or composed entirely of whitespace. + /// is . + public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall) + : base(id) + { + FunctionCall = Throw.IfNull(functionCall); + } + + /// + /// Gets the function call that pre-invoke approval is required for. + /// + public FunctionCallContent FunctionCall { get; } + + /// + /// Creates a to indicate whether the function call is approved or rejected based on the value of . + /// + /// if the function call is approved; otherwise, . + /// The representing the approval response. + public FunctionApprovalResponseContent CreateResponse(bool approved) => new(Id, approved, FunctionCall); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs new file mode 100644 index 00000000000..948dc6a1347 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a response to a function approval request. +/// +[Experimental("MEAI001")] +public sealed class FunctionApprovalResponseContent : UserInputResponseContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the function approval request/response pair. + /// if the function call is approved; otherwise, . + /// The function call that requires user approval. + /// is . + /// is empty or composed entirely of whitespace. + /// is . + public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall) + : base(id) + { + Approved = approved; + FunctionCall = Throw.IfNull(functionCall); + } + + /// + /// Gets a value indicating whether the user approved the request. + /// + public bool Approved { get; } + + /// + /// Gets the function call for which approval was requested. + /// + public FunctionCallContent FunctionCall { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index d19988b2b76..836d5a4110b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -83,7 +83,6 @@ public static FunctionCallContent CreateFromParsedArguments( IDictionary? arguments = null; Exception? parsingException = null; -#pragma warning disable CA1031 // Do not catch general exception types try { arguments = argumentParser(encodedArguments); @@ -92,7 +91,6 @@ public static FunctionCallContent CreateFromParsedArguments( { parsingException = new InvalidOperationException("Error parsing function call arguments.", ex); } -#pragma warning restore CA1031 // Do not catch general exception types return new FunctionCallContent(callId, name, arguments) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedFileContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedFileContent.cs new file mode 100644 index 00000000000..cc9d8fd4e97 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedFileContent.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a file that is hosted by the AI service. +/// +/// +/// Unlike which contains the data for a file or blob, this class represents a file that is hosted +/// by the AI service and referenced by an identifier. Such identifiers are specific to the provider. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class HostedFileContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the hosted file. + /// is . + /// is empty or composed entirely of whitespace. + public HostedFileContent(string fileId) + { + FileId = Throw.IfNullOrWhitespace(fileId); + } + + /// + /// Gets or sets the ID of the hosted file. + /// + /// is . + /// is empty or composed entirely of whitespace. + public string FileId + { + get => field; + set => field = Throw.IfNullOrWhitespace(value); + } + + /// Gets or sets an optional media type (also known as MIME type) associated with the file. + /// represents an invalid media type. + public string? MediaType + { + get; + set => field = value is not null ? DataUriParser.ThrowIfInvalidMediaType(value) : value; + } + + /// Gets or sets an optional name associated with the file. + public string? Name { get; set; } + + /// + /// Determines whether the 's top-level type matches the specified . + /// + /// The type to compare against . + /// if the type portion of matches the specified value; otherwise, false. + /// + /// + /// A media type is primarily composed of two parts, a "type" and a "subtype", separated by a slash ("/"). + /// The type portion is also referred to as the "top-level type"; for example, + /// "image/png" has a top-level type of "image". compares + /// the specified against the type portion of . + /// + /// + /// If is , this method returns . + /// + /// + public bool HasTopLevelMediaType(string topLevelType) => MediaType is not null && DataUriParser.HasTopLevelMediaType(MediaType, topLevelType); + + /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay + { + get + { + string display = $"FileId = {FileId}"; + + if (MediaType is string mediaType) + { + display += $", MediaType = {mediaType}"; + } + + if (Name is string name) + { + display += $", Name = \"{name}\""; + } + + return display; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedVectorStoreContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedVectorStoreContent.cs new file mode 100644 index 00000000000..cab314486cf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedVectorStoreContent.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a vector store that is hosted by the AI service. +/// +/// +/// Unlike which represents a specific file that is hosted by the AI service, +/// represents a vector store that can contain multiple files, indexed +/// for searching. +/// +[DebuggerDisplay("VectorStoreId = {VectorStoreId}")] +public sealed class HostedVectorStoreContent : AIContent +{ + private string _vectorStoreId; + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the hosted file store. + /// is . + /// is empty or composed entirely of whitespace. + public HostedVectorStoreContent(string vectorStoreId) + { + _vectorStoreId = Throw.IfNullOrWhitespace(vectorStoreId); + } + + /// + /// Gets or sets the ID of the hosted vector store. + /// + /// is . + /// is empty or composed entirely of whitespace. + public string VectorStoreId + { + get => _vectorStoreId; + set => _vectorStoreId = Throw.IfNullOrWhitespace(value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs new file mode 100644 index 00000000000..f5703a39e69 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the invocation of an image generation tool call by a hosted service. +/// +[Experimental("MEAI001")] +public sealed class ImageGenerationToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public ImageGenerationToolCallContent() + { + } + + /// + /// Gets or sets the unique identifier of the image generation item. + /// + public string? ImageId { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs new file mode 100644 index 00000000000..2ce6d5045f7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an image generation tool call invocation by a hosted service. +/// +/// +/// This content type represents when a hosted AI service invokes an image generation tool. +/// It is informational only and represents the call itself, not the result. +/// +[Experimental("MEAI001")] +public sealed class ImageGenerationToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public ImageGenerationToolResultContent() + { + } + + /// + /// Gets or sets the unique identifier of the image generation item. + /// + public string? ImageId { get; set; } + + /// + /// Gets or sets the generated content items. + /// + /// + /// Content is typically for images streamed from the tool, or for remotely hosted images, but + /// can also be provider-specific content types that represent the generated images. + /// + public IList? Outputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs new file mode 100644 index 00000000000..8f302d901b4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for user approval of an MCP server tool call. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolApprovalRequestContent : UserInputRequestContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the MCP server tool approval request/response pair. + /// The tool call that requires user approval. + /// is . + /// is empty or composed entirely of whitespace. + /// is . + public McpServerToolApprovalRequestContent(string id, McpServerToolCallContent toolCall) + : base(id) + { + ToolCall = Throw.IfNull(toolCall); + } + + /// + /// Gets the tool call that pre-invoke approval is required for. + /// + public McpServerToolCallContent ToolCall { get; } + + /// + /// Creates a to indicate whether the function call is approved or rejected based on the value of . + /// + /// if the function call is approved; otherwise, . + /// The representing the approval response. + public McpServerToolApprovalResponseContent CreateResponse(bool approved) => new(Id, approved); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs new file mode 100644 index 00000000000..0e239a79d7f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a response to an MCP server tool approval request. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolApprovalResponseContent : UserInputResponseContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the MCP server tool approval request/response pair. + /// if the MCP server tool call is approved; otherwise, . + /// is . + /// is empty or composed entirely of whitespace. + public McpServerToolApprovalResponseContent(string id, bool approved) + : base(id) + { + Approved = approved; + } + + /// + /// Gets a value indicating whether the user approved the request. + /// + public bool Approved { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs new file mode 100644 index 00000000000..3283c09a7ee --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a tool call request to a MCP server. +/// +/// +/// This content type is used to represent an invocation of an MCP server tool by a hosted service. +/// It is informational only. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The tool call ID. + /// The tool name. + /// The MCP server name that hosts the tool. + /// or is . + /// or is empty or composed entirely of whitespace. + public McpServerToolCallContent(string callId, string toolName, string? serverName) + { + CallId = Throw.IfNullOrWhitespace(callId); + ToolName = Throw.IfNullOrWhitespace(toolName); + ServerName = serverName; + } + + /// + /// Gets the tool call ID. + /// + public string CallId { get; } + + /// + /// Gets the name of the tool called. + /// + public string ToolName { get; } + + /// + /// Gets the name of the MCP server that hosts the tool. + /// + public string? ServerName { get; } + + /// + /// Gets or sets the arguments used for the tool call. + /// + public IReadOnlyDictionary? Arguments { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs new file mode 100644 index 00000000000..b8329c74d99 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the result of a MCP server tool call. +/// +/// +/// This content type is used to represent the result of an invocation of an MCP server tool by a hosted service. +/// It is informational only. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The tool call ID. + /// is . + /// is empty or composed entirely of whitespace. + public McpServerToolResultContent(string callId) + { + CallId = Throw.IfNullOrWhitespace(callId); + } + + /// + /// Gets the tool call ID. + /// + public string CallId { get; } + + /// + /// Gets or sets the output of the tool call. + /// + public IList? Output { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs index 0f70a5f8b0a..d6bac57420f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs @@ -12,15 +12,13 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class TextContent : AIContent { - private string? _text; - /// /// Initializes a new instance of the class. /// /// The text content. public TextContent(string? text) { - _text = text; + Text = text; } /// @@ -29,8 +27,8 @@ public TextContent(string? text) [AllowNull] public string Text { - get => _text ?? string.Empty; - set => _text = value; + get => field ?? string.Empty; + set; } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs index ccf84af2e3d..57fec14cc0e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs @@ -17,15 +17,13 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class TextReasoningContent : AIContent { - private string? _text; - /// /// Initializes a new instance of the class. /// /// The text reasoning content. public TextReasoningContent(string? text) { - _text = text; + Text = text; } /// @@ -34,10 +32,26 @@ public TextReasoningContent(string? text) [AllowNull] public string Text { - get => _text ?? string.Empty; - set => _text = value; + get => field ?? string.Empty; + set; } + /// Gets or sets an optional opaque blob of data associated with this reasoning content. + /// + /// + /// This property is used to store data from a provider that should be roundtripped back to the provider but that is not + /// intended for human consumption. It is often encrypted or otherwise redacted information that is only intended to be + /// sent back to the provider and not displayed to the user. It's possible for a to contain + /// only and have an empty property. This data also may be associated with + /// the corresponding , acting as a validation signature for it. + /// + /// + /// Note that whereas can be provider agnostic, + /// is provider-specific, and is likely to only be understood by the provider that created it. + /// + /// + public string? ProtectedData { get; set; } + /// public override string ToString() => Text; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs new file mode 100644 index 00000000000..8ce3dbfa3c5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Describes a location in the associated based on starting and ending character indices. +/// This typically applies to . +[DebuggerDisplay("[{StartIndex}, {EndIndex})")] +public sealed class TextSpanAnnotatedRegion : AnnotatedRegion +{ + /// + /// Initializes a new instance of the class. + /// + public TextSpanAnnotatedRegion() + { + } + + /// + /// Gets or sets the start character index (inclusive) of the annotated span in the . + /// + [JsonPropertyName("start")] + public int? StartIndex { get; set; } + + /// + /// Gets or sets the end character index (exclusive) of the annotated span in the . + /// + [JsonPropertyName("end")] + public int? EndIndex { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs index 7beaa40efdf..37acd121960 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs @@ -30,7 +30,7 @@ public class UriContent : AIContent /// is . /// is . /// is an invalid media type. - /// is an invalid URL. + /// is an invalid URL. /// /// A media type must be specified, so that consumers know what to do with the content. /// If an exact media type is not known, but the category (e.g. image) is known, a wildcard @@ -67,6 +67,7 @@ public Uri Uri } /// Gets or sets the media type (also known as MIME type) for this content. + /// represents an invalid media type. public string MediaType { get => _mediaType; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs new file mode 100644 index 00000000000..b2a2e0e6e95 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for user input. +/// +[Experimental("MEAI001")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(FunctionApprovalRequestContent), "functionApprovalRequest")] +[JsonDerivedType(typeof(McpServerToolApprovalRequestContent), "mcpServerToolApprovalRequest")] +public class UserInputRequestContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the user input request/response pair. + /// is . + /// is empty or composed entirely of whitespace. + protected UserInputRequestContent(string id) + { + Id = Throw.IfNullOrWhitespace(id); + } + + /// + /// Gets the ID that uniquely identifies the user input request/response pair. + /// + public string Id { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs new file mode 100644 index 00000000000..6902f047282 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the response to a request for user input. +/// +[Experimental("MEAI001")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(FunctionApprovalResponseContent), "functionApprovalResponse")] +[JsonDerivedType(typeof(McpServerToolApprovalResponseContent), "mcpServerToolApprovalResponse")] +public class UserInputResponseContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the user input request/response pair. + /// is . + /// is empty or composed entirely of whitespace. + protected UserInputResponseContent(string id) + { + Id = Throw.IfNullOrWhitespace(id); + } + + /// + /// Gets the ID that uniquely identifies the user input request/response pair. + /// + public string Id { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs index 4343983c550..e5d8459351e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -8,12 +10,29 @@ namespace Microsoft.Extensions.AI; /// Represents the options for an embedding generation request. public class EmbeddingGenerationOptions { - private int? _dimensions; + /// Initializes a new instance of the class. + public EmbeddingGenerationOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected EmbeddingGenerationOptions(EmbeddingGenerationOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + Dimensions = other.Dimensions; + ModelId = other.ModelId; + RawRepresentationFactory = other.RawRepresentationFactory; + } /// Gets or sets the number of dimensions requested in the embedding. public int? Dimensions { - get => _dimensions; + get; set { if (value is not null) @@ -21,7 +40,7 @@ public int? Dimensions _ = Throw.IfLessThan(value.Value, 1, nameof(value)); } - _dimensions = value; + field = value; } } @@ -31,17 +50,30 @@ public int? Dimensions /// Gets or sets additional properties for the embedding generation request. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// + /// Gets or sets a callback responsible for creating the raw representation of the embedding generation options from an underlying implementation. + /// + /// + /// The underlying implementation may have its own representation of options. + /// When + /// is invoked with an , that implementation may convert the provided options into + /// its own representation in order to use it while performing the operation. For situations where a consumer knows + /// which concrete is being used and how it represents options, a new instance of that + /// implementation-specific options type may be returned by this callback, for the + /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// instance further based on other settings supplied on this instance or from other inputs, + /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + /// Produces a clone of the current instance. /// A clone of the current instance. /// /// The clone will have the same values for all properties as the original instance. Any collections, like /// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original. /// - public virtual EmbeddingGenerationOptions Clone() => - new() - { - ModelId = ModelId, - Dimensions = Dimensions, - AdditionalProperties = AdditionalProperties?.Clone(), - }; + public virtual EmbeddingGenerationOptions Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs index 31f58772abf..d4503f57c2b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs @@ -8,9 +8,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable S2302 // "nameof" should be used -#pragma warning disable S4136 // Method overloads should be grouped together - namespace Microsoft.Extensions.AI; /// Provides a collection of static methods for extending instances. @@ -87,35 +84,6 @@ public static TService GetRequiredService( return service; } - /// Generates an embedding vector from the specified . - /// The type from which embeddings will be generated. - /// The numeric type of the embedding data. - /// The embedding generator. - /// A value from which an embedding will be generated. - /// The embedding generation options to configure the request. - /// The to monitor for cancellation requests. The default is . - /// The generated embedding for the specified . - /// is . - /// is . - /// The generator did not produce exactly one embedding. - /// - /// This operation is equivalent to using and returning the - /// resulting 's property. - /// - /// - /// This method is obsolete. Use instead. - /// - [Obsolete("Use GenerateVectorAsync instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public static async Task> GenerateEmbeddingVectorAsync( - this IEmbeddingGenerator> generator, - TInput value, - EmbeddingGenerationOptions? options = null, - CancellationToken cancellationToken = default) - { - return await GenerateVectorAsync(generator, value, options, cancellationToken).ConfigureAwait(false); - } - /// Generates an embedding vector from the specified . /// The type from which embeddings will be generated. /// The numeric type of the embedding data. @@ -141,39 +109,6 @@ public static async Task> GenerateVectorAsync< return embedding.Vector; } - /// Generates an embedding from the specified . - /// The type from which embeddings will be generated. - /// The type of embedding to generate. - /// The embedding generator. - /// A value from which an embedding will be generated. - /// The embedding generation options to configure the request. - /// The to monitor for cancellation requests. The default is . - /// - /// The generated embedding for the specified . - /// - /// is . - /// is . - /// The generator did not produce exactly one embedding. - /// - /// This operations is equivalent to using with a - /// collection composed of the single and then returning the first embedding element from the - /// resulting collection. - /// - /// - /// This method is obsolete. Use instead. - /// - [Obsolete("Use GenerateAsync instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public static async Task GenerateEmbeddingAsync( - this IEmbeddingGenerator generator, - TInput value, - EmbeddingGenerationOptions? options = null, - CancellationToken cancellationToken = default) - where TEmbedding : Embedding - { - return await GenerateAsync(generator, value, options, cancellationToken).ConfigureAwait(false); - } - /// Generates an embedding from the specified . /// The type from which embeddings will be generated. /// The type of embedding to generate. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs index 448c81f61f4..9af299013e5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs @@ -9,34 +9,12 @@ namespace Microsoft.Extensions.AI; /// Represents a function that can be described to an AI service and invoked. -public abstract class AIFunction : AITool +public abstract class AIFunction : AIFunctionDeclaration { - /// Gets a JSON Schema describing the function and its input parameters. - /// - /// - /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. - /// A simple example of a JSON schema for a function that adds two numbers together is shown below: - /// - /// - /// { - /// "title" : "addNumbers", - /// "description": "A simple function that adds two numbers together.", - /// "type": "object", - /// "properties": { - /// "a" : { "type": "number" }, - /// "b" : { "type": "number", "default": 1 } - /// }, - /// "required" : ["a"] - /// } - /// - /// - /// The metadata present in the schema document plays an important role in guiding AI function invocation. - /// - /// - /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. - /// - /// - public virtual JsonElement JsonSchema => AIJsonUtilities.DefaultJsonSchema; + /// Initializes a new instance of the class. + protected AIFunction() + { + } /// /// Gets the underlying that this might be wrapping. @@ -65,4 +43,14 @@ public abstract class AIFunction : AITool protected abstract ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken); + + /// Creates a representation of this that can't be invoked. + /// The created instance. + /// + /// derives from , layering on the ability to invoke the function in addition + /// to describing it. creates a new object that describes the function but that can't be invoked. + /// + public AIFunctionDeclaration AsDeclarationOnly() => new NonInvocableAIFunction(this); + + private sealed class NonInvocableAIFunction(AIFunction function) : DelegatingAIFunctionDeclaration(function); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs index 3238b88e532..4a7c9a555ce 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs @@ -9,8 +9,6 @@ #pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter #pragma warning disable SA1112 // Closing parenthesis should be on line of opening parenthesis #pragma warning disable SA1114 // Parameter list should follow declaration -#pragma warning disable S3358 // Extract this nested ternary operation into an independent statement. -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S4039 // Make 'AIFunctionArguments' sealed #pragma warning disable CA1033 // Make 'AIFunctionArguments' sealed #pragma warning disable CA1710 // Identifiers should have correct suffix @@ -79,14 +77,10 @@ public AIFunctionArguments(IEqualityComparer? comparer) /// public AIFunctionArguments(IDictionary? arguments, IEqualityComparer? comparer) { -#pragma warning disable S1698 // Consider using 'Equals' if value comparison is intended. _arguments = - arguments is null - ? new Dictionary(comparer) - : (arguments is Dictionary dc) && (comparer is null || dc.Comparer == comparer) - ? dc - : new Dictionary(arguments, comparer); -#pragma warning restore S1698 // Consider using 'Equals' if value comparison is intended. + arguments is null ? new(comparer) : + arguments is Dictionary dc && (comparer is null || ReferenceEquals(dc.Comparer, comparer)) ? dc : + new(arguments, comparer); } /// Gets or sets services optionally associated with these arguments. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs new file mode 100644 index 00000000000..203045f92b2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// Represents a function that can be described to an AI service. +/// +/// is the base class for , which +/// adds the ability to invoke the function. Components can type test instances +/// for to determine whether they can be described as functions, +/// and can type test for to determine whether they can be invoked. +/// +public abstract class AIFunctionDeclaration : AITool +{ + /// Initializes a new instance of the class. + protected AIFunctionDeclaration() + { + } + + /// Gets a JSON Schema describing the function and its input parameters. + /// + /// + /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. + /// A simple example of a JSON schema for a function that adds two numbers together is shown below: + /// + /// + /// { + /// "type": "object", + /// "properties": { + /// "a" : { "type": "number" }, + /// "b" : { "type": ["number","null"], "default": 1 } + /// }, + /// "required" : ["a"] + /// } + /// + /// + /// The metadata present in the schema document plays an important role in guiding AI function invocation. + /// + /// + /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. + /// + /// + public virtual JsonElement JsonSchema => AIJsonUtilities.DefaultJsonSchema; + + /// Gets a JSON Schema describing the function's return value. + /// + /// A typically reflects a function that doesn't specify a return schema + /// or a function that returns , , or . + /// + public virtual JsonElement? ReturnJsonSchema => null; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index d5274186645..7daa5a49340 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -22,10 +22,7 @@ using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; -#pragma warning disable CA1031 // Do not catch general exception types -#pragma warning disable S2333 // Redundant modifiers should not be used #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable SA1202 // Public members should come before private members namespace Microsoft.Extensions.AI; @@ -48,13 +45,13 @@ public static partial class AIFunctionFactory /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. The behavior of parameters can't be overridden. /// /// /// @@ -63,7 +60,7 @@ public static partial class AIFunctionFactory /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . + /// The handling of parameters can be overridden via . /// /// /// @@ -71,19 +68,19 @@ public static partial class AIFunctionFactory /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether + /// optional or not. The handling of parameters can be overridden via /// . /// /// /// /// All other parameter types are, by default, bound from the dictionary passed into - /// and are included in the generated JSON schema. This may be overridden by the provided + /// and are included in the generated JSON schema. This can be overridden by the provided /// via the parameter; for every parameter, the delegate is enabled to choose if the parameter should be included in the /// generated schema and how its value should be bound, including handling of optionality (by default, required parameters that are not included in the - /// dictionary will result in an exception being thrown). Loosely-typed additional context information may be passed + /// dictionary will result in an exception being thrown). Loosely-typed additional context information can be passed /// into via the 's dictionary; the default - /// binding ignores this collection, but a custom binding supplied via may choose to + /// binding ignores this collection, but a custom binding supplied via can choose to /// source arguments from this data. /// /// @@ -96,13 +93,18 @@ public static partial class AIFunctionFactory /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// By default, return values are serialized to using 's /// if provided, or else using . - /// Handling of return values may be overridden via . + /// However, return values whose declared type is , a derived type of , or + /// any type assignable from (e.g. AIContent[], List<AIContent>) are + /// special-cased and are not serialized: the created function returns the original instance(s) directly to enable + /// callers (such as an IChatClient) to perform type tests and implement specialized handling. If + /// is supplied, that delegate governs the behavior instead. + /// Handling of return values can be overridden via . /// /// /// is . @@ -118,7 +120,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// The method to be represented via the created . /// /// The name to use for the . If , the name will be derived from - /// the name of . + /// any on , if available, or else from the name of . /// /// /// The description to use for the . If , a description will be derived from @@ -130,7 +132,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// /// Any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -152,7 +154,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether /// optional or not. /// /// @@ -170,12 +172,14 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// Return values are serialized to using if provided, - /// or else using . + /// or else using . However, return values whose declared type is , a + /// derived type of , or any type assignable from are not serialized; + /// they are returned as-is to facilitate specialized handling. /// /// /// is . @@ -211,13 +215,13 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. The behavior of parameters can't be overridden. /// /// /// @@ -226,7 +230,7 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . + /// The handling of parameters can be overridden via . /// /// /// @@ -234,19 +238,19 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether + /// optional or not. The handling of parameters can be overridden via /// . /// /// /// /// All other parameter types are, by default, bound from the dictionary passed into - /// and are included in the generated JSON schema. This may be overridden by the provided + /// and are included in the generated JSON schema. This can be overridden by the provided /// via the parameter; for every parameter, the delegate is enabled to choose if the parameter should be included in the /// generated schema and how its value should be bound, including handling of optionality (by default, required parameters that are not included in the - /// dictionary will result in an exception being thrown). Loosely-typed additional context information may be passed + /// dictionary will result in an exception being thrown). Loosely typed additional context information can be passed /// into via the 's dictionary; the default - /// binding ignores this collection, but a custom binding supplied via may choose to + /// binding ignores this collection, but a custom binding supplied via can choose to /// source arguments from this data. /// /// @@ -259,13 +263,15 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// By default, return values are serialized to using 's /// if provided, or else using . - /// Handling of return values may be overridden via . + /// However, return values whose declared type is , a derived type of , or + /// any type assignable from are not serialized and are instead returned directly. + /// Handling of return values can be overridden via . /// /// /// is . @@ -291,7 +297,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// /// The name to use for the . If , the name will be derived from - /// the name of . + /// any on , if available, or else from the name of . /// /// /// The description to use for the . If , a description will be derived from @@ -303,7 +309,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// Any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -325,7 +331,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether /// optional or not. /// /// @@ -343,12 +349,14 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// Return values are serialized to using if provided, - /// or else using . + /// or else using . However, return values whose declared type is , a + /// derived type of , or any type assignable from are returned + /// without serialization to enable specialized handling. /// /// /// is . @@ -397,13 +405,13 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. The behavior of parameters can't be overridden. /// /// /// @@ -412,7 +420,7 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . + /// The handling of parameters can be overridden via . /// /// /// @@ -420,19 +428,19 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether + /// optional or not. The handling of parameters can be overridden via /// . /// /// /// /// All other parameter types are, by default, bound from the dictionary passed into - /// and are included in the generated JSON schema. This may be overridden by the provided + /// and are included in the generated JSON schema. This can be overridden by the provided /// via the parameter; for every parameter, the delegate is enabled to choose if the parameter should be included in the /// generated schema and how its value should be bound, including handling of optionality (by default, required parameters that are not included in the - /// dictionary will result in an exception being thrown). Loosely-typed additional context information may be passed + /// dictionary will result in an exception being thrown). Loosely-typed additional context information can be passed /// into via the 's dictionary; the default - /// binding ignores this collection, but a custom binding supplied via may choose to + /// binding ignores this collection, but a custom binding supplied via can choose to /// source arguments from this data. /// /// @@ -445,13 +453,15 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , the instance constructed - /// for each invocation may contain that data in it, such that it's then available to as instance data. - /// An parameter may also be used to resolve services from a dependency injection container. + /// for each invocation can contain that data in it, such that it's then available to as instance data. + /// An parameter can also be used to resolve services from a dependency injection container. /// /// /// By default, return values are serialized to using 's /// if provided, or else using . - /// Handling of return values may be overridden via . + /// However, return values whose declared type is , a derived type of , or any type + /// assignable from are returned directly without serialization. + /// Handling of return values can be overridden via . /// /// /// is . @@ -466,6 +476,39 @@ public static AIFunction Create( AIFunctionFactoryOptions? options = null) => ReflectionAIFunction.Build(method, createInstanceFunc, options ?? _defaultOptions); + /// Creates an using the specified parameters as the implementation of its corresponding properties. + /// The name of the function. + /// A description of the function, suitable for use in describing the purpose to a model. + /// A JSON schema describing the function and its input parameters. + /// A JSON schema describing the function's return value. + /// The created that describes a function. + /// is . + /// + /// creates an that can be used to describe a function + /// but not invoke it. To create an invocable , use Create. A non-invocable + /// can also be created from an invocable using that function's method. + /// + public static AIFunctionDeclaration CreateDeclaration( + string name, + string? description, + JsonElement jsonSchema, + JsonElement? returnJsonSchema = null) => + new DefaultAIFunctionDeclaration( + Throw.IfNullOrEmpty(name), + description ?? string.Empty, + jsonSchema, + returnJsonSchema); + + private sealed class DefaultAIFunctionDeclaration( + string name, string description, JsonElement jsonSchema, JsonElement? returnJsonSchema) : + AIFunctionDeclaration + { + public override string Name => name; + public override string Description => description; + public override JsonElement JsonSchema => jsonSchema; + public override JsonElement? ReturnJsonSchema => returnJsonSchema; + } + private sealed class ReflectionAIFunction : AIFunction { public static ReflectionAIFunction Build(MethodInfo method, object? target, AIFunctionFactoryOptions options) @@ -540,6 +583,7 @@ private ReflectionAIFunction( public override string Description => FunctionDescriptor.Description; public override MethodInfo UnderlyingMethod => FunctionDescriptor.Method; public override JsonElement JsonSchema => FunctionDescriptor.JsonSchema; + public override JsonElement? ReturnJsonSchema => FunctionDescriptor.ReturnJsonSchema; public override JsonSerializerOptions JsonSerializerOptions => FunctionDescriptor.JsonSerializerOptions; protected override async ValueTask InvokeCoreAsync( @@ -613,7 +657,7 @@ public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFu serializerOptions.MakeReadOnly(); ConcurrentDictionary innerCache = _descriptorCache.GetOrCreateValue(serializerOptions); - DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, schemaOptions); + DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, options.ExcludeResultSchema, schemaOptions); if (innerCache.TryGetValue(key, out ReflectionAIFunctionDescriptor? descriptor)) { return descriptor; @@ -683,19 +727,23 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions ParameterMarshallers[i] = GetParameterMarshaller(serializerOptions, options, parameters[i]); } - // Get a marshaling delegate for the return value. - ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions); - + ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType); Method = key.Method; - Name = key.Name ?? GetFunctionName(key.Method); + Name = key.Name ?? key.Method.GetCustomAttribute(inherit: true)?.DisplayName ?? GetFunctionName(key.Method); Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty; JsonSerializerOptions = serializerOptions; + ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema( + NormalizeReturnType(returnType, serializerOptions), + description: key.Method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description, + serializerOptions: serializerOptions, + inferenceOptions: schemaOptions); + JsonSchema = AIJsonUtilities.CreateFunctionJsonSchema( key.Method, - Name, - Description, - serializerOptions, - schemaOptions); + title: string.Empty, // Forces skipping of the title keyword + description: string.Empty, // Forces skipping of the description keyword + serializerOptions: serializerOptions, + inferenceOptions: schemaOptions); } public string Name { get; } @@ -703,6 +751,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions public MethodInfo Method { get; } public JsonSerializerOptions JsonSerializerOptions { get; } public JsonElement JsonSchema { get; } + public JsonElement? ReturnJsonSchema { get; } public Func[] ParameterMarshallers { get; } public Func> ReturnParameterMarshaller { get; } public ReflectionAIFunction? CachedDefaultInstance { get; set; } @@ -713,11 +762,21 @@ private static string GetFunctionName(MethodInfo method) string name = SanitizeMemberName(method.Name); const string AsyncSuffix = "Async"; - if (IsAsyncMethod(method) && - name.EndsWith(AsyncSuffix, StringComparison.Ordinal) && - name.Length > AsyncSuffix.Length) + if (IsAsyncMethod(method)) { - name = name.Substring(0, name.Length - AsyncSuffix.Length); + // If the method ends in "Async" or contains "Async_", remove the "Async". + int asyncIndex = name.LastIndexOf(AsyncSuffix, StringComparison.Ordinal); + if (asyncIndex > 0 && + (asyncIndex + AsyncSuffix.Length == name.Length || + ((asyncIndex + AsyncSuffix.Length < name.Length) && (name[asyncIndex + AsyncSuffix.Length] == '_')))) + { + name = +#if NET + string.Concat(name.AsSpan(0, asyncIndex), name.AsSpan(asyncIndex + AsyncSuffix.Length)); +#else + string.Concat(name.Substring(0, asyncIndex), name.Substring(asyncIndex + AsyncSuffix.Length)); +#endif + } } return name; @@ -785,10 +844,11 @@ static bool IsAsyncMethod(MethodInfo method) // For IServiceProvider parameters, we bind to the services passed to InvokeAsync via AIFunctionArguments. if (parameterType == typeof(IServiceProvider)) { + bool hasDefault = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out _); return (arguments, _) => { IServiceProvider? services = arguments.Services; - if (!parameter.HasDefaultValue && services is null) + if (!hasDefault && services is null) { ThrowNullServices(parameter.Name); } @@ -800,6 +860,7 @@ static bool IsAsyncMethod(MethodInfo method) // For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary. // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType); + bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue); return (arguments, _) => { // If the parameter has an argument specified in the dictionary, return that argument. @@ -819,6 +880,23 @@ static bool IsAsyncMethod(MethodInfo method) { try { + if (value is string text && IsPotentiallyJson(text)) + { + Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch."); + + // Account for the parameter potentially being a JSON string. + // The value is a string but the type is not. Try to deserialize it under the assumption that it's JSON. + // If it's not, we'll fall through to the default path that makes it valid JSON and then tries to deserialize. + try + { + return JsonSerializer.Deserialize(text, typeInfo); + } + catch (JsonException) + { + // If the string is not valid JSON, fall through to the round-trip. + } + } + string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType())); return JsonSerializer.Deserialize(json, typeInfo); } @@ -831,13 +909,13 @@ static bool IsAsyncMethod(MethodInfo method) } // If the parameter is required and there's no argument specified for it, throw. - if (!parameter.HasDefaultValue) + if (!hasDefaultValue) { Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'."); } // Otherwise, use the optional parameter's default value. - return parameter.DefaultValue; + return effectiveDefaultValue; }; // Throws an ArgumentNullException indicating that AIFunctionArguments.Services must be provided. @@ -849,15 +927,16 @@ static void ThrowNullServices(string parameterName) => /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. /// private static Func> GetReturnParameterMarshaller( - DescriptorKey key, JsonSerializerOptions serializerOptions) + DescriptorKey key, JsonSerializerOptions serializerOptions, out Type? returnType) { - Type returnType = key.Method.ReturnType; + returnType = key.Method.ReturnType; JsonTypeInfo returnTypeInfo; Func>? marshalResult = key.MarshalResult; // Void if (returnType == typeof(void)) { + returnType = null; if (marshalResult is not null) { return (result, cancellationToken) => marshalResult(null, null, cancellationToken); @@ -869,6 +948,7 @@ static void ThrowNullServices(string parameterName) => // Task if (returnType == typeof(Task)) { + returnType = null; if (marshalResult is not null) { return async (result, cancellationToken) => @@ -888,6 +968,7 @@ static void ThrowNullServices(string parameterName) => // ValueTask if (returnType == typeof(ValueTask)) { + returnType = null; if (marshalResult is not null) { return async (result, cancellationToken) => @@ -910,6 +991,9 @@ static void ThrowNullServices(string parameterName) => if (returnType.GetGenericTypeDefinition() == typeof(Task<>)) { MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); + returnType = taskResultGetter.ReturnType; + + // If a MarshalResult delegate is provided, use it. if (marshalResult is not null) { return async (taskObj, cancellationToken) => @@ -920,7 +1004,19 @@ static void ThrowNullServices(string parameterName) => }; } - returnTypeInfo = serializerOptions.GetTypeInfo(taskResultGetter.ReturnType); + // Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them + // specially, such as by returning content to the model/service in a manner appropriate to the content type. + if (IsAIContentRelatedType(returnType)) + { + return async (taskObj, cancellationToken) => + { + await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(true); + return ReflectionInvoke(taskResultGetter, taskObj, null); + }; + } + + // For everything else, just serialize the result as-is. + returnTypeInfo = serializerOptions.GetTypeInfo(returnType); return async (taskObj, cancellationToken) => { await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(true); @@ -934,7 +1030,9 @@ static void ThrowNullServices(string parameterName) => { MethodInfo valueTaskAsTask = GetMethodFromGenericMethodDefinition(returnType, _valueTaskAsTask); MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult); + returnType = asTaskResultGetter.ReturnType; + // If a MarshalResult delegate is provided, use it. if (marshalResult is not null) { return async (taskObj, cancellationToken) => @@ -946,7 +1044,20 @@ static void ThrowNullServices(string parameterName) => }; } - returnTypeInfo = serializerOptions.GetTypeInfo(asTaskResultGetter.ReturnType); + // Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them + // specially, such as by returning content to the model/service in a manner appropriate to the content type. + if (IsAIContentRelatedType(returnType)) + { + return async (taskObj, cancellationToken) => + { + var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; + await task.ConfigureAwait(true); + return ReflectionInvoke(asTaskResultGetter, task, null); + }; + } + + // For everything else, just serialize the result as-is. + returnTypeInfo = serializerOptions.GetTypeInfo(returnType); return async (taskObj, cancellationToken) => { var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; @@ -957,12 +1068,21 @@ static void ThrowNullServices(string parameterName) => } } - // For everything else, just serialize the result as-is. + // If a MarshalResult delegate is provided, use it. if (marshalResult is not null) { - return (result, cancellationToken) => marshalResult(result, returnType, cancellationToken); + Type returnTypeCopy = returnType; + return (result, cancellationToken) => marshalResult(result, returnTypeCopy, cancellationToken); + } + + // Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them + // specially, such as by returning content to the model/service in a manner appropriate to the content type. + if (IsAIContentRelatedType(returnType)) + { + return static (result, _) => new ValueTask(result); } + // For everything else, just serialize the result as-is. returnTypeInfo = serializerOptions.GetTypeInfo(returnType); return (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken); @@ -999,15 +1119,79 @@ private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedT #endif } + private static bool IsAIContentRelatedType(Type type) => + typeof(AIContent).IsAssignableFrom(type) || + typeof(IEnumerable).IsAssignableFrom(type); + + private static Type NormalizeReturnType(Type type, JsonSerializerOptions? options) + { + options ??= AIJsonUtilities.DefaultOptions; + + if (options == AIJsonUtilities.DefaultOptions && !options.TryGetTypeInfo(type, out _)) + { + // GetTypeInfo is not polymorphic, so attempts to look up derived types will fail even if the + // base type is registered. In some cases, though, we can fall back to using interfaces + // we know we have contracts for in AIJsonUtilities.DefaultOptions where the semantics of using + // that interface will be reasonable. This should really only affect situations where + // reflection-based serialization is disabled. + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return typeof(IEnumerable); + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return typeof(IEnumerable); + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return typeof(IEnumerable); + } + } + + return type; + } + private record struct DescriptorKey( MethodInfo Method, string? Name, string? Description, Func? GetBindParameterOptions, Func>? MarshalResult, + bool ExcludeResultSchema, AIJsonSchemaCreateOptions SchemaOptions); } + /// + /// Quickly checks if the specified string is potentially JSON + /// by checking if the first non-whitespace characters are valid JSON start tokens. + /// + /// The string to check. + /// If then the string is definitely not valid JSON. + private static bool IsPotentiallyJson(string value) => PotentiallyJsonRegex().IsMatch(value); +#if NET + [GeneratedRegex(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex PotentiallyJsonRegex(); +#else + private static Regex PotentiallyJsonRegex() => _potentiallyJsonRegex; + private static readonly Regex _potentiallyJsonRegex = new(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); +#endif + private const string PotentiallyJsonRegexString = """ + ^\s* # Optional whitespace at the start of the string + ( null # null literal + | false # false literal + | true # true literal + | -?[0-9]# number + | " # string + | \[ # start array + | { # start object + | // # Start of single-line comment + | /\* # Start of multi-line comment + ) + """; + /// /// Removes characters from a .NET member name that shouldn't be used in an AI function name. /// @@ -1016,16 +1200,37 @@ private record struct DescriptorKey( /// Replaces non-alphanumeric characters in the identifier with the underscore character. /// Primarily intended to remove characters produced by compiler-generated method name mangling. /// - private static string SanitizeMemberName(string memberName) => - InvalidNameCharsRegex().Replace(memberName, "_"); + private static string SanitizeMemberName(string memberName) + { + // Handle compiler-generated names (local functions and lambdas) + // Local functions: g__LocalFunctionName|ordinal_depth -> ContainingMethod_LocalFunctionName_ordinal_depth + // Lambdas: b__ordinal_depth -> ContainingMethod_ordinal_depth + if (CompilerGeneratedNameRegex().Match(memberName) is { Success: true } match) + { + memberName = $"{match.Groups[1].Value}_{match.Groups[2].Value}"; + } + + // Replace all non-alphanumeric characters with underscores. + return InvalidNameCharsRegex().Replace(memberName, "_"); + } + + /// Regex that matches compiler-generated names (local functions and lambdas). +#if NET + [GeneratedRegex(@"^<([^>]+)>\w__(.+)")] + private static partial Regex CompilerGeneratedNameRegex(); +#else + private static Regex CompilerGeneratedNameRegex() => _compilerGeneratedNameRegex; + private static readonly Regex _compilerGeneratedNameRegex = new(@"^<([^>]+)>\w__(.+)", RegexOptions.Compiled); +#endif - /// Regex that flags any character other than ASCII digits or letters or the underscore. + /// Regex that flags any character other than ASCII digits or letters. + /// Underscore isn't included so that sequences of underscores are replaced by a single one. #if NET - [GeneratedRegex("[^0-9A-Za-z_]")] + [GeneratedRegex("[^0-9A-Za-z]+")] private static partial Regex InvalidNameCharsRegex(); #else private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z]+", RegexOptions.Compiled); #endif /// Invokes the MethodInfo with the specified target object and arguments. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs index e71a4687422..5caef21900c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs @@ -39,7 +39,7 @@ public AIFunctionFactoryOptions() /// Gets or sets the name to use for the function. /// - /// The name to use for the function. The default value is a name derived from the method represented by the passed or . + /// The name to use for the function. The default value is a name derived from the passed or (for example, via a on the method). /// public string? Name { get; set; } @@ -90,7 +90,7 @@ public AIFunctionFactoryOptions() /// -returning methods). /// /// - /// Methods strongly-typed to return types of , , , + /// Methods strongly typed to return types of , , , /// and are special-cased. For methods typed to return or , /// will be invoked with the value after the returned task has successfully completed. /// For methods typed to return or , the delegate will be invoked with the @@ -106,6 +106,19 @@ public AIFunctionFactoryOptions() /// public Func>? MarshalResult { get; set; } + /// + /// Gets or sets a value indicating whether a schema should be created for the function's result type, if possible, and included as . + /// + /// + /// + /// The default value is . + /// + /// + /// When set to , results in the produced to always be . + /// + /// + public bool ExcludeResultSchema { get; set; } + /// Provides configuration options produced by the delegate. public readonly record struct ParameterBindingOptions { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs new file mode 100644 index 00000000000..994e4660ac1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an that can be described to an AI service and invoked, but for which +/// the invoker should obtain user approval before the function is actually invoked. +/// +/// +/// This class simply augments an with an indication that approval is required before invocation. +/// It does not enforce the requirement for user approval; it is the responsibility of the invoker to obtain that approval before invoking the function. +/// +[Experimental("MEAI001")] +public sealed class ApprovalRequiredAIFunction : DelegatingAIFunction +{ + /// + /// Initializes a new instance of the class. + /// + /// The represented by this instance. + /// is . + public ApprovalRequiredAIFunction(AIFunction innerFunction) + : base(innerFunction) + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs new file mode 100644 index 00000000000..263d1ad6739 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +public class DelegatingAIFunction : AIFunction +{ + /// + /// Initializes a new instance of the class as a wrapper around . + /// + /// The inner AI function to which all calls are delegated by default. + /// is . + protected DelegatingAIFunction(AIFunction innerFunction) + { + InnerFunction = Throw.IfNull(innerFunction); + } + + /// Gets the inner . + protected AIFunction InnerFunction { get; } + + /// + public override string Name => InnerFunction.Name; + + /// + public override string Description => InnerFunction.Description; + + /// + public override JsonElement JsonSchema => InnerFunction.JsonSchema; + + /// + public override JsonElement? ReturnJsonSchema => InnerFunction.ReturnJsonSchema; + + /// + public override JsonSerializerOptions JsonSerializerOptions => InnerFunction.JsonSerializerOptions; + + /// + public override MethodInfo? UnderlyingMethod => InnerFunction.UnderlyingMethod; + + /// + public override IReadOnlyDictionary AdditionalProperties => InnerFunction.AdditionalProperties; + + /// + public override string ToString() => InnerFunction.ToString(); + + /// + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + InnerFunction.InvokeAsync(arguments, cancellationToken); + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerFunction.GetService(serviceType, serviceKey); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs new file mode 100644 index 00000000000..38ebcf0ffd9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand +{ + /// + /// Initializes a new instance of the class as a wrapper around . + /// + /// The inner AI function to which all calls are delegated by default. + /// is . + protected DelegatingAIFunctionDeclaration(AIFunctionDeclaration innerFunction) + { + InnerFunction = Throw.IfNull(innerFunction); + } + + /// Gets the inner . + protected AIFunctionDeclaration InnerFunction { get; } + + /// + public override string Name => InnerFunction.Name; + + /// + public override string Description => InnerFunction.Description; + + /// + public override JsonElement JsonSchema => InnerFunction.JsonSchema; + + /// + public override JsonElement? ReturnJsonSchema => InnerFunction.ReturnJsonSchema; + + /// + public override IReadOnlyDictionary AdditionalProperties => InnerFunction.AdditionalProperties; + + /// + public override string ToString() => InnerFunction.ToString(); + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerFunction.GetService(serviceType, serviceKey); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs new file mode 100644 index 00000000000..388ffbc2f7f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Indicates that approval is always required for tool calls to a hosted MCP server. +/// +/// +/// Use to get an instance of . +/// +[Experimental("MEAI001")] +[DebuggerDisplay(nameof(AlwaysRequire))] +public sealed class HostedMcpServerToolAlwaysRequireApprovalMode : HostedMcpServerToolApprovalMode +{ + /// Initializes a new instance of the class. + /// Use to get an instance of . + public HostedMcpServerToolAlwaysRequireApprovalMode() + { + } + + /// + public override bool Equals(object? obj) => obj is HostedMcpServerToolAlwaysRequireApprovalMode; + + /// + public override int GetHashCode() => typeof(HostedMcpServerToolAlwaysRequireApprovalMode).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs new file mode 100644 index 00000000000..9bc1a7e6423 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Describes how approval is required for tool calls to a hosted MCP server. +/// +/// +/// The predefined values , and are provided to specify handling for all tools. +/// To specify approval behavior for individual tool names, use . +/// +[Experimental("MEAI001")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(HostedMcpServerToolNeverRequireApprovalMode), typeDiscriminator: "never")] +[JsonDerivedType(typeof(HostedMcpServerToolAlwaysRequireApprovalMode), typeDiscriminator: "always")] +[JsonDerivedType(typeof(HostedMcpServerToolRequireSpecificApprovalMode), typeDiscriminator: "requireSpecific")] +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +public class HostedMcpServerToolApprovalMode +#pragma warning restore CA1052 +{ + /// + /// Gets a predefined indicating that all tool calls to a hosted MCP server always require approval. + /// + public static HostedMcpServerToolAlwaysRequireApprovalMode AlwaysRequire { get; } = new(); + + /// + /// Gets a predefined indicating that all tool calls to a hosted MCP server never require approval. + /// + public static HostedMcpServerToolNeverRequireApprovalMode NeverRequire { get; } = new(); + + private protected HostedMcpServerToolApprovalMode() + { + } + + /// + /// Instantiates a that specifies approval behavior for individual tool names. + /// + /// The list of tool names that always require approval. + /// The list of tool names that never require approval. + /// An instance of for the specified tool names. + public static HostedMcpServerToolRequireSpecificApprovalMode RequireSpecific(IList? alwaysRequireApprovalToolNames, IList? neverRequireApprovalToolNames) + => new(alwaysRequireApprovalToolNames, neverRequireApprovalToolNames); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs new file mode 100644 index 00000000000..bca80649f0d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Indicates that approval is never required for tool calls to a hosted MCP server. +/// +/// +/// Use to get an instance of . +/// +[Experimental("MEAI001")] +[DebuggerDisplay(nameof(NeverRequire))] +public sealed class HostedMcpServerToolNeverRequireApprovalMode : HostedMcpServerToolApprovalMode +{ + /// Initializes a new instance of the class. + /// Use to get an instance of . + public HostedMcpServerToolNeverRequireApprovalMode() + { + } + + /// + public override bool Equals(object? obj) => obj is HostedMcpServerToolNeverRequireApprovalMode; + + /// + public override int GetHashCode() => typeof(HostedMcpServerToolNeverRequireApprovalMode).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs new file mode 100644 index 00000000000..267b25334e1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a mode where approval behavior is specified for individual tool names. +/// +[Experimental("MEAI001")] +public sealed class HostedMcpServerToolRequireSpecificApprovalMode : HostedMcpServerToolApprovalMode +{ + /// + /// Initializes a new instance of the class that specifies approval behavior for individual tool names. + /// + /// The list of tools names that always require approval. + /// The list of tools names that never require approval. + public HostedMcpServerToolRequireSpecificApprovalMode(IList? alwaysRequireApprovalToolNames, IList? neverRequireApprovalToolNames) + { + AlwaysRequireApprovalToolNames = alwaysRequireApprovalToolNames; + NeverRequireApprovalToolNames = neverRequireApprovalToolNames; + } + + /// + /// Gets or sets the list of tool names that always require approval. + /// + public IList? AlwaysRequireApprovalToolNames { get; set; } + + /// + /// Gets or sets the list of tool names that never require approval. + /// + public IList? NeverRequireApprovalToolNames { get; set; } + + /// + public override bool Equals(object? obj) => obj is HostedMcpServerToolRequireSpecificApprovalMode other && + ListEquals(AlwaysRequireApprovalToolNames, other.AlwaysRequireApprovalToolNames) && + ListEquals(NeverRequireApprovalToolNames, other.NeverRequireApprovalToolNames); + + /// + public override int GetHashCode() => + Combine(GetListHashCode(AlwaysRequireApprovalToolNames), GetListHashCode(NeverRequireApprovalToolNames)); + + private static bool ListEquals(IList? list1, IList? list2) => + ReferenceEquals(list1, list2) || + (list1 is not null && list2 is not null && list1.SequenceEqual(list2)); + + private static int GetListHashCode(IList? list) + { + if (list is null) + { + return 0; + } + +#if NET + HashCode hc = default; + for (int i = 0; i < list.Count; i++) + { + hc.Add(list[i]); + } + + return hc.ToHashCode(); +#else + int hash = 0; + for (int i = 0; i < list.Count; i++) + { + hash = Combine(hash, list[i]?.GetHashCode() ?? 0); + } + + return hash; +#endif + } + + private static int Combine(int h1, int h2) + { +#if NET + return HashCode.Combine(h1, h2); +#else + uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); + return ((int)rol5 + h1) ^ h2; +#endif + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs new file mode 100644 index 00000000000..91ffb136af5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building generators that can be chained in any order around an underlying . +/// The default implementation simply passes each call to the inner generator instance. +/// +[Experimental("MEAI001")] +public class DelegatingImageGenerator : IImageGenerator +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped generator instance. + /// is . + protected DelegatingImageGenerator(IImageGenerator innerGenerator) + { + InnerGenerator = Throw.IfNull(innerGenerator); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected IImageGenerator InnerGenerator { get; } + + /// + public virtual Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerGenerator.GenerateAsync(request, options, cancellationToken); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerGenerator.GetService(serviceType, serviceKey); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerGenerator.Dispose(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs new file mode 100644 index 00000000000..e630ecff8e9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a generator of images. +/// +[Experimental("MEAI001")] +public interface IImageGenerator : IDisposable +{ + /// + /// Sends an image generation request and returns the generated image as a . + /// + /// The image generation request containing the prompt and optional original images for editing. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// is . + /// The images generated by the . + Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs new file mode 100644 index 00000000000..586fbcc8bbe --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for an image generation request. +[Experimental("MEAI001")] +public class ImageGenerationOptions +{ + /// Initializes a new instance of the class. + public ImageGenerationOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected ImageGenerationOptions(ImageGenerationOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + Count = other.Count; + ImageSize = other.ImageSize; + MediaType = other.MediaType; + ModelId = other.ModelId; + RawRepresentationFactory = other.RawRepresentationFactory; + ResponseFormat = other.ResponseFormat; + } + + /// + /// Gets or sets the number of images to generate. + /// + public int? Count { get; set; } + + /// + /// Gets or sets the size of the generated image. + /// + /// + /// If a provider only supports fixed sizes, the closest supported size is used. + /// + public Size? ImageSize { get; set; } + + /// + /// Gets or sets the media type (also known as MIME type) of the generated image. + /// + public string? MediaType { get; set; } + + /// + /// Gets or sets the model ID to use for image generation. + /// + public string? ModelId { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the image generation options from an underlying implementation. + /// + /// + /// The underlying implementation can have its own representation of options. + /// When is invoked with an , + /// that implementation can convert the provided options into its own representation in order to use it while performing + /// the operation. For situations where a consumer knows which concrete is being used + /// and how it represents options, a new instance of that implementation-specific options type can be returned by this + /// callback for the implementation to use instead of creating a new instance. + /// Such implementations might mutate the supplied options instance further based on other settings supplied on this + /// instance or from other inputs, therefore, it is strongly recommended to not + /// return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + + /// + /// Gets or sets the response format of the generated image. + /// + public ImageGenerationResponseFormat? ResponseFormat { get; set; } + + /// + /// Gets or sets the number of intermediate streaming images to generate. + /// + public int? StreamingCount { get; set; } + + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + public virtual ImageGenerationOptions Clone() => new(this); +} + +/// +/// Represents the requested response format of the generated image. +/// +/// +/// Not all implementations support all response formats and this value might be ignored by the implementation if not supported. +/// +[Experimental("MEAI001")] +public enum ImageGenerationResponseFormat +{ + /// + /// The generated image is returned as a URI pointing to the image resource. + /// + Uri, + + /// + /// The generated image is returned as in-memory image data. + /// + Data, + + /// + /// The generated image is returned as a hosted resource identifier, which can be used to retrieve the image later. + /// + Hosted, +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs new file mode 100644 index 00000000000..d519d08c731 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Represents a request for image generation. +[Experimental("MEAI001")] +public class ImageGenerationRequest +{ + /// Initializes a new instance of the class. + public ImageGenerationRequest() + { + } + + /// Initializes a new instance of the class. + /// The prompt to guide the image generation. + public ImageGenerationRequest(string prompt) + { + Prompt = prompt; + } + + /// Initializes a new instance of the class. + /// The prompt to guide the image generation. + /// The original images to base edits on. + public ImageGenerationRequest(string prompt, IEnumerable? originalImages) + { + Prompt = prompt; + OriginalImages = originalImages; + } + + /// Gets or sets the prompt to guide the image generation. + public string? Prompt { get; set; } + + /// + /// Gets or sets the original images to base edits on. + /// + /// + /// If this property is set, the request will behave as an image edit operation. + /// If this property is null or empty, the request will behave as a new image generation operation. + /// + public IEnumerable? OriginalImages { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs new file mode 100644 index 00000000000..8f093634783 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents the result of an image generation request. +[Experimental("MEAI001")] +public class ImageGenerationResponse +{ + /// Initializes a new instance of the class. + [JsonConstructor] + public ImageGenerationResponse() + { + } + + /// Initializes a new instance of the class. + /// The contents for this response. + public ImageGenerationResponse(IList? contents) + { + Contents = contents; + } + + /// Gets or sets the raw representation of the image generation response from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// + /// Gets or sets the generated content items. + /// + /// + /// Content is typically for images streamed from the generator, or for remotely hosted images, but + /// can also be provider-specific content types that represent the generated images. + /// + [AllowNull] + public IList Contents + { + get => field ??= []; + set; + } + + /// Gets or sets usage details for the image generation response. + public UsageDetails? Usage { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs new file mode 100644 index 00000000000..fe976231635 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for . +[Experimental("MEAI001")] +public static class ImageGeneratorExtensions +{ + private static readonly Dictionary _extensionToMimeType = new(StringComparer.OrdinalIgnoreCase) + { + [".png"] = "image/png", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".webp"] = "image/webp", + [".gif"] = "image/gif", + [".bmp"] = "image/bmp", + [".tiff"] = "image/tiff", + [".tif"] = "image/tiff", + }; + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IImageGenerator generator, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + + return generator.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type + /// and throws an exception if one isn't available. + /// + /// The generator. + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static object GetRequiredService(this IImageGenerator generator, Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(serviceType); + + return + generator.GetService(serviceType, serviceKey) ?? + throw Throw.CreateMissingServiceException(serviceType, serviceKey); + } + + /// + /// Asks the for an object of type + /// and throws an exception if one isn't available. + /// + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService GetRequiredService(this IImageGenerator generator, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + + if (generator.GetService(typeof(TService), serviceKey) is not TService service) + { + throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); + } + + return service; + } + + /// + /// Generates images based on a text prompt. + /// + /// The image generator. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// or is . + /// The images generated by the generator. + public static Task GenerateImagesAsync( + this IImageGenerator generator, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt), options, cancellationToken); + } + + /// + /// Edits images based on original images and a text prompt. + /// + /// The image generator. + /// The images to base edits on. + /// The prompt to guide the image editing. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// , , or is . + /// The images generated by the generator. + public static Task EditImagesAsync( + this IImageGenerator generator, + IEnumerable originalImages, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(originalImages); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt, originalImages), options, cancellationToken); + } + + /// + /// Edits a single image based on the original image and the specified prompt. + /// + /// The image generator. + /// The single image to base edits on. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// , , or is . + /// The images generated by the generator. + public static Task EditImageAsync( + this IImageGenerator generator, + DataContent originalImage, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(originalImage); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt, [originalImage]), options, cancellationToken); + } + + /// + /// Edits a single image based on a byte array and the specified prompt. + /// + /// The image generator. + /// The byte array containing the image data to base edits on. + /// The filename for the image data. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// + /// , , or is . + /// + /// The images generated by the generator. + public static Task EditImageAsync( + this IImageGenerator generator, + ReadOnlyMemory originalImageData, + string fileName, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(fileName); + _ = Throw.IfNull(prompt); + + // Infer media type from file extension + string mediaType = GetMediaTypeFromFileName(fileName); + + var dataContent = new DataContent(originalImageData, mediaType) { Name = fileName }; + return generator.GenerateAsync(new ImageGenerationRequest(prompt, [dataContent]), options, cancellationToken); + } + + /// + /// Gets the media type based on the file extension. + /// + /// The filename to extract the media type from. + /// The inferred media type. + private static string GetMediaTypeFromFileName(string fileName) + { + string extension = Path.GetExtension(fileName); + + if (_extensionToMimeType.TryGetValue(extension, out string? mediaType)) + { + return mediaType; + } + + return "image/png"; // Default to PNG if unknown extension + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs new file mode 100644 index 00000000000..c5604155285 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Provides metadata about an . +[Experimental("MEAI001")] +public class ImageGeneratorMetadata +{ + /// Initializes a new instance of the class. + /// + /// The name of the image generation provider, if applicable. Where possible, this should map to the + /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// The URL for accessing the image generation provider, if applicable. + /// The ID of the image generation model used by default, if applicable. + public ImageGeneratorMetadata(string? providerName = null, Uri? providerUri = null, string? defaultModelId = null) + { + DefaultModelId = defaultModelId; + ProviderName = providerName; + ProviderUri = providerUri; + } + + /// Gets the name of the image generation provider. + /// + /// Where possible, this maps to the appropriate name defined in the + /// OpenTelemetry Semantic Conventions for Generative AI systems. + /// + public string? ProviderName { get; } + + /// Gets the URL for accessing the image generation provider. + public Uri? ProviderUri { get; } + + /// Gets the ID of the default model used by this image generator. + /// + /// This value can be if no default model is set on the corresponding . + /// An individual request may override this value via . + /// + public string? DefaultModelId { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj index dc6896b7f53..22c7461de35 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -1,21 +1,20 @@ - + Microsoft.Extensions.AI - Abstractions for generative AI. + Abstractions representing generative AI components. AI + true - preview - false + normal 82 - 0 + 85 $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;CA1034;SA1316;S3253 $(NoWarn);MEAI001 true true @@ -23,7 +22,6 @@ true - true true true true @@ -31,12 +29,13 @@ true - + + - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index e69de29bb2d..b5ef3774b45 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -0,0 +1,2591 @@ +{ + "Name": "Microsoft.Extensions.AI.Abstractions, Version=9.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "sealed class Microsoft.Extensions.AI.AdditionalPropertiesDictionary : Microsoft.Extensions.AI.AdditionalPropertiesDictionary", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.AdditionalPropertiesDictionary();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.AdditionalPropertiesDictionary(System.Collections.Generic.IDictionary dictionary);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.AdditionalPropertiesDictionary(System.Collections.Generic.IEnumerable> collection);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Clone();", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.AdditionalPropertiesDictionary : System.Collections.Generic.IDictionary, System.Collections.Generic.ICollection>, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable, System.Collections.Generic.IReadOnlyDictionary, System.Collections.Generic.IReadOnlyCollection>", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.AdditionalPropertiesDictionary();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.AdditionalPropertiesDictionary(System.Collections.Generic.IDictionary dictionary);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.AdditionalPropertiesDictionary(System.Collections.Generic.IEnumerable> collection);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Add(string key, TValue value);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Clear();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Clone();", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AdditionalPropertiesDictionary.ContainsKey(string key);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator Microsoft.Extensions.AI.AdditionalPropertiesDictionary.GetEnumerator();", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Remove(string key);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AdditionalPropertiesDictionary.TryAdd(string key, TValue value);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AdditionalPropertiesDictionary.TryGetValue(string key, out T? value);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AdditionalPropertiesDictionary.TryGetValue(string key, out TValue value);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "int Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Count { get; }", + "Stage": "Stable" + }, + { + "Member": "TValue Microsoft.Extensions.AI.AdditionalPropertiesDictionary.this[string key] { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.ICollection Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Keys { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.ICollection Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Values { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "struct Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator.Enumerator();", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator.Dispose();", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator.MoveNext();", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator.Reset();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.KeyValuePair Microsoft.Extensions.AI.AdditionalPropertiesDictionary.Enumerator.Current { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.AIAnnotation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIAnnotation.AIAnnotation();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.AIAnnotation.AnnotatedRegions { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.AIAnnotation.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.AIAnnotation.RawRepresentation { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIContent.AIContent();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.AIContent.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.AIContent.Annotations { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.AIContent.RawRepresentation { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.AIFunction : Microsoft.Extensions.AI.AIFunctionDeclaration", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIFunction.AIFunction();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.AIFunction.InvokeAsync(Microsoft.Extensions.AI.AIFunctionArguments? arguments = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.AIFunction.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.AIFunction.AsDeclarationOnly();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "virtual System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.AIFunction.JsonSerializerOptions { get; }", + "Stage": "Stable" + }, + { + "Member": "virtual System.Reflection.MethodInfo? Microsoft.Extensions.AI.AIFunction.UnderlyingMethod { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.AIFunctionDeclaration : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration.AIFunctionDeclaration();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "virtual System.Text.Json.JsonElement Microsoft.Extensions.AI.AIFunctionDeclaration.JsonSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "virtual System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIFunctionDeclaration.ReturnJsonSchema { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.AIFunctionArguments : System.Collections.Generic.IDictionary, System.Collections.Generic.ICollection>, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable, System.Collections.Generic.IReadOnlyDictionary, System.Collections.Generic.IReadOnlyCollection>", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIFunctionArguments.AIFunctionArguments();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionArguments.AIFunctionArguments(System.Collections.Generic.IDictionary? arguments);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionArguments.AIFunctionArguments(System.Collections.Generic.IEqualityComparer? comparer);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionArguments.AIFunctionArguments(System.Collections.Generic.IDictionary? arguments, System.Collections.Generic.IEqualityComparer? comparer);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AIFunctionArguments.Add(string key, object? value);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AIFunctionArguments.Clear();", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionArguments.ContainsKey(string key);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.AIFunctionArguments.CopyTo(System.Collections.Generic.KeyValuePair[] array, int arrayIndex);", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IEnumerator> Microsoft.Extensions.AI.AIFunctionArguments.GetEnumerator();", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionArguments.Remove(string key);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionArguments.TryGetValue(string key, out object? value);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IDictionary? Microsoft.Extensions.AI.AIFunctionArguments.Context { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.AIFunctionArguments.Count { get; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.AIFunctionArguments.this[string key] { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.ICollection Microsoft.Extensions.AI.AIFunctionArguments.Keys { get; }", + "Stage": "Stable" + }, + { + "Member": "System.IServiceProvider? Microsoft.Extensions.AI.AIFunctionArguments.Services { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.ICollection Microsoft.Extensions.AI.AIFunctionArguments.Values { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.AIFunctionFactory", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Delegate method, Microsoft.Extensions.AI.AIFunctionFactoryOptions? options);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Delegate method, string? name = null, string? description = null, System.Text.Json.JsonSerializerOptions? serializerOptions = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Reflection.MethodInfo method, object? target, Microsoft.Extensions.AI.AIFunctionFactoryOptions? options);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Reflection.MethodInfo method, object? target, string? name = null, string? description = null, System.Text.Json.JsonSerializerOptions? serializerOptions = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Reflection.MethodInfo method, System.Func createInstanceFunc, Microsoft.Extensions.AI.AIFunctionFactoryOptions? options = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.AIFunctionFactory.CreateDeclaration(string name, string? description, System.Text.Json.JsonElement jsonSchema, System.Text.Json.JsonElement? returnJsonSchema = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.AIFunctionFactoryOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIFunctionFactoryOptions.AIFunctionFactoryOptions();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyDictionary? Microsoft.Extensions.AI.AIFunctionFactoryOptions.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.AIFunctionFactoryOptions.ConfigureParameterBinding { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.AIFunctionFactoryOptions.Description { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ExcludeResultSchema { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? Microsoft.Extensions.AI.AIFunctionFactoryOptions.JsonSchemaCreateOptions { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Func>? Microsoft.Extensions.AI.AIFunctionFactoryOptions.MarshalResult { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.AIFunctionFactoryOptions.Name { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonSerializerOptions? Microsoft.Extensions.AI.AIFunctionFactoryOptions.SerializerOptions { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "readonly record struct Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.ParameterBindingOptions();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.Equals(object obj);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.Equals(Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions other);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.GetHashCode();", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.operator ==(Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions left, Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions right);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.operator !=(Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions left, Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions right);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Func? Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.BindParameter { get; init; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ParameterBindingOptions.ExcludeFromSchema { get; init; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "readonly struct Microsoft.Extensions.AI.AIJsonSchemaCreateContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaCreateContext.AIJsonSchemaCreateContext();", + "Stage": "Stable" + }, + { + "Member": "TAttribute? Microsoft.Extensions.AI.AIJsonSchemaCreateContext.GetCustomAttribute(bool inherit = false);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo? Microsoft.Extensions.AI.AIJsonSchemaCreateContext.BaseTypeInfo { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Type? Microsoft.Extensions.AI.AIJsonSchemaCreateContext.DeclaringType { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Reflection.ICustomAttributeProvider? Microsoft.Extensions.AI.AIJsonSchemaCreateContext.ParameterAttributeProvider { get; }", + "Stage": "Stable" + }, + { + "Member": "System.ReadOnlySpan Microsoft.Extensions.AI.AIJsonSchemaCreateContext.Path { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Reflection.ICustomAttributeProvider? Microsoft.Extensions.AI.AIJsonSchemaCreateContext.PropertyAttributeProvider { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.Serialization.Metadata.JsonPropertyInfo? Microsoft.Extensions.AI.AIJsonSchemaCreateContext.PropertyInfo { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.Serialization.Metadata.JsonTypeInfo Microsoft.Extensions.AI.AIJsonSchemaCreateContext.TypeInfo { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed record Microsoft.Extensions.AI.AIJsonSchemaCreateOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.AIJsonSchemaCreateOptions();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaCreateOptions Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.$();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.Equals(Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? other);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.GetHashCode();", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.operator ==(Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? left, Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? right);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.operator !=(Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? left, Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? right);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static Microsoft.Extensions.AI.AIJsonSchemaCreateOptions Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.Default { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.IncludeParameter { get; init; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.IncludeSchemaKeyword { get; init; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaTransformOptions? Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.TransformOptions { get; init; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.AIJsonSchemaCreateOptions.TransformSchemaNode { get; init; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.AIJsonSchemaTransformCache", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaTransformCache.AIJsonSchemaTransformCache(Microsoft.Extensions.AI.AIJsonSchemaTransformOptions transformOptions);", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.AIFunction function);", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.AIFunctionDeclaration function);", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.ChatResponseFormatJson responseFormat);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaTransformOptions Microsoft.Extensions.AI.AIJsonSchemaTransformCache.TransformOptions { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "readonly struct Microsoft.Extensions.AI.AIJsonSchemaTransformContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaTransformContext.AIJsonSchemaTransformContext();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformContext.IsCollectionElementSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformContext.IsDictionaryValueSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "System.ReadOnlySpan Microsoft.Extensions.AI.AIJsonSchemaTransformContext.Path { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.AIJsonSchemaTransformContext.PropertyName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed record Microsoft.Extensions.AI.AIJsonSchemaTransformOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.AIJsonSchemaTransformOptions();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIJsonSchemaTransformOptions Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.$();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.Equals(Microsoft.Extensions.AI.AIJsonSchemaTransformOptions? other);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.GetHashCode();", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.operator ==(Microsoft.Extensions.AI.AIJsonSchemaTransformOptions? left, Microsoft.Extensions.AI.AIJsonSchemaTransformOptions? right);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.operator !=(Microsoft.Extensions.AI.AIJsonSchemaTransformOptions? left, Microsoft.Extensions.AI.AIJsonSchemaTransformOptions? right);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.ConvertBooleanSchemas { get; init; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.DisallowAdditionalProperties { get; init; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.MoveDefaultKeywordToDescription { get; init; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.RequireAllProperties { get; init; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.TransformSchemaNode { get; init; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.AIJsonSchemaTransformOptions.UseNullableKeyword { get; init; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.AIJsonUtilities", + "Stage": "Stable", + "Methods": [ + { + "Member": "static void Microsoft.Extensions.AI.AIJsonUtilities.AddAIContentType(this System.Text.Json.JsonSerializerOptions options, string typeDiscriminatorId);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.AIJsonUtilities.AddAIContentType(this System.Text.Json.JsonSerializerOptions options, System.Type contentType, string typeDiscriminatorId);", + "Stage": "Stable" + }, + { + "Member": "static System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonUtilities.CreateFunctionJsonSchema(System.Reflection.MethodBase method, string? title = null, string? description = null, System.Text.Json.JsonSerializerOptions? serializerOptions = null, Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? inferenceOptions = null);", + "Stage": "Stable" + }, + { + "Member": "static System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema(System.Type? type, string? description = null, bool hasDefaultValue = false, object? defaultValue = null, System.Text.Json.JsonSerializerOptions? serializerOptions = null, Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? inferenceOptions = null);", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.AIJsonUtilities.HashDataToString(System.ReadOnlySpan values, System.Text.Json.JsonSerializerOptions? serializerOptions = null);", + "Stage": "Stable" + }, + { + "Member": "static System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonUtilities.TransformSchema(System.Text.Json.JsonElement schema, Microsoft.Extensions.AI.AIJsonSchemaTransformOptions transformOptions);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.AIJsonUtilities.DefaultOptions { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AITool.AITool();", + "Stage": "Stable" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.AITool.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "TService? Microsoft.Extensions.AI.AITool.GetService(object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.AITool.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "virtual System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.AITool.AdditionalProperties { get; }", + "Stage": "Stable" + }, + { + "Member": "virtual string Microsoft.Extensions.AI.AITool.Description { get; }", + "Stage": "Stable" + }, + { + "Member": "virtual string Microsoft.Extensions.AI.AITool.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.AnnotatedRegion", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AnnotatedRegion.AnnotatedRegion();", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.AutoChatToolMode : Microsoft.Extensions.AI.ChatToolMode", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AutoChatToolMode.AutoChatToolMode();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.AutoChatToolMode.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.AutoChatToolMode.GetHashCode();", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.BinaryEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.BinaryEmbedding.BinaryEmbedding(System.Collections.BitArray vector);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "override int Microsoft.Extensions.AI.BinaryEmbedding.Dimensions { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.BitArray Microsoft.Extensions.AI.BinaryEmbedding.Vector { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.BinaryEmbedding.VectorConverter", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.BinaryEmbedding.VectorConverter.VectorConverter();", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.BitArray Microsoft.Extensions.AI.BinaryEmbedding.VectorConverter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Stable" + }, + { + "Member": "override void Microsoft.Extensions.AI.BinaryEmbedding.VectorConverter.Write(System.Text.Json.Utf8JsonWriter writer, System.Collections.BitArray value, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ChatClientExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static object Microsoft.Extensions.AI.ChatClientExtensions.GetRequiredService(this Microsoft.Extensions.AI.IChatClient client, System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "static TService Microsoft.Extensions.AI.ChatClientExtensions.GetRequiredService(this Microsoft.Extensions.AI.IChatClient client, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.ChatClientExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient client, string chatMessage, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.ChatClientExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient client, Microsoft.Extensions.AI.ChatMessage chatMessage, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static TService? Microsoft.Extensions.AI.ChatClientExtensions.GetService(this Microsoft.Extensions.AI.IChatClient client, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "static System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ChatClientExtensions.GetStreamingResponseAsync(this Microsoft.Extensions.AI.IChatClient client, string chatMessage, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ChatClientExtensions.GetStreamingResponseAsync(this Microsoft.Extensions.AI.IChatClient client, Microsoft.Extensions.AI.ChatMessage chatMessage, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatClientMetadata", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatClientMetadata.ChatClientMetadata(string? providerName = null, System.Uri? providerUri = null, string? defaultModelId = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.ChatClientMetadata.DefaultModelId { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatClientMetadata.ProviderName { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.ChatClientMetadata.ProviderUri { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "readonly struct Microsoft.Extensions.AI.ChatFinishReason : System.IEquatable", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatFinishReason.ChatFinishReason(string value);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatFinishReason.ChatFinishReason();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.ChatFinishReason.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.ChatFinishReason.Equals(Microsoft.Extensions.AI.ChatFinishReason other);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.ChatFinishReason.GetHashCode();", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.ChatFinishReason.operator ==(Microsoft.Extensions.AI.ChatFinishReason left, Microsoft.Extensions.AI.ChatFinishReason right);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.ChatFinishReason.operator !=(Microsoft.Extensions.AI.ChatFinishReason left, Microsoft.Extensions.AI.ChatFinishReason right);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.ChatFinishReason.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static Microsoft.Extensions.AI.ChatFinishReason Microsoft.Extensions.AI.ChatFinishReason.ContentFilter { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatFinishReason Microsoft.Extensions.AI.ChatFinishReason.Length { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatFinishReason Microsoft.Extensions.AI.ChatFinishReason.Stop { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatFinishReason Microsoft.Extensions.AI.ChatFinishReason.ToolCalls { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.ChatFinishReason.Value { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChatFinishReason.Converter", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatFinishReason.Converter.Converter();", + "Stage": "Stable" + }, + { + "Member": "override Microsoft.Extensions.AI.ChatFinishReason Microsoft.Extensions.AI.ChatFinishReason.Converter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Stable" + }, + { + "Member": "override void Microsoft.Extensions.AI.ChatFinishReason.Converter.Write(System.Text.Json.Utf8JsonWriter writer, Microsoft.Extensions.AI.ChatFinishReason value, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatMessage", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatMessage.ChatMessage();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatMessage.ChatMessage(Microsoft.Extensions.AI.ChatRole role, string? content);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatMessage.ChatMessage(Microsoft.Extensions.AI.ChatRole role, System.Collections.Generic.IList? contents);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatMessage Microsoft.Extensions.AI.ChatMessage.Clone();", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.ChatMessage.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.ChatMessage.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatMessage.AuthorName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.ChatMessage.Contents { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatMessage.CreatedAt { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatMessage.MessageId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.ChatMessage.RawRepresentation { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatRole Microsoft.Extensions.AI.ChatMessage.Role { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.ChatMessage.Text { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatOptions.ChatOptions();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatOptions.ChatOptions(Microsoft.Extensions.AI.ChatOptions? other);", + "Stage": "Stable" + }, + { + "Member": "virtual Microsoft.Extensions.AI.ChatOptions Microsoft.Extensions.AI.ChatOptions.Clone();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.ChatOptions.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool? Microsoft.Extensions.AI.ChatOptions.AllowMultipleToolCalls { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatOptions.ConversationId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatOptions.Instructions { get; set; }", + "Stage": "Stable" + }, + { + "Member": "float? Microsoft.Extensions.AI.ChatOptions.FrequencyPenalty { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.ChatOptions.MaxOutputTokens { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatOptions.ModelId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "float? Microsoft.Extensions.AI.ChatOptions.PresencePenalty { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.ChatOptions.RawRepresentationFactory { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseFormat? Microsoft.Extensions.AI.ChatOptions.ResponseFormat { get; set; }", + "Stage": "Stable" + }, + { + "Member": "long? Microsoft.Extensions.AI.ChatOptions.Seed { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.ChatOptions.StopSequences { get; set; }", + "Stage": "Stable" + }, + { + "Member": "float? Microsoft.Extensions.AI.ChatOptions.Temperature { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatToolMode? Microsoft.Extensions.AI.ChatOptions.ToolMode { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.ChatOptions.Tools { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.ChatOptions.TopK { get; set; }", + "Stage": "Stable" + }, + { + "Member": "float? Microsoft.Extensions.AI.ChatOptions.TopP { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatResponse", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatResponse.ChatResponse();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponse.ChatResponse(Microsoft.Extensions.AI.ChatMessage message);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponse.ChatResponse(System.Collections.Generic.IList? messages);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate[] Microsoft.Extensions.AI.ChatResponse.ToChatResponseUpdates();", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.ChatResponse.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.ChatResponse.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponse.ConversationId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatResponse.CreatedAt { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatFinishReason? Microsoft.Extensions.AI.ChatResponse.FinishReason { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.ChatResponse.Messages { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponse.ModelId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.ChatResponse.RawRepresentation { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponse.ResponseId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.ChatResponse.Text { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.ChatResponse.Usage { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ChatResponseExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static void Microsoft.Extensions.AI.ChatResponseExtensions.AddMessages(this System.Collections.Generic.IList list, Microsoft.Extensions.AI.ChatResponse response);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.ChatResponseExtensions.AddMessages(this System.Collections.Generic.IList list, System.Collections.Generic.IEnumerable updates);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.ChatResponseExtensions.AddMessages(this System.Collections.Generic.IList list, Microsoft.Extensions.AI.ChatResponseUpdate update, System.Func? filter = null);", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.ChatResponseExtensions.AddMessagesAsync(this System.Collections.Generic.IList list, System.Collections.Generic.IAsyncEnumerable updates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponse Microsoft.Extensions.AI.ChatResponseExtensions.ToChatResponse(this System.Collections.Generic.IEnumerable updates);", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.ChatResponseExtensions.ToChatResponseAsync(this System.Collections.Generic.IAsyncEnumerable updates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatResponseFormat", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonElement schema, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Type schemaType, System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.Json { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatText Microsoft.Extensions.AI.ChatResponseFormat.Text { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChatResponseFormatJson : Microsoft.Extensions.AI.ChatResponseFormat", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatResponseFormatJson.ChatResponseFormatJson(System.Text.Json.JsonElement? schema, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Text.Json.JsonElement? Microsoft.Extensions.AI.ChatResponseFormatJson.Schema { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseFormatJson.SchemaDescription { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseFormatJson.SchemaName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChatResponseFormatText : Microsoft.Extensions.AI.ChatResponseFormat", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatResponseFormatText.ChatResponseFormatText();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.ChatResponseFormatText.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.ChatResponseFormatText.GetHashCode();", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatResponseUpdate", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate.ChatResponseUpdate();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate.ChatResponseUpdate(Microsoft.Extensions.AI.ChatRole? role, string? content);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate.ChatResponseUpdate(Microsoft.Extensions.AI.ChatRole? role, System.Collections.Generic.IList? contents);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate Microsoft.Extensions.AI.ChatResponseUpdate.Clone();", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.ChatResponseUpdate.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.ChatResponseUpdate.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseUpdate.AuthorName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.ChatResponseUpdate.Contents { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseUpdate.ConversationId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatResponseUpdate.CreatedAt { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatFinishReason? Microsoft.Extensions.AI.ChatResponseUpdate.FinishReason { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseUpdate.MessageId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseUpdate.ModelId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.ChatResponseUpdate.RawRepresentation { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ChatResponseUpdate.ResponseId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatRole? Microsoft.Extensions.AI.ChatResponseUpdate.Role { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.ChatResponseUpdate.Text { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "readonly struct Microsoft.Extensions.AI.ChatRole : System.IEquatable", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatRole.ChatRole(string value);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatRole.ChatRole();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.ChatRole.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.ChatRole.Equals(Microsoft.Extensions.AI.ChatRole other);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.ChatRole.GetHashCode();", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.ChatRole.operator ==(Microsoft.Extensions.AI.ChatRole left, Microsoft.Extensions.AI.ChatRole right);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.ChatRole.operator !=(Microsoft.Extensions.AI.ChatRole left, Microsoft.Extensions.AI.ChatRole right);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.ChatRole.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static Microsoft.Extensions.AI.ChatRole Microsoft.Extensions.AI.ChatRole.Assistant { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatRole Microsoft.Extensions.AI.ChatRole.System { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatRole Microsoft.Extensions.AI.ChatRole.Tool { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatRole Microsoft.Extensions.AI.ChatRole.User { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.ChatRole.Value { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChatRole.Converter", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatRole.Converter.Converter();", + "Stage": "Stable" + }, + { + "Member": "override Microsoft.Extensions.AI.ChatRole Microsoft.Extensions.AI.ChatRole.Converter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Stable" + }, + { + "Member": "override void Microsoft.Extensions.AI.ChatRole.Converter.Write(System.Text.Json.Utf8JsonWriter writer, Microsoft.Extensions.AI.ChatRole value, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatToolMode", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.RequiredChatToolMode Microsoft.Extensions.AI.ChatToolMode.RequireSpecific(string functionName);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static Microsoft.Extensions.AI.AutoChatToolMode Microsoft.Extensions.AI.ChatToolMode.Auto { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.NoneChatToolMode Microsoft.Extensions.AI.ChatToolMode.None { get; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.RequiredChatToolMode Microsoft.Extensions.AI.ChatToolMode.RequireAny { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.CitationAnnotation : Microsoft.Extensions.AI.AIAnnotation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.CitationAnnotation.CitationAnnotation();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.Title { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.ToolName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.CitationAnnotation.Url { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.FileId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.Snippet { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DataContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DataContent.DataContent(System.Uri uri, string? mediaType = null);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.DataContent.DataContent(string uri, string? mediaType = null);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.DataContent.DataContent(System.ReadOnlyMemory data, string mediaType);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.DataContent.HasTopLevelMediaType(string topLevelType);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.ReadOnlyMemory Microsoft.Extensions.AI.DataContent.Base64Data { get; }", + "Stage": "Stable" + }, + { + "Member": "System.ReadOnlyMemory Microsoft.Extensions.AI.DataContent.Data { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.DataContent.Name { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.DataContent.MediaType { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.DataContent.Uri { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DelegatingAIFunction : Microsoft.Extensions.AI.AIFunction", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DelegatingAIFunction.DelegatingAIFunction(Microsoft.Extensions.AI.AIFunction innerFunction);", + "Stage": "Stable" + }, + { + "Member": "override object? Microsoft.Extensions.AI.DelegatingAIFunction.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.DelegatingAIFunction.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunction.ToString();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.DelegatingAIFunction.InnerFunction { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.DelegatingAIFunction.AdditionalProperties { get; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunction.Description { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonElement Microsoft.Extensions.AI.DelegatingAIFunction.JsonSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.DelegatingAIFunction.JsonSerializerOptions { get; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.DelegatingAIFunction.Name { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Text.Json.JsonElement? Microsoft.Extensions.AI.DelegatingAIFunction.ReturnJsonSchema { get; }", + "Stage": "Stable" + }, + { + "Member": "override System.Reflection.MethodInfo? Microsoft.Extensions.AI.DelegatingAIFunction.UnderlyingMethod { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DelegatingChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DelegatingChatClient.DelegatingChatClient(Microsoft.Extensions.AI.IChatClient innerClient);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.DelegatingChatClient.Dispose();", + "Stage": "Stable" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.DelegatingChatClient.Dispose(bool disposing);", + "Stage": "Stable" + }, + { + "Member": "virtual System.Threading.Tasks.Task Microsoft.Extensions.AI.DelegatingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.DelegatingChatClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "virtual System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.DelegatingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.IChatClient Microsoft.Extensions.AI.DelegatingChatClient.InnerClient { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DelegatingEmbeddingGenerator : Microsoft.Extensions.AI.IEmbeddingGenerator, Microsoft.Extensions.AI.IEmbeddingGenerator, System.IDisposable where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DelegatingEmbeddingGenerator.DelegatingEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.DelegatingEmbeddingGenerator.Dispose();", + "Stage": "Stable" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.DelegatingEmbeddingGenerator.Dispose(bool disposing);", + "Stage": "Stable" + }, + { + "Member": "virtual System.Threading.Tasks.Task> Microsoft.Extensions.AI.DelegatingEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.DelegatingEmbeddingGenerator.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.IEmbeddingGenerator Microsoft.Extensions.AI.DelegatingEmbeddingGenerator.InnerGenerator { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DelegatingSpeechToTextClient : Microsoft.Extensions.AI.ISpeechToTextClient, System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DelegatingSpeechToTextClient.DelegatingSpeechToTextClient(Microsoft.Extensions.AI.ISpeechToTextClient innerClient);", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.DelegatingSpeechToTextClient.Dispose();", + "Stage": "Experimental" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.DelegatingSpeechToTextClient.Dispose(bool disposing);", + "Stage": "Experimental" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.DelegatingSpeechToTextClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.DelegatingSpeechToTextClient.GetStreamingTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Threading.Tasks.Task Microsoft.Extensions.AI.DelegatingSpeechToTextClient.GetTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.ISpeechToTextClient Microsoft.Extensions.AI.DelegatingSpeechToTextClient.InnerClient { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Embedding.Embedding();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.Embedding.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.Embedding.CreatedAt { get; set; }", + "Stage": "Stable" + }, + { + "Member": "virtual int Microsoft.Extensions.AI.Embedding.Dimensions { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.Embedding.ModelId { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Embedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Embedding.Embedding(System.ReadOnlyMemory vector);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "override int Microsoft.Extensions.AI.Embedding.Dimensions { get; }", + "Stage": "Stable" + }, + { + "Member": "System.ReadOnlyMemory Microsoft.Extensions.AI.Embedding.Vector { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.EmbeddingGenerationOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.EmbeddingGenerationOptions.EmbeddingGenerationOptions();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.EmbeddingGenerationOptions.EmbeddingGenerationOptions(Microsoft.Extensions.AI.EmbeddingGenerationOptions? other);", + "Stage": "Stable" + }, + { + "Member": "virtual Microsoft.Extensions.AI.EmbeddingGenerationOptions Microsoft.Extensions.AI.EmbeddingGenerationOptions.Clone();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.EmbeddingGenerationOptions.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.EmbeddingGenerationOptions.Dimensions { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.EmbeddingGenerationOptions.ModelId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.EmbeddingGenerationOptions.RawRepresentationFactory { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.EmbeddingGeneratorExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static System.Threading.Tasks.Task<(TInput Value, TEmbedding Embedding)[]> Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GenerateAndZipAsync(this Microsoft.Extensions.AI.IEmbeddingGenerator generator, System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GenerateAsync(this Microsoft.Extensions.AI.IEmbeddingGenerator generator, TInput value, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GenerateVectorAsync(this Microsoft.Extensions.AI.IEmbeddingGenerator> generator, TInput value, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static object Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GetRequiredService(this Microsoft.Extensions.AI.IEmbeddingGenerator generator, System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "static TService Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GetRequiredService(this Microsoft.Extensions.AI.IEmbeddingGenerator generator, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "static TService? Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GetService(this Microsoft.Extensions.AI.IEmbeddingGenerator generator, object? serviceKey = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.EmbeddingGeneratorMetadata", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.EmbeddingGeneratorMetadata.EmbeddingGeneratorMetadata(string? providerName = null, System.Uri? providerUri = null, string? defaultModelId = null, int? defaultModelDimensions = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "int? Microsoft.Extensions.AI.EmbeddingGeneratorMetadata.DefaultModelDimensions { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.EmbeddingGeneratorMetadata.DefaultModelId { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.EmbeddingGeneratorMetadata.ProviderName { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.EmbeddingGeneratorMetadata.ProviderUri { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ErrorContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ErrorContent.ErrorContent(string? message);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.ErrorContent.Details { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.ErrorContent.ErrorCode { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.ErrorContent.Message { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.FunctionCallContent.FunctionCallContent(string callId, string name, System.Collections.Generic.IDictionary? arguments = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.FunctionCallContent Microsoft.Extensions.AI.FunctionCallContent.CreateFromParsedArguments(TEncoding encodedArguments, string callId, string name, System.Func?> argumentParser);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IDictionary? Microsoft.Extensions.AI.FunctionCallContent.Arguments { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.FunctionCallContent.CallId { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Exception? Microsoft.Extensions.AI.FunctionCallContent.Exception { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.FunctionCallContent.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.FunctionResultContent.FunctionResultContent(string callId, object? result);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.FunctionResultContent.CallId { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Exception? Microsoft.Extensions.AI.FunctionResultContent.Exception { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.FunctionResultContent.Result { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.GeneratedEmbeddings : System.Collections.Generic.IList, System.Collections.Generic.ICollection, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable, System.Collections.Generic.IReadOnlyList, System.Collections.Generic.IReadOnlyCollection where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.GeneratedEmbeddings.GeneratedEmbeddings();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.GeneratedEmbeddings.GeneratedEmbeddings(int capacity);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.GeneratedEmbeddings.GeneratedEmbeddings(System.Collections.Generic.IEnumerable embeddings);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.GeneratedEmbeddings.Add(TEmbedding item);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.GeneratedEmbeddings.AddRange(System.Collections.Generic.IEnumerable items);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.GeneratedEmbeddings.Clear();", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.GeneratedEmbeddings.Contains(TEmbedding item);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.GeneratedEmbeddings.CopyTo(TEmbedding[] array, int arrayIndex);", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IEnumerator Microsoft.Extensions.AI.GeneratedEmbeddings.GetEnumerator();", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.GeneratedEmbeddings.IndexOf(TEmbedding item);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.GeneratedEmbeddings.Insert(int index, TEmbedding item);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.GeneratedEmbeddings.Remove(TEmbedding item);", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.GeneratedEmbeddings.RemoveAt(int index);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.GeneratedEmbeddings.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.GeneratedEmbeddings.Count { get; }", + "Stage": "Stable" + }, + { + "Member": "TEmbedding Microsoft.Extensions.AI.GeneratedEmbeddings.this[int index] { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.GeneratedEmbeddings.Usage { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.HostedCodeInterpreterTool : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedCodeInterpreterTool.HostedCodeInterpreterTool();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedCodeInterpreterTool.Inputs { get; set; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.HostedCodeInterpreterTool.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.HostedFileSearchTool : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedFileSearchTool.HostedFileSearchTool();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedFileSearchTool.Inputs { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.HostedFileSearchTool.MaximumResultCount { get; set; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.HostedFileSearchTool.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.HostedWebSearchTool : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedWebSearchTool.HostedWebSearchTool();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "override string Microsoft.Extensions.AI.HostedWebSearchTool.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.HostedFileContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedFileContent.HostedFileContent(string fileId);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.HostedFileContent.HasTopLevelMediaType(string topLevelType);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.HostedFileContent.FileId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.HostedFileContent.MediaType { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.HostedFileContent.Name { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedVectorStoreContent.HostedVectorStoreContent(string vectorStoreId);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.HostedVectorStoreContent.VectorStoreId { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.IChatClient : System.IDisposable", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.Task Microsoft.Extensions.AI.IChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.IChatClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.IChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.IEmbeddingGenerator : System.IDisposable", + "Stage": "Stable", + "Methods": [ + { + "Member": "object? Microsoft.Extensions.AI.IEmbeddingGenerator.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.IEmbeddingGenerator : Microsoft.Extensions.AI.IEmbeddingGenerator, System.IDisposable where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.Task> Microsoft.Extensions.AI.IEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.ISpeechToTextClient : System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "object? Microsoft.Extensions.AI.ISpeechToTextClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ISpeechToTextClient.GetStreamingTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "System.Threading.Tasks.Task Microsoft.Extensions.AI.ISpeechToTextClient.GetTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.NoneChatToolMode : Microsoft.Extensions.AI.ChatToolMode", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.NoneChatToolMode.NoneChatToolMode();", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.NoneChatToolMode.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.NoneChatToolMode.GetHashCode();", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.RequiredChatToolMode : Microsoft.Extensions.AI.ChatToolMode", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.RequiredChatToolMode.RequiredChatToolMode(string? requiredFunctionName);", + "Stage": "Stable" + }, + { + "Member": "override bool Microsoft.Extensions.AI.RequiredChatToolMode.Equals(object? obj);", + "Stage": "Stable" + }, + { + "Member": "override int Microsoft.Extensions.AI.RequiredChatToolMode.GetHashCode();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.RequiredChatToolMode.RequiredFunctionName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.SpeechToTextClientExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static TService? Microsoft.Extensions.AI.SpeechToTextClientExtensions.GetService(this Microsoft.Extensions.AI.ISpeechToTextClient client, object? serviceKey = null);", + "Stage": "Experimental" + }, + { + "Member": "static System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.SpeechToTextClientExtensions.GetStreamingTextAsync(this Microsoft.Extensions.AI.ISpeechToTextClient client, Microsoft.Extensions.AI.DataContent audioSpeechContent, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.SpeechToTextClientExtensions.GetTextAsync(this Microsoft.Extensions.AI.ISpeechToTextClient client, Microsoft.Extensions.AI.DataContent audioSpeechContent, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.SpeechToTextClientMetadata", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextClientMetadata.SpeechToTextClientMetadata(string? providerName = null, System.Uri? providerUri = null, string? defaultModelId = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextClientMetadata.DefaultModelId { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextClientMetadata.ProviderName { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.SpeechToTextClientMetadata.ProviderUri { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.SpeechToTextOptions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextOptions.SpeechToTextOptions();", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.SpeechToTextOptions Microsoft.Extensions.AI.SpeechToTextOptions.Clone();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.SpeechToTextOptions.AdditionalProperties { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextOptions.ModelId { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.SpeechToTextOptions.RawRepresentationFactory { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextOptions.SpeechLanguage { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "int? Microsoft.Extensions.AI.SpeechToTextOptions.SpeechSampleRate { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextOptions.TextLanguage { get; set; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.SpeechToTextResponse", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponse.SpeechToTextResponse();", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponse.SpeechToTextResponse(System.Collections.Generic.IList contents);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponse.SpeechToTextResponse(string? content);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdate[] Microsoft.Extensions.AI.SpeechToTextResponse.ToSpeechToTextResponseUpdates();", + "Stage": "Experimental" + }, + { + "Member": "override string Microsoft.Extensions.AI.SpeechToTextResponse.ToString();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.SpeechToTextResponse.AdditionalProperties { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.SpeechToTextResponse.Contents { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.TimeSpan? Microsoft.Extensions.AI.SpeechToTextResponse.EndTime { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextResponse.ModelId { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "object? Microsoft.Extensions.AI.SpeechToTextResponse.RawRepresentation { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextResponse.ResponseId { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.TimeSpan? Microsoft.Extensions.AI.SpeechToTextResponse.StartTime { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.SpeechToTextResponse.Text { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.SpeechToTextResponseUpdate", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdate.SpeechToTextResponseUpdate();", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdate.SpeechToTextResponseUpdate(System.Collections.Generic.IList contents);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdate.SpeechToTextResponseUpdate(string? content);", + "Stage": "Experimental" + }, + { + "Member": "override string Microsoft.Extensions.AI.SpeechToTextResponseUpdate.ToString();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.SpeechToTextResponseUpdate.AdditionalProperties { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.SpeechToTextResponseUpdate.Contents { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.TimeSpan? Microsoft.Extensions.AI.SpeechToTextResponseUpdate.EndTime { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdate.Kind { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextResponseUpdate.ModelId { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "object? Microsoft.Extensions.AI.SpeechToTextResponseUpdate.RawRepresentation { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.SpeechToTextResponseUpdate.ResponseId { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.TimeSpan? Microsoft.Extensions.AI.SpeechToTextResponseUpdate.StartTime { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.SpeechToTextResponseUpdate.Text { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.SpeechToTextResponseUpdateExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextResponse Microsoft.Extensions.AI.SpeechToTextResponseUpdateExtensions.ToSpeechToTextResponse(this System.Collections.Generic.IEnumerable updates);", + "Stage": "Experimental" + }, + { + "Member": "static System.Threading.Tasks.Task Microsoft.Extensions.AI.SpeechToTextResponseUpdateExtensions.ToSpeechToTextResponseAsync(this System.Collections.Generic.IAsyncEnumerable updates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ] + }, + { + "Type": "readonly struct Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind : System.IEquatable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.SpeechToTextResponseUpdateKind(string value);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.SpeechToTextResponseUpdateKind();", + "Stage": "Experimental" + }, + { + "Member": "override bool Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Equals(object? obj);", + "Stage": "Experimental" + }, + { + "Member": "bool Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Equals(Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind other);", + "Stage": "Experimental" + }, + { + "Member": "override int Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.GetHashCode();", + "Stage": "Experimental" + }, + { + "Member": "static bool Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.operator ==(Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind left, Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind right);", + "Stage": "Experimental" + }, + { + "Member": "static bool Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.operator !=(Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind left, Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind right);", + "Stage": "Experimental" + }, + { + "Member": "override string Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.ToString();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Error { get; }", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.SessionClose { get; }", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.SessionOpen { get; }", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.TextUpdated { get; }", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.TextUpdating { get; }", + "Stage": "Experimental" + }, + { + "Member": "string Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Value { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Converter", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Converter.Converter();", + "Stage": "Experimental" + }, + { + "Member": "override Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Converter.Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Experimental" + }, + { + "Member": "override void Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind.Converter.Write(System.Text.Json.Utf8JsonWriter writer, Microsoft.Extensions.AI.SpeechToTextResponseUpdateKind value, System.Text.Json.JsonSerializerOptions options);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.TextContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.TextContent.TextContent(string? text);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.TextContent.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.TextContent.Text { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.TextReasoningContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.TextReasoningContent.TextReasoningContent(string? text);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.TextReasoningContent.ToString();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.TextReasoningContent.Text { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.TextReasoningContent.ProtectedData { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.TextSpanAnnotatedRegion : Microsoft.Extensions.AI.AnnotatedRegion", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.TextSpanAnnotatedRegion.TextSpanAnnotatedRegion();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "int? Microsoft.Extensions.AI.TextSpanAnnotatedRegion.StartIndex { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.TextSpanAnnotatedRegion.EndIndex { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.UriContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.UriContent.UriContent(string uri, string mediaType);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.UriContent.UriContent(System.Uri uri, string mediaType);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.UriContent.HasTopLevelMediaType(string topLevelType);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.UriContent.MediaType { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Uri Microsoft.Extensions.AI.UriContent.Uri { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.UsageContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.UsageContent.UsageContent();", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.UsageContent.UsageContent(Microsoft.Extensions.AI.UsageDetails details);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.UsageDetails Microsoft.Extensions.AI.UsageContent.Details { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.UsageDetails", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.UsageDetails.UsageDetails();", + "Stage": "Stable" + }, + { + "Member": "void Microsoft.Extensions.AI.UsageDetails.Add(Microsoft.Extensions.AI.UsageDetails usage);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.UsageDetails.AdditionalCounts { get; set; }", + "Stage": "Stable" + }, + { + "Member": "long? Microsoft.Extensions.AI.UsageDetails.InputTokenCount { get; set; }", + "Stage": "Stable" + }, + { + "Member": "long? Microsoft.Extensions.AI.UsageDetails.OutputTokenCount { get; set; }", + "Stage": "Stable" + }, + { + "Member": "long? Microsoft.Extensions.AI.UsageDetails.TotalTokenCount { get; set; }", + "Stage": "Stable" + } + ] + } + ] +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md index 94a0c53e162..0f981dbbc87 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md @@ -1,6 +1,18 @@ # Microsoft.Extensions.AI.Abstractions -Provides abstractions representing generative AI components. +.NET developers need to integrate and interact with a growing variety of artificial intelligence (AI) services in their apps. The `Microsoft.Extensions.AI` libraries provide a unified approach for representing generative AI components, and enable seamless integration and interoperability with various AI services. + +## The packages + +The [Microsoft.Extensions.AI.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions) package provides the core exchange types, including [`IChatClient`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.ichatclient) and [`IEmbeddingGenerator`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.iembeddinggenerator-2). Any .NET library that provides an LLM client can implement the `IChatClient` interface to enable seamless integration with consuming code. + +The [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) package has an implicit dependency on the `Microsoft.Extensions.AI.Abstractions` package. This package enables you to easily integrate components such as automatic function tool invocation, telemetry, and caching into your applications using familiar dependency injection and middleware patterns. For example, it provides the [`UseOpenTelemetry(ChatClientBuilder, ILoggerFactory, String, Action)`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.opentelemetrychatclientbuilderextensions.useopentelemetry#microsoft-extensions-ai-opentelemetrychatclientbuilderextensions-useopentelemetry(microsoft-extensions-ai-chatclientbuilder-microsoft-extensions-logging-iloggerfactory-system-string-system-action((microsoft-extensions-ai-opentelemetrychatclient)))) extension method, which adds OpenTelemetry support to the chat client pipeline. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.AI.Abstractions`. + +To also have access to higher-level utilities for working with generative AI components, reference the `Microsoft.Extensions.AI` package instead (which itself references `Microsoft.Extensions.AI.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.AI` package along with one or more libraries that provide concrete implementations of the abstractions. ## Install the package @@ -10,7 +22,7 @@ From the command-line: dotnet add package Microsoft.Extensions.AI.Abstractions ``` -or directly in the C# project file: +Or directly in the C# project file: ```xml @@ -18,598 +30,9 @@ or directly in the C# project file: ``` -To also have access to higher-level utilities for working with such components, instead reference the [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) -package. Libraries providing implementations of the abstractions will typically only reference `Microsoft.Extensions.AI.Abstractions`, whereas most consuming applications and services -will reference the `Microsoft.Extensions.AI` package (which itself references `Microsoft.Extensions.AI.Abstractions`) along with one or more libraries that provide concrete implementations -of the abstractions. - -## Usage Examples - -### `IChatClient` - -The `IChatClient` interface defines a client abstraction responsible for interacting with AI services that provide "chat" capabilities. It defines methods for sending and receiving messages comprised of multi-modal content (text, images, audio, etc.), with responses providing either a complete result or one streamed incrementally. Additionally, it allows for retrieving strongly-typed services that may be provided by the client or its underlying services. - -.NET libraries that provide clients for language models and services may provide an implementation of the `IChatClient` interface. Any consumers of the interface are then able to interoperate seamlessly with these models and services via the abstractions. - -#### Requesting a Chat Response: `GetResponseAsync` - -With an instance of `IChatClient`, the `GetResponseAsync` method may be used to send a request and get a response. The request is composed of one or more messages, each of which is composed of one or more pieces of content. Accelerator methods exist to simplify common cases, such as constructing a request for a single piece of text content. - -```csharp -IChatClient client = ...; - -Console.WriteLine(await client.GetResponseAsync("What is AI?")); -``` - -The core `GetResponseAsync` method on the `IChatClient` interface accepts a list of messages. This list often represents the history of all messages that are part of the conversation. - -```csharp -IChatClient client = ...; - -Console.WriteLine(await client.GetResponseAsync( -[ - new(ChatRole.System, "You are a helpful AI assistant"), - new(ChatRole.User, "What is AI?"), -])); -``` - -The `ChatResponse` that's returned from `GetResponseAsync` exposes a list of `ChatMessage` instances representing one or more messages generated as part of the operation. -In common cases, there is only one response message, but a variety of situations can result in their being multiple; the list is ordered, such that the last message in -the list represents the final message to the request. In order to provide all of those response messages back to the service in a subsequent request, the messages from -the response may be added back into the messages list. - -```csharp -List history = []; -while (true) -{ - Console.Write("Q: "); - history.Add(new(ChatRole.User, Console.ReadLine())); - - var response = await client.GetResponseAsync(history); - Console.WriteLine(response); - - history.AddMessages(response); -} -``` - -#### Requesting a Streaming Chat Response: `GetStreamingResponseAsync` - -The inputs to `GetStreamingResponseAsync` are identical to those of `GetResponseAsync`. However, rather than returning the complete response as part of a -`ChatResponse` object, the method returns an `IAsyncEnumerable`, providing a stream of updates that together form the single response. - -```csharp -IChatClient client = ...; - -await foreach (var update in client.GetStreamingResponseAsync("What is AI?")) -{ - Console.Write(update); -} -``` - -As with `GetResponseAsync`, the updates from `IChatClient.GetStreamingResponseAsync` can be added back into the messages list. As the updates provided -are individual pieces of a response, helpers like `ToChatResponse` can be used to compose one or more updates back into a single `ChatResponse` instance. -Further helpers like `AddMessages` perform that same operation and then extract the composed messages from the response and add them into a list. - -```csharp -List history = []; -while (true) -{ - Console.Write("Q: "); - history.Add(new(ChatRole.User, Console.ReadLine())); - - List updates = []; - await foreach (var update in client.GetStreamingResponseAsync(history)) - { - Console.Write(update); - } - Console.WriteLine(); - - history.AddMessages(updates); -} -``` - -#### Tool Calling - -Some models and services support the notion of tool calling, where requests may include information about tools (in particular .NET methods) that the model may request be invoked in order to gather additional information. Rather than sending back a response message that represents the final response to the input, the model sends back a request to invoke a given function with a given set of arguments; the client may then find and invoke the relevant function and send back the results to the model (along with all the rest of the history). The abstractions in `Microsoft.Extensions.AI` include representations for various forms of content that may be included in messages, and this includes representations for these function call requests and results. While it's possible for the consumer of the `IChatClient` to interact with this content directly, `Microsoft.Extensions.AI` supports automating these interactions. It provides an `AIFunction` that represents an invocable function along with metadata for describing the function to the AI model, as well as an `AIFunctionFactory` for creating `AIFunction`s to represent .NET methods. It also provides a `FunctionInvokingChatClient` that both is an `IChatClient` and also wraps an `IChatClient`, enabling layering automatic function invocation capabilities around an arbitrary `IChatClient` implementation. - -```csharp -using Microsoft.Extensions.AI; - -string GetCurrentWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; - -IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1") - .AsBuilder() - .UseFunctionInvocation() - .Build(); - -ChatOptions options = new() { Tools = [AIFunctionFactory.Create(GetCurrentWeather)] }; - -var response = client.GetStreamingResponseAsync("Should I wear a rain coat?", options); -await foreach (var update in response) -{ - Console.Write(update); -} -``` - -#### Caching - -`Microsoft.Extensions.AI` provides other such delegating `IChatClient` implementations. The `DistributedCachingChatClient` is an `IChatClient` that layers caching around another arbitrary `IChatClient` instance. When a unique chat history that's not been seen before is submitted to the `DistributedCachingChatClient`, it forwards it along to the underlying client, and then caches the response prior to it being forwarded back to the consumer. The next time the same history is submitted, such that a cached response can be found in the cache, the `DistributedCachingChatClient` can return back the cached response rather than needing to forward the request along the pipeline. - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -IChatClient client = new ChatClientBuilder(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")) - .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - -string[] prompts = ["What is AI?", "What is .NET?", "What is AI?"]; - -foreach (var prompt in prompts) -{ - await foreach (var update in client.GetStreamingResponseAsync(prompt)) - { - Console.Write(update); - } - Console.WriteLine(); -} -``` - -#### Telemetry - -Other such delegating chat clients are provided as well. The `OpenTelemetryChatClient`, for example, provides an implementation of the [OpenTelemetry Semantic Conventions for Generative AI systems](https://opentelemetry.io/docs/specs/semconv/gen-ai/). As with the aforementioned `IChatClient` delegators, this implementation layers metrics and spans around other arbitrary `IChatClient` implementations. - -```csharp -using Microsoft.Extensions.AI; -using OpenTelemetry.Trace; - -// Configure OpenTelemetry exporter -var sourceName = Guid.NewGuid().ToString(); -var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddConsoleExporter() - .Build(); - -IChatClient client = new ChatClientBuilder(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")) - .UseOpenTelemetry(sourceName: sourceName, configure: c => c.EnableSensitiveData = true) - .Build(); - -Console.WriteLine(await client.GetResponseAsync("What is AI?")); -``` - -Alternatively, the `LoggingChatClient` and corresponding `UseLogging` method provide a simple way to write log entries to an `ILogger` for every request and response. - -#### Options - -Every call to `GetResponseAsync` or `GetStreamingResponseAsync` may optionally supply a `ChatOptions` instance containing additional parameters for the operation. The most common parameters that are common amongst AI models and services show up as strongly-typed properties on the type, such as `ChatOptions.Temperature`. Other parameters may be supplied by name in a weakly-typed manner via the `ChatOptions.AdditionalProperties` dictionary. - -Options may also be baked into an `IChatClient` via the `ConfigureOptions` extension method on `ChatClientBuilder`. This delegating client wraps another client and invokes the supplied delegate to populate a `ChatOptions` instance for every call. For example, to ensure that the `ChatOptions.ModelId` property defaults to a particular model name, code like the following may be used: -```csharp -using Microsoft.Extensions.AI; - -IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434")) - .AsBuilder() - .ConfigureOptions(options => options.ModelId ??= "phi3") - .Build(); - -Console.WriteLine(await client.GetResponseAsync("What is AI?")); // will request "phi3" -Console.WriteLine(await client.GetResponseAsync("What is AI?", new() { ModelId = "llama3.1" })); // will request "llama3.1" -``` - -#### Pipelines of Chat Functionality - -All of these `IChatClient`s may be layered, creating a pipeline of any number of components that all add additional functionality. Such components may come from `Microsoft.Extensions.AI`, may come from other NuGet packages, or may be your own custom implementations that augment the behavior in whatever ways you need. - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using OpenTelemetry.Trace; - -// Configure OpenTelemetry exporter -var sourceName = Guid.NewGuid().ToString(); -var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddConsoleExporter() - .Build(); - -// Explore changing the order of the intermediate "Use" calls to see the impact -// that has on what gets cached, traced, etc. -IChatClient client = new ChatClientBuilder(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")) - .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) - .UseFunctionInvocation() - .UseOpenTelemetry(sourceName: sourceName, configure: c => c.EnableSensitiveData = true) - .Build(); - -ChatOptions options = new() -{ - Tools = [AIFunctionFactory.Create( - () => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining", - name: "GetCurrentWeather", - description: "Gets the current weather")] -}; - -for (int i = 0; i < 3; i++) -{ - List history = - [ - new ChatMessage(ChatRole.System, "You are a helpful AI assistant"), - new ChatMessage(ChatRole.User, "Do I need an umbrella?") - ]; - - Console.WriteLine(await client.GetResponseAsync(history, options)); -} -``` - -#### Custom `IChatClient` Middleware - -Anyone can layer in such additional functionality. While it's possible to implement `IChatClient` directly, the `DelegatingChatClient` class is an implementation of the `IChatClient` interface that serves as a base class for creating chat clients that delegate their operations to another `IChatClient` instance. It is designed to facilitate the chaining of multiple clients, allowing calls to be passed through to an underlying client. The class provides default implementations for methods such as `GetResponseAsync`, `GetStreamingResponseAsync`, and `Dispose`, simply forwarding the calls to the inner client instance. A derived type may then override just the methods it needs to in order to augment the behavior, delegating to the base implementation in order to forward the call along to the wrapped client. This setup is useful for creating flexible and modular chat clients that can be easily extended and composed. - -Here is an example class derived from `DelegatingChatClient` to provide rate limiting functionality, utilizing the [System.Threading.RateLimiting](https://www.nuget.org/packages/System.Threading.RateLimiting) library: -```csharp -using Microsoft.Extensions.AI; -using System.Threading.RateLimiting; - -public sealed class RateLimitingChatClient(IChatClient innerClient, RateLimiter rateLimiter) : DelegatingChatClient(innerClient) -{ - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); - if (!lease.IsAcquired) - throw new InvalidOperationException("Unable to acquire lease."); - - return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - } - - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); - if (!lease.IsAcquired) - throw new InvalidOperationException("Unable to acquire lease."); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - yield return update; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - rateLimiter.Dispose(); - - base.Dispose(disposing); - } -} -``` - -This can then be composed as with other `IChatClient` implementations. - -```csharp -using Microsoft.Extensions.AI; -using System.Threading.RateLimiting; - -var client = new RateLimitingChatClient( - new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1"), - new ConcurrencyLimiter(new() { PermitLimit = 1, QueueLimit = int.MaxValue })); - -Console.WriteLine(await client.GetResponseAsync("What color is the sky?")); -``` - -To make it easier to compose such components with others, the author of the component is recommended to create a "Use" extension method for registering this component into a pipeline, e.g. -```csharp -public static class RateLimitingChatClientExtensions -{ - public static ChatClientBuilder UseRateLimiting(this ChatClientBuilder builder, RateLimiter rateLimiter) => - builder.Use(innerClient => new RateLimitingChatClient(innerClient, rateLimiter)); -} -``` - -Such extensions may also query for relevant services from the DI container; the `IServiceProvider` used by the pipeline is passed in as an optional parameter: -```csharp -public static class RateLimitingChatClientExtensions -{ - public static ChatClientBuilder UseRateLimiting(this ChatClientBuilder builder, RateLimiter? rateLimiter = null) => - builder.Use((innerClient, services) => new RateLimitingChatClient(innerClient, services.GetRequiredService())); -} -``` - -The consumer can then easily use this in their pipeline, e.g. -```csharp -var client = new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1") - .AsBuilder() - .UseDistributedCache() - .UseRateLimiting() - .UseOpenTelemetry() - .Build(services); -``` - -The above extension methods demonstrate using a `Use` method on `ChatClientBuilder`. `ChatClientBuilder` also provides `Use` overloads that make it easier to -write such delegating handlers. For example, in the earlier `RateLimitingChatClient` example, the overrides of `GetResponseAsync` and `GetStreamingResponseAsync` only -need to do work before and after delegating to the next client in the pipeline. To achieve the same thing without writing a custom class, an overload of `Use` may -be used that accepts a delegate which is used for both `GetResponseAsync` and `GetStreamingResponseAsync`, reducing the boilderplate required: -```csharp -RateLimiter rateLimiter = ...; -var client = new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1") - .AsBuilder() - .UseDistributedCache() - .Use(async (messages, options, nextAsync, cancellationToken) => - { - using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); - if (!lease.IsAcquired) - throw new InvalidOperationException("Unable to acquire lease."); - - await nextAsync(messages, options, cancellationToken); - }) - .UseOpenTelemetry() - .Build(); -``` - -For scenarios where the developer would like to specify delegating implementations of `GetResponseAsync` and `GetStreamingResponseAsync` inline, -and where it's important to be able to write a different implementation for each in order to handle their unique return types specially, -another overload of `Use` exists that accepts a delegate for each. - -#### Dependency Injection - -While not required, `IChatClient` implementations will often be provided to an application via dependency injection (DI). In this example, an `IDistributedCache` is added into the DI container, as is an `IChatClient`. The registration for the `IChatClient` employs a builder that creates a pipeline containing a caching client (which will then use an `IDistributedCache` retrieved from DI) and the sample client. Elsewhere in the app, the injected `IChatClient` may be retrieved and used. - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -// App Setup -var builder = Host.CreateApplicationBuilder(); -builder.Services.AddDistributedMemoryCache(); -builder.Services.AddChatClient(new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1")) - .UseDistributedCache(); -var host = builder.Build(); - -// Elsewhere in the app -var chatClient = host.Services.GetRequiredService(); -Console.WriteLine(await chatClient.GetResponseAsync("What is AI?")); -``` - -What instance and configuration is injected may differ based on the current needs of the application, and multiple pipelines may be injected with different keys. - -#### Stateless vs Stateful Clients - -"Stateless" services require all relevant conversation history to sent back on every request, while "stateful" services keep track of the history and instead -require only additional messages be sent with a request. The `IChatClient` interface is designed to handle both stateless and stateful AI services. - -When working with a stateless service, callers maintain a list of all messages, adding in all received response messages, and providing the list -back on subsequent interactions. -```csharp -List history = []; -while (true) -{ - Console.Write("Q: "); - history.Add(new(ChatRole.User, Console.ReadLine())); - - var response = await client.GetResponseAsync(history); - Console.WriteLine(response); - - history.AddMessages(response); -} -``` - -For stateful services, you may know ahead of time an identifier used for the relevant conversation. That identifier can be put into `ChatOptions.ConversationId`. -Usage then follows the same pattern, except there's no need to maintain a history manually. -```csharp -ChatOptions options = new() { ConversationId = "my-conversation-id" }; -while (true) -{ - Console.Write("Q: "); - ChatMessage message = new(ChatRole.User, Console.ReadLine()); - - Console.WriteLine(await client.GetResponseAsync(message, options)); -} -``` - -Some services may support automatically creating a thread ID for a request that doesn't have one. In such cases, you can transfer the `ChatResponse.ConversationId` over -to the `ChatOptions.ConversationId` for subsequent requests, e.g. -```csharp -ChatOptions options = new(); -while (true) -{ - Console.Write("Q: "); - ChatMessage message = new(ChatRole.User, Console.ReadLine()); - - ChatResponse response = await client.GetResponseAsync(message, options); - Console.WriteLine(response); - - options.ConversationId = response.ConversationId; -} -``` - -If you don't know ahead of time whether the service is stateless or stateful, both can be accomodated by checking the response `ConversationId` -and acting based on its value. Here, if the response `ConversationId` is set, then that value is propagated to the options and the history -cleared so as to not resend the same history again. If, however, the `ConversationId` is not set, then the response message is added to the -history so that it's sent back to the service on the next turn. -```csharp -List history = []; -ChatOptions options = new(); -while (true) -{ - Console.Write("Q: "); - history.Add(new(ChatRole.User, Console.ReadLine())); - - ChatResponse response = await client.GetResponseAsync(history); - Console.WriteLine(response); - - options.ConversationId = response.ConversationId; - if (response.ConversationId is not null) - { - history.Clear(); - } - else - { - history.AddMessages(response); - } -} -``` - -### IEmbeddingGenerator - -The `IEmbeddingGenerator` interface represents a generic generator of embeddings, where `TInput` is the type of input values being embedded and `TEmbedding` is the type of generated embedding, inheriting from `Embedding`. - -The `Embedding` class provides a base class for embeddings generated by an `IEmbeddingGenerator`. This class is designed to store and manage the metadata and data associated with embeddings. Types derived from `Embedding`, like `Embedding`, then provide the concrete embedding vector data. For example, an `Embedding` exposes a `ReadOnlyMemory Vector { get; }` property for access to its embedding data. - -`IEmbeddingGenerator` defines a method to asynchronously generate embeddings for a collection of input values with optional configuration and cancellation support. Additionally, it provides metadata describing the generator and allows for the retrieval of strongly-typed services that may be provided by the generator or its underlying services. - -#### Sample Implementation - -Here is a sample implementation of an `IEmbeddingGenerator` to show the general structure but that just generates random embedding vectors. You can find actual concrete implementations in the following packages: - -- [Microsoft.Extensions.AI.OpenAI](https://aka.ms/meai-openai-nuget) -- [Microsoft.Extensions.AI.Ollama](https://aka.ms/meai-ollama-nuget) - -```csharp -using Microsoft.Extensions.AI; - -public class SampleEmbeddingGenerator(Uri endpoint, string modelId) : IEmbeddingGenerator> -{ - private readonly EmbeddingGeneratorMetadata _metadata = new("SampleEmbeddingGenerator", endpoint, modelId); - - public async Task>> GenerateAsync( - IEnumerable values, - EmbeddingGenerationOptions? options = null, - CancellationToken cancellationToken = default) - { - // Simulate some async operation - await Task.Delay(100, cancellationToken); - - // Create random embeddings - return new GeneratedEmbeddings>( - from value in values - select new Embedding( - Enumerable.Range(0, 384).Select(_ => Random.Shared.NextSingle()).ToArray())); - } - - object? IChatClient.GetService(Type serviceType, object? serviceKey = null) => - serviceKey is not null ? null : - serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : - serviceType?.IsInstanceOfType(this) is true ? this : - null; - - void IDisposable.Dispose() { } -} -``` - -#### Creating an Embedding: `GenerateAsync` - -The primary operation performed with an `IEmbeddingGenerator` is generating embeddings, which is accomplished with its `GenerateAsync` method. - -```csharp -using Microsoft.Extensions.AI; - -IEmbeddingGenerator> generator = - new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model"); - -foreach (var embedding in await generator.GenerateAsync(["What is AI?", "What is .NET?"])) -{ - Console.WriteLine(string.Join(", ", embedding.Vector.ToArray())); -} -``` - -Accelerator extension methods also exist to simplify common cases, such as generating an embedding vector from a single input, e.g. -```csharp -using Microsoft.Extensions.AI; +## Documentation -IEmbeddingGenerator> generator = - new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model"); - -ReadOnlyMemory vector = generator.GenerateVectorAsync("What is AI?"); -``` - -#### Pipelines of Functionality - -As with `IChatClient`, `IEmbeddingGenerator` implementations may be layered. Just as `Microsoft.Extensions.AI` provides delegating implementations of `IChatClient` for caching and telemetry, it does so for `IEmbeddingGenerator` as well. - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using OpenTelemetry.Trace; - -// Configure OpenTelemetry exporter -var sourceName = Guid.NewGuid().ToString(); -var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddConsoleExporter() - .Build(); - -// Explore changing the order of the intermediate "Use" calls to see that impact -// that has on what gets cached, traced, etc. -var generator = new EmbeddingGeneratorBuilder>( - new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model")) - .UseDistributedCache(new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()))) - .UseOpenTelemetry(sourceName: sourceName) - .Build(); - -var embeddings = await generator.GenerateAsync( -[ - "What is AI?", - "What is .NET?", - "What is AI?" -]); - -foreach (var embedding in embeddings) -{ - Console.WriteLine(string.Join(", ", embedding.Vector.ToArray())); -} -``` - -Also as with `IChatClient`, `IEmbeddingGenerator` enables building custom middleware that extends the functionality of an `IEmbeddingGenerator`. The `DelegatingEmbeddingGenerator` class is an implementation of the `IEmbeddingGenerator` interface that serves as a base class for creating embedding generators which delegate their operations to another `IEmbeddingGenerator` instance. It allows for chaining multiple generators in any order, passing calls through to an underlying generator. The class provides default implementations for methods such as `GenerateAsync` and `Dispose`, which simply forward the calls to the inner generator instance, enabling flexible and modular embedding generation. - -Here is an example implementation of such a delegating embedding generator that rate limits embedding generation requests: -```csharp -using Microsoft.Extensions.AI; -using System.Threading.RateLimiting; - -public class RateLimitingEmbeddingGenerator(IEmbeddingGenerator> innerGenerator, RateLimiter rateLimiter) : - DelegatingEmbeddingGenerator>(innerGenerator) -{ - public override async Task>> GenerateAsync( - IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - { - using var lease = await rateLimiter.AcquireAsync(permitCount: 1, cancellationToken).ConfigureAwait(false); - if (!lease.IsAcquired) - throw new InvalidOperationException("Unable to acquire lease."); - - return await base.GenerateAsync(values, options, cancellationToken); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - rateLimiter.Dispose(); - - base.Dispose(disposing); - } -} -``` - -This can then be layered around an arbitrary `IEmbeddingGenerator>` to rate limit all embedding generation operations performed. - -```csharp -using Microsoft.Extensions.AI; -using System.Threading.RateLimiting; - -IEmbeddingGenerator> generator = - new RateLimitingEmbeddingGenerator( - new SampleEmbeddingGenerator(new Uri("http://coolsite.ai"), "my-custom-model"), - new ConcurrencyLimiter(new() { PermitLimit = 1, QueueLimit = int.MaxValue })); - -foreach (var embedding in await generator.GenerateAsync(["What is AI?", "What is .NET?"])) -{ - Console.WriteLine(string.Join(", ", embedding.Vector.ToArray())); -} -``` +Refer to the [Microsoft.Extensions.AI libraries documentation](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) for more information and API usage examples. ## Feedback & Contributing diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ResponseContinuationToken.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ResponseContinuationToken.cs new file mode 100644 index 00000000000..cf73130be10 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ResponseContinuationToken.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a token used to resume, continue, or rehydrate an operation across multiple scenarios/calls, +/// such as resuming a streamed response from a specific point or retrieving the result of a background operation. +/// Subclasses of this class encapsulate all necessary information within the token to facilitate these actions. +/// +[JsonConverter(typeof(Converter))] +[Experimental("MEAI001")] +public class ResponseContinuationToken +{ + /// Bytes representing this token. + private readonly ReadOnlyMemory _bytes; + + /// Initializes a new instance of the class. + protected ResponseContinuationToken() + { + } + + /// Initializes a new instance of the class. + /// Bytes to create the token from. + protected ResponseContinuationToken(ReadOnlyMemory bytes) + { + _bytes = bytes; + } + + /// Create a new instance of from the provided . + /// + /// Bytes representing the . + /// A equivalent to the one from which + /// the original bytes were obtained. + public static ResponseContinuationToken FromBytes(ReadOnlyMemory bytes) => new(bytes); + + /// Gets the bytes representing this . + /// Bytes representing the ."/> + public virtual ReadOnlyMemory ToBytes() => _bytes; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + [Experimental("MEAI001")] + public sealed class Converter : JsonConverter + { + /// + public override ResponseContinuationToken Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return ResponseContinuationToken.FromBytes(reader.GetBytesFromBase64()); + } + + /// + public override void Write(Utf8JsonWriter writer, ResponseContinuationToken value, JsonSerializerOptions options) + { + _ = Throw.IfNull(writer); + _ = Throw.IfNull(value); + + writer.WriteBase64StringValue(value.ToBytes().Span); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs index cb196a4c91c..0e93a9bb1af 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -9,34 +11,62 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class SpeechToTextOptions { + /// Initializes a new instance of the class. + public SpeechToTextOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected SpeechToTextOptions(SpeechToTextOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + ModelId = other.ModelId; + RawRepresentationFactory = other.RawRepresentationFactory; + SpeechLanguage = other.SpeechLanguage; + SpeechSampleRate = other.SpeechSampleRate; + TextLanguage = other.TextLanguage; + } + + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the model ID for the speech to text. public string? ModelId { get; set; } /// Gets or sets the language of source speech. public string? SpeechLanguage { get; set; } - /// Gets or sets the language for the target generated text. - public string? TextLanguage { get; set; } - /// Gets or sets the sample rate of the speech input audio. public int? SpeechSampleRate { get; set; } - /// Gets or sets any additional properties associated with the options. - public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the language for the target generated text. + public string? TextLanguage { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the embedding generation options from an underlying implementation. + /// + /// + /// The underlying implementation may have its own representation of options. + /// When or + /// is invoked with an , that implementation may convert the provided options into + /// its own representation in order to use it while performing the operation. For situations where a consumer knows + /// which concrete is being used and how it represents options, a new instance of that + /// implementation-specific options type may be returned by this callback, for the + /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// instance further based on other settings supplied on this instance or from other inputs, + /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } /// Produces a clone of the current instance. /// A clone of the current instance. - public virtual SpeechToTextOptions Clone() - { - SpeechToTextOptions options = new() - { - ModelId = ModelId, - SpeechLanguage = SpeechLanguage, - TextLanguage = TextLanguage, - SpeechSampleRate = SpeechSampleRate, - AdditionalProperties = AdditionalProperties?.Clone(), - }; - - return options; - } + public virtual SpeechToTextOptions Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs index 24fa20a11ed..a63d5cf2d63 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs @@ -7,8 +7,6 @@ using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operators - namespace Microsoft.Extensions.AI; /// Represents the result of an speech to text request. @@ -47,10 +45,10 @@ public SpeechToTextResponse(string? content) /// Gets or sets the ID of the speech to text response. public string? ResponseId { get; set; } - /// Gets or sets the model ID used in the creation of the speech to text completion. + /// Gets or sets the model ID used in the creation of the speech to text response. public string? ModelId { get; set; } - /// Gets or sets the raw representation of the speech to text completion from an underlying implementation. + /// Gets or sets the raw representation of the speech to text response from an underlying implementation. /// /// If a is created to represent some underlying object from another object /// model, this property can be used to store that original object. This can be useful for debugging or @@ -59,7 +57,7 @@ public SpeechToTextResponse(string? content) [JsonIgnore] public object? RawRepresentation { get; set; } - /// Gets or sets any additional properties associated with the speech to text completion. + /// Gets or sets any additional properties associated with the speech to text response. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } /// Gets the text of this speech to text response. @@ -76,9 +74,15 @@ public SpeechToTextResponse(string? content) /// An array of instances that may be used to represent this . public SpeechToTextResponseUpdate[] ToSpeechToTextResponseUpdates() { - SpeechToTextResponseUpdate update = new SpeechToTextResponseUpdate + IList contents = Contents; + if (Usage is { } usage) + { + contents = [.. contents, new UsageContent(usage)]; + } + + SpeechToTextResponseUpdate update = new() { - Contents = Contents, + Contents = contents, AdditionalProperties = AdditionalProperties, RawRepresentation = RawRepresentation, StartTime = StartTime, @@ -98,4 +102,7 @@ public IList Contents get => _contents ??= []; set => _contents = value; } + + /// Gets or sets usage details for the speech to text response. + public UsageDetails? Usage { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs index 24b7f079302..e65dd7dcbe7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs @@ -7,8 +7,6 @@ using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operators - namespace Microsoft.Extensions.AI; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs index 230ec838ba3..67272761ceb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -25,32 +24,13 @@ public static SpeechToTextResponse ToSpeechToTextResponse( _ = Throw.IfNull(updates); SpeechToTextResponse response = new(); - List contents = []; - string? responseId = null; - string? modelId = null; - AdditionalPropertiesDictionary? additionalProperties = null; - TimeSpan? endTime = null; foreach (var update in updates) { - // Track the first start time provided by the updates - response.StartTime ??= update.StartTime; - - // Track the last end time provided by the updates - if (update.EndTime is not null) - { - endTime = update.EndTime; - } - - ProcessUpdate(update, contents, ref responseId, ref modelId, ref additionalProperties); + ProcessUpdate(update, response); } - ChatResponseExtensions.CoalesceTextContent(contents); - response.EndTime = endTime; - response.Contents = contents; - response.ResponseId = responseId; - response.ModelId = modelId; - response.AdditionalProperties = additionalProperties; + ChatResponseExtensions.CoalesceContent((List)response.Contents); return response; } @@ -70,33 +50,13 @@ static async Task ToResponseAsync( IAsyncEnumerable updates, CancellationToken cancellationToken) { SpeechToTextResponse response = new(); - List contents = []; - string? responseId = null; - string? modelId = null; - AdditionalPropertiesDictionary? additionalProperties = null; - TimeSpan? endTime = null; await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { - // Track the first start time provided by the updates - response.StartTime ??= update.StartTime; - - // Track the last end time provided by the updates - if (update.EndTime is not null) - { - endTime = update.EndTime; - } - - ProcessUpdate(update, contents, ref responseId, ref modelId, ref additionalProperties); + ProcessUpdate(update, response); } - ChatResponseExtensions.CoalesceTextContent(contents); - - response.EndTime = endTime; - response.Contents = contents; - response.ResponseId = responseId; - response.ModelId = modelId; - response.AdditionalProperties = additionalProperties; + ChatResponseExtensions.CoalesceContent((List)response.Contents); return response; } @@ -104,40 +64,59 @@ static async Task ToResponseAsync( /// Processes the , incorporating its contents and properties. /// The update to process. - /// The list of content items being accumulated. - /// The response ID to update if the update has one. - /// The model ID to update if the update has one. - /// The additional properties to update if the update has any. + /// The object that should be updated based on . private static void ProcessUpdate( SpeechToTextResponseUpdate update, - List contents, - ref string? responseId, - ref string? modelId, - ref AdditionalPropertiesDictionary? additionalProperties) + SpeechToTextResponse response) { if (update.ResponseId is not null) { - responseId = update.ResponseId; + response.ResponseId = update.ResponseId; } if (update.ModelId is not null) { - modelId = update.ModelId; + response.ModelId = update.ModelId; } - contents.AddRange(update.Contents); + if (response.StartTime is null || (update.StartTime is not null && update.StartTime < response.StartTime)) + { + // Track the first start time provided by the updates + response.StartTime = update.StartTime; + } + + if (response.EndTime is null || (update.EndTime is not null && update.EndTime > response.EndTime)) + { + // Track the last end time provided by the updates + response.EndTime = update.EndTime; + } + + foreach (var content in update.Contents) + { + switch (content) + { + // Usage content is treated specially and propagated to the response's Usage. + case UsageContent usage: + (response.Usage ??= new()).Add(usage.Details); + break; + + default: + response.Contents.Add(content); + break; + } + } if (update.AdditionalProperties is not null) { - if (additionalProperties is null) + if (response.AdditionalProperties is null) { - additionalProperties = new(update.AdditionalProperties); + response.AdditionalProperties = new(update.AdditionalProperties); } else { foreach (var entry in update.AdditionalProperties) { - additionalProperties[entry.Key] = entry.Value; + response.AdditionalProperties[entry.Key] = entry.Value; } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ToolReduction/IToolReductionStrategy.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ToolReduction/IToolReductionStrategy.cs new file mode 100644 index 00000000000..029eeae47a1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ToolReduction/IToolReductionStrategy.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a strategy capable of selecting a reduced set of tools for a chat request. +/// +/// +/// A tool reduction strategy is invoked prior to sending a request to an underlying , +/// enabling scenarios where a large tool catalog must be trimmed to fit provider limits or to improve model +/// tool selection quality. +/// +/// The implementation should return a non- enumerable. Returning the original +/// instance indicates no change. Returning a different enumerable indicates +/// the caller may replace the existing tool list. +/// +/// +[Experimental("MEAI001")] +public interface IToolReductionStrategy +{ + /// + /// Selects the tools that should be included for a specific request. + /// + /// The chat messages for the request. This is an to avoid premature materialization. + /// The chat options for the request (may be ). + /// A token to observe cancellation. + /// + /// A (possibly reduced) enumerable of instances. Must never be . + /// Returning the same instance referenced by . signals no change. + /// + Task> SelectToolsForRequestAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/AITool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/AITool.cs new file mode 100644 index 00000000000..366dc66f77c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/AITool.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a tool that can be specified to an AI service. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract class AITool +{ + /// Initializes a new instance of the class. + protected AITool() + { + } + + /// Gets the name of the tool. + public virtual string Name => GetType().Name; + + /// Gets a description of the tool, suitable for use in describing the purpose to a model. + public virtual string Description => string.Empty; + + /// Gets any additional properties associated with the tool. + public virtual IReadOnlyDictionary AdditionalProperties => EmptyReadOnlyDictionary.Instance; + + /// + public override string ToString() => Name; + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public TService? GetService(object? serviceKey = null) => + GetService(typeof(TService), serviceKey) is TService service ? service : default; + + /// Gets the string to display in the debugger for this instance. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay + { + get + { + StringBuilder sb = new(Name); + + if (Description is string description && !string.IsNullOrEmpty(description)) + { + _ = sb.Append(" (").Append(description).Append(')'); + } + + foreach (var entry in AdditionalProperties) + { + _ = sb.Append(", ").Append(entry.Key).Append(" = ").Append(entry.Value); + } + + return sb.ToString(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedCodeInterpreterTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs similarity index 52% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedCodeInterpreterTool.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs index 6662fc420e3..4bd63a0df75 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedCodeInterpreterTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; + namespace Microsoft.Extensions.AI; /// Represents a hosted tool that can be specified to an AI service to enable it to execute code it generates. @@ -14,4 +16,15 @@ public class HostedCodeInterpreterTool : AITool public HostedCodeInterpreterTool() { } + + /// + public override string Name => "code_interpreter"; + + /// Gets or sets a collection of to be used as input to the code interpreter tool. + /// + /// Services support different varied kinds of inputs. Most support the IDs of files that are hosted by the service, + /// represented via . Some also support binary data, represented via . + /// Unsupported inputs will be ignored by the to which the tool is passed. + /// + public IList? Inputs { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs new file mode 100644 index 00000000000..b130e26b647 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to perform file search operations. +/// +/// This tool is designed to facilitate file search functionality within AI services. It allows the service to search +/// for relevant content based on the provided inputs and constraints, such as the maximum number of results. +/// +public class HostedFileSearchTool : AITool +{ + /// Initializes a new instance of the class. + public HostedFileSearchTool() + { + } + + /// + public override string Name => "file_search"; + + /// Gets or sets a collection of to be used as input to the file search tool. + /// + /// If no explicit inputs are provided, the service determines what inputs should be searched. Different services + /// support different kinds of inputs, for example, some might respect using provider-specific file IDs, + /// others might support binary data uploaded as part of the request in , and others might support + /// content in a hosted vector store and represented by a . + /// + public IList? Inputs { get; set; } + + /// Gets or sets a requested bound on the number of matches the tool should produce. + public int? MaximumResultCount { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs new file mode 100644 index 00000000000..aca072653ab --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to perform image generation. +/// +/// This tool does not itself implement image generation. It is a marker that can be used to inform a service +/// that the service is allowed to perform image generation if the service is capable of doing so. +/// +[Experimental("MEAI001")] +public class HostedImageGenerationTool : AITool +{ + /// + /// Initializes a new instance of the class with the specified options. + /// + public HostedImageGenerationTool() + { + } + + /// + /// Gets or sets the options used to configure image generation. + /// + public ImageGenerationOptions? Options { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs new file mode 100644 index 00000000000..aa33a581710 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a hosted MCP server tool that can be specified to an AI service. +/// +[Experimental("MEAI001")] +public class HostedMcpServerTool : AITool +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the remote MCP server. + /// The address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. + /// or is . + /// or is empty or composed entirely of whitespace. + public HostedMcpServerTool(string serverName, string serverAddress) + { + ServerName = Throw.IfNullOrWhitespace(serverName); + ServerAddress = Throw.IfNullOrWhitespace(serverAddress); + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the remote MCP server. + /// The URL of the remote MCP server. + /// or is . + /// is empty or composed entirely of whitespace. + /// is not an absolute URL. + public HostedMcpServerTool(string serverName, Uri serverUrl) + : this(serverName, ValidateUrl(serverUrl)) + { + } + + private static string ValidateUrl(Uri serverUrl) + { + _ = Throw.IfNull(serverUrl); + + if (!serverUrl.IsAbsoluteUri) + { + Throw.ArgumentException(nameof(serverUrl), "The provided URL is not absolute."); + } + + return serverUrl.AbsoluteUri; + } + + /// + public override string Name => "mcp"; + + /// + /// Gets the name of the remote MCP server that is used to identify it. + /// + public string ServerName { get; } + + /// + /// Gets the address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. + /// + public string ServerAddress { get; } + + /// + /// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server. + /// + public string? AuthorizationToken { get; set; } + + /// + /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. + /// + public string? ServerDescription { get; set; } + + /// + /// Gets or sets the list of tools allowed to be used by the AI service. + /// + /// + /// The default value is , which allows any tool to be used. + /// + public IList? AllowedTools { get; set; } + + /// + /// Gets or sets the approval mode that indicates when the AI service should require user approval for tool calls to the remote MCP server. + /// + /// + /// + /// You can set this property to to require approval for all tool calls, + /// or to to never require approval. + /// + /// + /// The default value is , which some providers might treat the same as . + /// + /// + /// The underlying provider is not guaranteed to support or honor the approval mode. + /// + /// + public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedWebSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs similarity index 90% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedWebSearchTool.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs index 06d11bf40ed..19d25510d19 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedWebSearchTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs @@ -14,4 +14,7 @@ public class HostedWebSearchTool : AITool public HostedWebSearchTool() { } + + /// + public override string Name => "web_search"; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs index 22e3bc6066a..5b3656630e7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.AI; /// Defines the context in which a JSON schema within a type graph is being generated. /// /// -/// This struct is being passed to the user-provided +/// This struct is being passed to the user-provided /// callback by the method and cannot be instantiated directly. /// public readonly struct AIJsonSchemaCreateContext @@ -51,32 +51,20 @@ internal AIJsonSchemaCreateContext(JsonSchemaExporterContext exporterContext) /// Gets the declaring type of the property or parameter being processed. /// public Type? DeclaringType => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.DeclaringType; -#else - _exporterContext.DeclaringType; -#endif /// /// Gets the corresponding to the property or field being processed. /// public ICustomAttributeProvider? PropertyAttributeProvider => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.AttributeProvider; -#else - _exporterContext.PropertyAttributeProvider; -#endif /// /// Gets the of the /// constructor parameter associated with the accompanying . /// public ICustomAttributeProvider? ParameterAttributeProvider => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.AssociatedParameter?.AttributeProvider; -#else - _exporterContext.ParameterInfo; -#endif /// /// Retrieves a custom attribute of a specified type that is applied to the specified schema node context. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs index 8c53938f481..9da0d72e5a5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs @@ -6,8 +6,6 @@ using System.Text.Json.Nodes; using System.Threading; -#pragma warning disable S1067 // Expressions should not be too complex - namespace Microsoft.Extensions.AI; /// @@ -42,29 +40,8 @@ public sealed record class AIJsonSchemaCreateOptions /// public AIJsonSchemaTransformOptions? TransformOptions { get; init; } - /// - /// Gets a value indicating whether to include the type keyword in created schemas for .NET enums. - /// - [Obsolete("This property has been deprecated.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public bool IncludeTypeInEnumSchemas { get; init; } = true; - - /// - /// Gets a value indicating whether to generate schemas with the additionalProperties set to false for .NET objects. - /// - [Obsolete("This property has been deprecated. Use the equivalent property in TransformOptions instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public bool DisallowAdditionalProperties { get; init; } - /// /// Gets a value indicating whether to include the $schema keyword in created schemas. /// public bool IncludeSchemaKeyword { get; init; } - - /// - /// Gets a value indicating whether to mark all properties as required in the schema. - /// - [Obsolete("This property has been deprecated. Use the equivalent property in TransformOptions instead.")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public bool RequireAllProperties { get; init; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs index a1aaeff26ac..438c05ce39b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; +using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Shared.Diagnostics; @@ -18,15 +18,15 @@ namespace Microsoft.Extensions.AI; /// implementations that enforce vendor-specific restrictions on what constitutes a valid JSON schema for a given function or response format. /// /// -/// It is recommended implementations with schema transformation requirements should create a single static instance of this cache. +/// It is recommended implementations with schema transformation requirements create a single static instance of this cache. /// /// public sealed class AIJsonSchemaTransformCache { - private readonly ConditionalWeakTable _functionSchemaCache = new(); + private readonly ConditionalWeakTable _functionSchemaCache = new(); private readonly ConditionalWeakTable _responseFormatCache = new(); - private readonly ConditionalWeakTable.CreateValueCallback _functionSchemaCreateValueCallback; + private readonly ConditionalWeakTable.CreateValueCallback _functionSchemaCreateValueCallback; private readonly ConditionalWeakTable.CreateValueCallback _responseFormatCreateValueCallback; /// @@ -55,9 +55,18 @@ public AIJsonSchemaTransformCache(AIJsonSchemaTransformOptions transformOptions) /// /// Gets or creates a transformed JSON schema for the specified instance. /// - /// The function whose JSON schema we want to transform. + /// The function whose JSON schema is to be transformed. /// The transformed JSON schema corresponding to . - public JsonElement GetOrCreateTransformedSchema(AIFunction function) + [EditorBrowsable(EditorBrowsableState.Never)] // maintained for binary compat; functionality for AIFunction is satisfied by AIFunctionDeclaration overload + public JsonElement GetOrCreateTransformedSchema(AIFunction function) => + GetOrCreateTransformedSchema((AIFunctionDeclaration)function); + + /// + /// Gets or creates a transformed JSON schema for the specified instance. + /// + /// The function whose JSON schema is to be transformed. + /// The transformed JSON schema corresponding to . + public JsonElement GetOrCreateTransformedSchema(AIFunctionDeclaration function) { _ = Throw.IfNull(function); return (JsonElement)_functionSchemaCache.GetValue(function, _functionSchemaCreateValueCallback); @@ -66,7 +75,7 @@ public JsonElement GetOrCreateTransformedSchema(AIFunction function) /// /// Gets or creates a transformed JSON schema for the specified instance. /// - /// The response format whose JSON schema we want to transform. + /// The response format whose JSON schema is to be transformed. /// The transformed JSON schema corresponding to . public JsonElement? GetOrCreateTransformedSchema(ChatResponseFormatJson responseFormat) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs index 46e7476afcf..101cfa03168 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S1067 // Expressions should not be too complex - using System; using System.Text.Json.Nodes; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 33531661813..d01294836bc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -48,6 +48,18 @@ private static JsonSerializerOptions CreateDefaultOptions() Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + // Temporary workaround: these types are [Experimental] and can't be added as [JsonDerivedType] on AIContent yet, + // or else consuming assemblies that used source generation with AIContent would implicitly reference them. + // Once they're no longer [Experimental] and added as [JsonDerivedType] on AIContent, these lines should be removed. + AddAIContentType(options, typeof(FunctionApprovalRequestContent), typeDiscriminatorId: "functionApprovalRequest", checkBuiltIn: false); + AddAIContentType(options, typeof(FunctionApprovalResponseContent), typeDiscriminatorId: "functionApprovalResponse", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolCallContent), typeDiscriminatorId: "mcpServerToolCall", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolResultContent), typeDiscriminatorId: "mcpServerToolResult", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolApprovalRequestContent), typeDiscriminatorId: "mcpServerToolApprovalRequest", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolApprovalResponseContent), typeDiscriminatorId: "mcpServerToolApprovalResponse", checkBuiltIn: false); + AddAIContentType(options, typeof(CodeInterpreterToolCallContent), typeDiscriminatorId: "codeInterpreterToolCall", checkBuiltIn: false); + AddAIContentType(options, typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false); + if (JsonSerializer.IsReflectionEnabledByDefault) { // If reflection-based serialization is enabled by default, use it as a fallback for all other types. @@ -65,37 +77,23 @@ private static JsonSerializerOptions CreateDefaultOptions() UseStringEnumConverter = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] - [JsonSerializable(typeof(SpeechToTextOptions))] - [JsonSerializable(typeof(SpeechToTextClientMetadata))] - [JsonSerializable(typeof(SpeechToTextResponse))] - [JsonSerializable(typeof(SpeechToTextResponseUpdate))] - [JsonSerializable(typeof(IReadOnlyList))] - [JsonSerializable(typeof(IList))] - [JsonSerializable(typeof(IEnumerable))] - [JsonSerializable(typeof(ChatMessage[]))] - [JsonSerializable(typeof(ChatOptions))] - [JsonSerializable(typeof(EmbeddingGenerationOptions))] - [JsonSerializable(typeof(ChatClientMetadata))] - [JsonSerializable(typeof(EmbeddingGeneratorMetadata))] - [JsonSerializable(typeof(ChatResponse))] - [JsonSerializable(typeof(ChatResponseUpdate))] - [JsonSerializable(typeof(IReadOnlyList))] - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] + + // JSON [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JsonValue))] [JsonSerializable(typeof(JsonArray))] - [JsonSerializable(typeof(IEnumerable))] - [JsonSerializable(typeof(char))] + + // Primitives [JsonSerializable(typeof(string))] - [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(char))] [JsonSerializable(typeof(short))] - [JsonSerializable(typeof(long))] - [JsonSerializable(typeof(uint))] [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(long))] [JsonSerializable(typeof(ulong))] [JsonSerializable(typeof(float))] [JsonSerializable(typeof(double))] @@ -104,6 +102,42 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(TimeSpan))] [JsonSerializable(typeof(DateTime))] [JsonSerializable(typeof(DateTimeOffset))] + + // AIFunction + [JsonSerializable(typeof(AIFunctionArguments))] + + // IChatClient + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(ChatMessage[]))] + [JsonSerializable(typeof(ChatOptions))] + [JsonSerializable(typeof(ChatClientMetadata))] + [JsonSerializable(typeof(ChatResponse))] + [JsonSerializable(typeof(ChatResponseUpdate))] + [JsonSerializable(typeof(IReadOnlyList))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(AIContent))] + [JsonSerializable(typeof(IEnumerable))] + + // Temporary workaround: These should be implicitly added in once they're no longer [Experimental] + // and are included via [JsonDerivedType] on AIContent. + [JsonSerializable(typeof(UserInputRequestContent))] + [JsonSerializable(typeof(UserInputResponseContent))] + [JsonSerializable(typeof(FunctionApprovalRequestContent))] + [JsonSerializable(typeof(FunctionApprovalResponseContent))] + [JsonSerializable(typeof(McpServerToolCallContent))] + [JsonSerializable(typeof(McpServerToolResultContent))] + [JsonSerializable(typeof(McpServerToolApprovalRequestContent))] + [JsonSerializable(typeof(McpServerToolApprovalResponseContent))] + [JsonSerializable(typeof(CodeInterpreterToolCallContent))] + [JsonSerializable(typeof(CodeInterpreterToolResultContent))] + [JsonSerializable(typeof(ResponseContinuationToken))] + + // IEmbeddingGenerator + [JsonSerializable(typeof(EmbeddingGenerationOptions))] + [JsonSerializable(typeof(EmbeddingGeneratorMetadata))] [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] @@ -112,8 +146,18 @@ private static JsonSerializerOptions CreateDefaultOptions() #endif [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] - [JsonSerializable(typeof(AIContent))] - [JsonSerializable(typeof(AIFunctionArguments))] + + // ISpeechToTextClient + [JsonSerializable(typeof(SpeechToTextOptions))] + [JsonSerializable(typeof(SpeechToTextClientMetadata))] + [JsonSerializable(typeof(SpeechToTextResponse))] + [JsonSerializable(typeof(SpeechToTextResponseUpdate))] + [JsonSerializable(typeof(IReadOnlyList))] + + // IImageGenerator + [JsonSerializable(typeof(ImageGenerationOptions))] + [JsonSerializable(typeof(ImageGenerationResponse))] + [EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead. private sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index a44836d8e96..667b3c4d080 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -3,6 +3,9 @@ using System; using System.ComponentModel; +#if NET || NETFRAMEWORK +using System.ComponentModel.DataAnnotations; +#endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -11,14 +14,13 @@ using System.Text.Json.Nodes; using System.Text.Json.Schema; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using Microsoft.Shared.Diagnostics; -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions -#pragma warning disable S107 // Methods should not have too many parameters #pragma warning disable S1075 // URIs should not be hardcoded +#pragma warning disable S1199 // Nested block #pragma warning disable SA1118 // Parameter should not span multiple lines -#pragma warning disable S109 // Magic numbers should not be used namespace Microsoft.Extensions.AI; @@ -38,14 +40,25 @@ public static partial class AIJsonUtilities private const string AdditionalPropertiesPropertyName = "additionalProperties"; private const string DefaultPropertyName = "default"; private const string RefPropertyName = "$ref"; +#if NET || NETFRAMEWORK + private const string FormatPropertyName = "format"; + private const string MinLengthStringPropertyName = "minLength"; + private const string MaxLengthStringPropertyName = "maxLength"; + private const string MinLengthCollectionPropertyName = "minItems"; + private const string MaxLengthCollectionPropertyName = "maxItems"; + private const string MinRangePropertyName = "minimum"; + private const string MaxRangePropertyName = "maximum"; +#endif +#if NET + private const string ContentEncodingPropertyName = "contentEncoding"; + private const string ContentMediaTypePropertyName = "contentMediaType"; + private const string MinExclusiveRangePropertyName = "exclusiveMinimum"; + private const string MaxExclusiveRangePropertyName = "exclusiveMaximum"; +#endif /// The uri used when populating the $schema keyword in created schemas. private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema"; - // List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors. - // cf. https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported - private static readonly string[] _schemaKeywordsDisallowedByAIVendors = ["minLength", "maxLength", "pattern", "format"]; - /// /// Determines a JSON schema for the provided method. /// @@ -67,7 +80,7 @@ public static JsonElement CreateFunctionJsonSchema( serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - title ??= method.Name; + title ??= method.GetCustomAttribute()?.DisplayName ?? method.Name; description ??= method.GetCustomAttribute()?.Description; JsonObject parameterSchemas = new(); @@ -95,17 +108,18 @@ public static JsonElement CreateFunctionJsonSchema( continue; } + bool hasDefaultValue = TryGetEffectiveDefaultValue(parameter, out object? defaultValue); JsonNode parameterSchema = CreateJsonSchemaCore( type: parameter.ParameterType, - parameterName: parameter.Name, + parameter: parameter, description: parameter.GetCustomAttribute(inherit: true)?.Description, - hasDefaultValue: parameter.HasDefaultValue, - defaultValue: GetDefaultValueNormalized(parameter), + hasDefaultValue: hasDefaultValue, + defaultValue: defaultValue, serializerOptions, inferenceOptions); parameterSchemas.Add(parameter.Name, parameterSchema); - if (!parameter.IsOptional) + if (!parameter.IsOptional && !hasDefaultValue) { (requiredProperties ??= []).Add((JsonNode)parameter.Name); } @@ -162,7 +176,7 @@ public static JsonElement CreateJsonSchema( { serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); + JsonNode schema = CreateJsonSchemaCore(type, parameter: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); // Finally, apply any schema transformations if specified. if (inferenceOptions.TransformOptions is { } options) @@ -185,14 +199,9 @@ internal static void ValidateSchemaDocument(JsonElement document, [CallerArgumen } } -#if !NET9_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", - Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + - "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] -#endif private static JsonNode CreateJsonSchemaCore( Type? type, - string? parameterName, + ParameterInfo? parameter, string? description, bool hasDefaultValue, object? defaultValue, @@ -227,7 +236,7 @@ private static JsonNode CreateJsonSchemaCore( (schemaObj ??= [])[DescriptionPropertyName] = description; } - return schemaObj ?? (JsonNode)true; + return schemaObj ?? new JsonObject(); } if (type == typeof(void)) @@ -256,14 +265,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js // The resulting schema might be a $ref using a pointer to a different location in the document. // As JSON pointer doesn't support relative paths, parameter schemas need to fix up such paths // to accommodate the fact that they're being nested inside of a higher-level schema. - if (parameterName is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) + if (parameter?.Name is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) { // Fix up any $ref URIs to match the path from the root document. string refUri = paramName!.GetValue(); Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}"); refUri = refUri == "#" - ? $"#/{PropertiesPropertyName}/{parameterName}" - : $"#/{PropertiesPropertyName}/{parameterName}/{refUri.AsMemory("#/".Length)}"; + ? $"#/{PropertiesPropertyName}/{parameter.Name}" + : $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}"; objSchema[RefPropertyName] = (JsonNode)refUri; } @@ -274,24 +283,55 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js objSchema.InsertAtStart(TypePropertyName, "string"); } - // Filter potentially disallowed keywords. - foreach (string keyword in _schemaKeywordsDisallowedByAIVendors) + // Include a trivial items keyword if missing + if (ctx.TypeInfo.Kind is JsonTypeInfoKind.Enumerable && !objSchema.ContainsKey(ItemsPropertyName)) { - _ = objSchema.Remove(keyword); + objSchema.Add(ItemsPropertyName, new JsonObject()); } // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand // schemas with "type": [...], and only understand "type" being a single value. // In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error. - if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType)) + if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType, out bool isNullable)) { // We don't want to emit any array for "type". In this case we know it contains "integer" or "number", // so reduce the type to that alone, assuming it's the most specific type. // This makes schemas for Int32 (etc) work with Ollama. JsonObject obj = ConvertSchemaToObject(ref schema); - obj[TypePropertyName] = numericType; + if (isNullable) + { + // If the type is nullable, we still need use a type array + obj[TypePropertyName] = new JsonArray { (JsonNode)numericType, (JsonNode)"null" }; + } + else + { + obj[TypePropertyName] = (JsonNode)numericType; + } + _ = obj.Remove(PatternPropertyName); } + + if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type) is Type nullableElement) + { + // Account for bug https://github.com/dotnet/runtime/issues/117493 + // To be removed once System.Text.Json v10 becomes the lowest supported version. + // null not inserted in the type keyword for root-level Nullable types. + if (objSchema.TryGetPropertyValue(TypePropertyName, out JsonNode? typeKeyWord) && + typeKeyWord?.GetValueKind() is JsonValueKind.String) + { + string typeValue = typeKeyWord.GetValue()!; + if (typeValue is not "null") + { + objSchema[TypePropertyName] = new JsonArray { (JsonNode)typeValue, (JsonNode)"null" }; + } + } + + // Include the type keyword in nullable enum types + if (nullableElement.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + { + objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); + } + } } if (ctx.Path.IsEmpty && hasDefaultValue) @@ -312,6 +352,8 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } + ApplyDataAnnotations(ref schema, ctx); + // Finally, apply any user-defined transformations if specified. if (inferenceOptions.TransformSchemaNode is { } transformer) { @@ -339,14 +381,320 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) return obj; } } + + void ApplyDataAnnotations(ref JsonNode schema, AIJsonSchemaCreateContext ctx) + { + if (ResolveAttribute() is { } displayNameAttribute) + { + ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; + } + +#if NET || NETFRAMEWORK + if (ResolveAttribute() is { } emailAttribute) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email"; + } + + if (ResolveAttribute() is { } urlAttribute) + { + ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "uri"; + } + + if (ResolveAttribute() is { } regexAttribute) + { + ConvertSchemaToObject(ref schema)[PatternPropertyName] ??= regexAttribute.Pattern; + } + + if (ResolveAttribute() is { } stringLengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + if (stringLengthAttribute.MinimumLength > 0) + { + obj[MinLengthStringPropertyName] ??= stringLengthAttribute.MinimumLength; + } + + obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength; + } + + if (ResolveAttribute() is { } minLengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + if (TryGetSchemaType(obj, out string? schemaType, out _) && schemaType is "string") + { + obj[MinLengthStringPropertyName] ??= minLengthAttribute.Length; + } + else + { + obj[MinLengthCollectionPropertyName] ??= minLengthAttribute.Length; + } + } + + if (ResolveAttribute() is { } maxLengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + if (TryGetSchemaType(obj, out string? schemaType, out _) && schemaType is "string") + { + obj[MaxLengthStringPropertyName] ??= maxLengthAttribute.Length; + } + else + { + obj[MaxLengthCollectionPropertyName] ??= maxLengthAttribute.Length; + } + } + + if (ResolveAttribute() is { } rangeAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + JsonNode? minNode = null; + JsonNode? maxNode = null; + switch (rangeAttribute.Minimum) + { + case int minInt32 when rangeAttribute.Maximum is int maxInt32: + maxNode = maxInt32; + if ( +#if NET + !rangeAttribute.MinimumIsExclusive || +#endif + minInt32 > 0) + { + minNode = minInt32; + } + + break; + + case double minDouble when rangeAttribute.Maximum is double maxDouble: + maxNode = maxDouble; + if ( +#if NET + !rangeAttribute.MinimumIsExclusive || +#endif + minDouble > 0) + { + minNode = minDouble; + } + + break; + + case string minString when rangeAttribute.Maximum is string maxString: + maxNode = maxString; + minNode = minString; + break; + } + + if (minNode is not null) + { +#if NET + if (rangeAttribute.MinimumIsExclusive) + { + obj[MinExclusiveRangePropertyName] ??= minNode; + } + else +#endif + { + obj[MinRangePropertyName] ??= minNode; + } + } + + if (maxNode is not null) + { +#if NET + if (rangeAttribute.MaximumIsExclusive) + { + obj[MaxExclusiveRangePropertyName] ??= maxNode; + } + else +#endif + { + obj[MaxRangePropertyName] ??= maxNode; + } + } + } +#endif + +#if NET + if (ResolveAttribute() is { } base64Attribute) + { + ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; + } + + if (ResolveAttribute() is { } lengthAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + if (TryGetSchemaType(obj, out string? schemaType, out _) && schemaType is "string") + { + if (lengthAttribute.MinimumLength > 0) + { + obj[MinLengthStringPropertyName] ??= lengthAttribute.MinimumLength; + } + + obj[MaxLengthStringPropertyName] ??= lengthAttribute.MaximumLength; + } + else + { + if (lengthAttribute.MinimumLength > 0) + { + obj[MinLengthCollectionPropertyName] ??= lengthAttribute.MinimumLength; + } + + obj[MaxLengthCollectionPropertyName] ??= lengthAttribute.MaximumLength; + } + } + + if (ResolveAttribute() is { } allowedValuesAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + if (!obj.ContainsKey(EnumPropertyName)) + { + if (CreateJsonArray(allowedValuesAttribute.Values, serializerOptions) is { Count: > 0 } enumArray) + { + obj[EnumPropertyName] = enumArray; + } + } + } + + if (ResolveAttribute() is { } deniedValuesAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + + JsonNode? notNode = obj[NotPropertyName]; + if (notNode is null or JsonObject) + { + JsonObject notObj = + notNode as JsonObject ?? + (JsonObject)(obj[NotPropertyName] = new JsonObject()); + + if (notObj[EnumPropertyName] is null) + { + if (CreateJsonArray(deniedValuesAttribute.Values, serializerOptions) is { Count: > 0 } enumArray) + { + notObj[EnumPropertyName] = enumArray; + } + } + } + } + + static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions serializerOptions) + { + JsonArray enumArray = new(); + foreach (object? allowedValue in values) + { + if (allowedValue is not null && JsonSerializer.SerializeToNode(allowedValue, serializerOptions.GetTypeInfo(allowedValue.GetType())) is { } valueNode) + { + enumArray.Add(valueNode); + } + } + + return enumArray; + } + + if (ResolveAttribute() is { } dataTypeAttribute) + { + JsonObject obj = ConvertSchemaToObject(ref schema); + switch (dataTypeAttribute.DataType) + { + case DataType.DateTime: + obj[FormatPropertyName] ??= "date-time"; + break; + + case DataType.Date: + obj[FormatPropertyName] ??= "date"; + break; + + case DataType.Time: + obj[FormatPropertyName] ??= "time"; + break; + + case DataType.EmailAddress: + obj[FormatPropertyName] ??= "email"; + break; + + case DataType.Url: + obj[FormatPropertyName] ??= "uri"; + break; + + case DataType.ImageUrl: + obj[FormatPropertyName] ??= "uri"; + obj[ContentMediaTypePropertyName] ??= "image/*"; + break; + } + } +#endif +#if NET || NETFRAMEWORK + static bool TryGetSchemaType(JsonObject schema, [NotNullWhen(true)] out string? schemaType, out bool isNullable) + { + schemaType = null; + isNullable = false; + + if (!schema.TryGetPropertyValue(TypePropertyName, out JsonNode? typeNode)) + { + return false; + } + + switch (typeNode?.GetValueKind()) + { + case JsonValueKind.String: + schemaType = typeNode.GetValue(); + return true; + + case JsonValueKind.Array: + string? foundSchemaType = null; + foreach (JsonNode? entry in (JsonArray)typeNode) + { + if (entry?.GetValueKind() is not JsonValueKind.String) + { + return false; + } + + string entryValue = entry.GetValue(); + if (entryValue is "null") + { + isNullable = true; + continue; + } + + if (foundSchemaType is null) + { + foundSchemaType = entryValue; + } + else if (foundSchemaType != entryValue) + { + return false; + } + } + + schemaType = foundSchemaType; + return schemaType is not null; + + default: + return false; + } + } +#endif + + TAttribute? ResolveAttribute() + where TAttribute : Attribute + { + // If this is the root schema, check for any parameter attributes first. + if (ctx.Path.IsEmpty && parameter?.GetCustomAttribute(inherit: true) is TAttribute attr) + { + return attr; + } + + return ctx.GetCustomAttribute(inherit: true); + } + } } } - private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType) + private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType, out bool isNullable) { numericType = null; + isNullable = false; - if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray { Count: 2 } typeArray) + if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { bool allowString = false; @@ -358,11 +706,23 @@ private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateCont switch (type) { case "integer" or "number": + if (numericType is not null) + { + // Conflicting numeric type + return false; + } + numericType = type; break; case "string": allowString = true; break; + case "null": + isNullable = true; + break; + default: + // keyword is not valid in the context of numeric types. + return false; } } } @@ -396,6 +756,32 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) return JsonElement.ParseValue(ref reader); } + /// + /// Tries to get the effective default value for a parameter, checking both C# default value syntax and DefaultValueAttribute. + /// + /// The parameter to check. + /// The default value if one exists. + /// if the parameter has a default value; otherwise, . + internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, out object? defaultValue) + { + // First check for DefaultValueAttribute + if (parameterInfo.GetCustomAttribute(inherit: true) is { } attr) + { + defaultValue = attr.Value; + return true; + } + + // Fall back to the parameter's declared default value + if (parameterInfo.HasDefaultValue) + { + defaultValue = GetDefaultValueNormalized(parameterInfo); + return true; + } + + defaultValue = null; + return false; + } + [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", Justification = "Called conditionally on structs whose default ctor never gets trimmed.")] private static object? GetDefaultValueNormalized(ParameterInfo parameterInfo) @@ -406,7 +792,7 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) if (defaultValue is null || (defaultValue == DBNull.Value && parameterType != typeof(DBNull))) { - return parameterType.IsValueType + return parameterType.IsValueType && Nullable.GetUnderlyingType(parameterType) is null #if NET ? RuntimeHelpers.GetUninitializedObject(parameterType) #else diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs index 51459193923..b69d0fb2aab 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs @@ -6,8 +6,10 @@ using System.Diagnostics; #endif using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; #if NET using System.Threading; @@ -15,9 +17,6 @@ #endif using Microsoft.Shared.Diagnostics; -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - namespace Microsoft.Extensions.AI; public static partial class AIJsonUtilities @@ -37,7 +36,7 @@ public static void AddAIContentType(this JsonSerializerOptions options _ = Throw.IfNull(options); _ = Throw.IfNull(typeDiscriminatorId); - AddAIContentTypeCore(options, typeof(TContent), typeDiscriminatorId); + AddAIContentType(options, typeof(TContent), typeDiscriminatorId, checkBuiltIn: true); } /// @@ -57,10 +56,10 @@ public static void AddAIContentType(this JsonSerializerOptions options, Type con if (!typeof(AIContent).IsAssignableFrom(contentType)) { - Throw.ArgumentException(nameof(contentType), "The content type must derive from AIContent."); + Throw.ArgumentException(nameof(contentType), $"The content type must derive from {nameof(AIContent)}."); } - AddAIContentTypeCore(options, contentType, typeDiscriminatorId); + AddAIContentType(options, contentType, typeDiscriminatorId, checkBuiltIn: true); } /// Serializes the supplied values and computes a string hash of the resulting JSON. @@ -112,7 +111,9 @@ public static string HashDataToString(ReadOnlySpan values, JsonSerializ { foreach (object? value in values) { - JsonSerializer.Serialize(stream, value, jti); + JsonNode? jsonNode = JsonSerializer.SerializeToNode(value, jti); + NormalizeJsonNode(jsonNode); + JsonSerializer.Serialize(stream, jsonNode, JsonContextNoIndentation.Default.JsonNode!); } stream.GetHashAndReset(hashData); @@ -130,7 +131,9 @@ public static string HashDataToString(ReadOnlySpan values, JsonSerializ MemoryStream stream = new(); foreach (object? value in values) { - JsonSerializer.Serialize(stream, value, jti); + JsonNode? jsonNode = JsonSerializer.SerializeToNode(value, jti); + NormalizeJsonNode(jsonNode); + JsonSerializer.Serialize(stream, jsonNode, JsonContextNoIndentation.Default.JsonNode!); } using var hashAlgorithm = SHA384.Create(); @@ -156,13 +159,38 @@ static string ConvertToHexString(ReadOnlySpan hashData) return new string(chars); } #endif + static void NormalizeJsonNode(JsonNode? node) + { + switch (node) + { + case JsonArray array: + foreach (JsonNode? item in array) + { + NormalizeJsonNode(item); + } + + break; + + case JsonObject obj: + var entries = obj.OrderBy(e => e.Key, StringComparer.Ordinal).ToArray(); + obj.Clear(); + + foreach (var entry in entries) + { + obj.Add(entry.Key, entry.Value); + NormalizeJsonNode(entry.Value); + } + + break; + } + } } - private static void AddAIContentTypeCore(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId) + private static void AddAIContentType(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId, bool checkBuiltIn) { - if (contentType.Assembly == typeof(AIContent).Assembly) + if (checkBuiltIn && (contentType.Assembly == typeof(AIContent).Assembly)) { - Throw.ArgumentException(nameof(contentType), "Cannot register built-in AI content types."); + Throw.ArgumentException(nameof(contentType), $"Cannot register built-in {nameof(AIContent)} types."); } IJsonTypeInfoResolver resolver = options.TypeInfoResolver ?? DefaultOptions.TypeInfoResolver!; diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index 6c0acb8ee23..45081c0ab6c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -15,7 +15,6 @@ using Azure.AI.Inference; using Microsoft.Shared.Diagnostics; -#pragma warning disable S1135 // Track uses of "TODO" tags #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -65,7 +64,7 @@ public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, s var providerUrl = typeof(ChatCompletionsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(chatCompletionsClient) as Uri; - _metadata = new ChatClientMetadata("az.ai.inference", providerUrl, defaultModelId); + _metadata = new ChatClientMetadata("azure.ai.inference", providerUrl, defaultModelId); } /// @@ -95,6 +94,7 @@ public async Task GetResponseAsync( // Create the return message. ChatMessage message = new(ToChatRole(response.Role), response.Content) { + CreatedAt = response.Created, MessageId = response.Id, // There is no per-message ID, but there's only one message per response, so use the response ID RawRepresentation = response, }; @@ -182,13 +182,6 @@ public async IAsyncEnumerable GetStreamingResponseAsync( // Transfer over tool call updates. if (chatCompletionUpdate.ToolCallUpdate is { } toolCallUpdate) { - // TODO https://github.com/Azure/azure-sdk-for-net/issues/46830: Azure.AI.Inference - // has removed the Index property from ToolCallUpdate. It's now impossible via the - // exposed APIs to correctly handle multiple parallel tool calls, as the CallId is - // often null for anything other than the first update for a given call, and Index - // isn't available to correlate which updates are for which call. This is a temporary - // workaround to at least make a single tool call work and also make work multiple - // tool calls when their updates aren't interleaved. if (toolCallUpdate.Id is not null) { lastCallId = toolCallUpdate.Id; @@ -283,13 +276,13 @@ private static ChatRole ToChatRole(global::Azure.AI.Inference.ChatRole role) => new(s); private ChatCompletionsOptions CreateAzureAIOptions(IEnumerable chatContents, ChatOptions? options) => - new(ToAzureAIInferenceChatMessages(chatContents)) + new(ToAzureAIInferenceChatMessages(chatContents, options)) { Model = options?.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options.") }; - /// Converts an extensions options instance to an AzureAI options instance. + /// Converts an extensions options instance to an Azure.AI.Inference options instance. private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatContents, ChatOptions? options) { if (options is null) @@ -299,7 +292,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon if (options.RawRepresentationFactory?.Invoke(this) is ChatCompletionsOptions result) { - result.Messages = ToAzureAIInferenceChatMessages(chatContents).ToList(); + result.Messages = ToAzureAIInferenceChatMessages(chatContents, options).ToList(); result.Model ??= options.ModelId ?? _metadata.DefaultModelId ?? throw new InvalidOperationException("No model id was provided when either constructing the client or in the chat options."); } @@ -342,7 +335,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon { foreach (AITool tool in tools) { - if (tool is AIFunction af) + if (tool is AIFunctionDeclaration af) { result.Tools.Add(ToAzureAIChatTool(af)); } @@ -409,7 +402,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon private static readonly BinaryData _falseString = BinaryData.FromString("false"); /// Converts an Extensions function to an AzureAI chat tool. - private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunctionDeclaration aiFunction) { // Map to an intermediate model so that redundant properties are skipped. var tool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction), JsonContext.Default.AzureAIChatToolJson)!; @@ -422,11 +415,16 @@ private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunc } /// Converts an Extensions chat message enumerable to an AzureAI chat message enumerable. - private static IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs) + private static IEnumerable ToAzureAIInferenceChatMessages(IEnumerable inputs, ChatOptions? options) { // Maps all of the M.E.AI types to the corresponding AzureAI types. // Unrecognized or non-processable content is ignored. + if (options?.Instructions is { } instructions && !string.IsNullOrWhiteSpace(instructions)) + { + yield return new ChatRequestSystemMessage(instructions); + } + foreach (ChatMessage input in inputs) { if (input.Role == ChatRole.System) @@ -479,8 +477,6 @@ private static IEnumerable ToAzureAIInferenceChatMessages(IE } else if (input.Role == ChatRole.Assistant) { - // TODO: ChatRequestAssistantMessage only enables text content currently. - // Update it with other content types when it supports that. ChatRequestAssistantMessage message = new(string.Concat(input.Contents.Where(c => c is TextContent))); foreach (var content in input.Contents) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index d96112c73c6..04383a85b86 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -15,9 +15,7 @@ using Azure.AI.Inference; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S109 // Magic numbers should not be used namespace Microsoft.Extensions.AI; @@ -69,7 +67,7 @@ public AzureAIInferenceEmbeddingGenerator( var providerUrl = typeof(EmbeddingsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(embeddingsClient) as Uri; - _metadata = new EmbeddingGeneratorMetadata("az.ai.inference", providerUrl, defaultModelId, defaultModelDimensions); + _metadata = new EmbeddingGeneratorMetadata("azure.ai.inference", providerUrl, defaultModelId, defaultModelDimensions); } /// @@ -91,7 +89,7 @@ public async Task>> GenerateAsync( { _ = Throw.IfNull(values); - var azureAIOptions = ToAzureAIOptions(values, options, EmbeddingEncodingFormat.Base64); + var azureAIOptions = ToAzureAIOptions(values, options); var embeddings = (await _embeddingsClient.EmbedAsync(azureAIOptions, cancellationToken).ConfigureAwait(false)).Value; @@ -164,14 +162,23 @@ static void ThrowInvalidData() => } /// Converts an extensions options instance to an Azure.AI.Inference options instance. - private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options, EmbeddingEncodingFormat format) + private EmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options) { - EmbeddingsOptions result = new(inputs) + if (options?.RawRepresentationFactory?.Invoke(this) is not EmbeddingsOptions result) { - Dimensions = options?.Dimensions ?? _dimensions, - Model = options?.ModelId ?? _metadata.DefaultModelId, - EncodingFormat = format, - }; + result = new EmbeddingsOptions(inputs); + } + else + { + foreach (var input in inputs) + { + result.Input.Add(input); + } + } + + result.Dimensions ??= options?.Dimensions ?? _dimensions; + result.Model ??= options?.ModelId ?? _metadata.DefaultModelId; + result.EncodingFormat = EmbeddingEncodingFormat.Base64; if (options?.AdditionalProperties is { } props) { diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs index ec6ee9dd6e6..b04a7c73a39 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs @@ -11,9 +11,7 @@ using Azure.AI.Inference; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S109 // Magic numbers should not be used namespace Microsoft.Extensions.AI; @@ -65,7 +63,7 @@ public AzureAIInferenceImageEmbeddingGenerator( var providerUrl = typeof(ImageEmbeddingsClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(imageEmbeddingsClient) as Uri; - _metadata = new EmbeddingGeneratorMetadata("az.ai.inference", providerUrl, defaultModelId, defaultModelDimensions); + _metadata = new EmbeddingGeneratorMetadata("azure.ai.inference", providerUrl, defaultModelId, defaultModelDimensions); } /// @@ -87,7 +85,7 @@ public async Task>> GenerateAsync( { _ = Throw.IfNull(values); - var azureAIOptions = ToAzureAIOptions(values, options, EmbeddingEncodingFormat.Base64); + var azureAIOptions = ToAzureAIOptions(values, options); var embeddings = (await _imageEmbeddingsClient.EmbedAsync(azureAIOptions, cancellationToken).ConfigureAwait(false)).Value; @@ -117,14 +115,24 @@ void IDisposable.Dispose() } /// Converts an extensions options instance to an Azure.AI.Inference options instance. - private ImageEmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options, EmbeddingEncodingFormat format) + private ImageEmbeddingsOptions ToAzureAIOptions(IEnumerable inputs, EmbeddingGenerationOptions? options) { - ImageEmbeddingsOptions result = new(inputs.Select(dc => new ImageEmbeddingInput(dc.Uri))) + IEnumerable imageEmbeddingInputs = inputs.Select(dc => new ImageEmbeddingInput(dc.Uri)); + if (options?.RawRepresentationFactory?.Invoke(this) is not ImageEmbeddingsOptions result) { - Dimensions = options?.Dimensions ?? _dimensions, - Model = options?.ModelId ?? _metadata.DefaultModelId, - EncodingFormat = format, - }; + result = new ImageEmbeddingsOptions(imageEmbeddingInputs); + } + else + { + foreach (var input in imageEmbeddingInputs) + { + result.Input.Add(input); + } + } + + result.Dimensions ??= options?.Dimensions ?? _dimensions; + result.Model ??= options?.ModelId ?? _metadata.DefaultModelId; + result.EncodingFormat = EmbeddingEncodingFormat.Base64; if (options?.AdditionalProperties is { } props) { diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md index aeb023efae5..60423003454 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -1,24 +1,60 @@ # Release History +## 9.10.1-preview.1.25521.4 + +- No changes. + +## 9.10.0-preview.1.25513.3 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.9.1-preview.1.25474.6 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.9.0-preview.1.25458.4 + +- Updated tool mapping to recognize any `AIFunctionDeclaration`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. +- Updated `AsIChatClient` for `OpenAIResponseClient` to support reasoning content with `GetStreamingResponseAsync`. + +## 9.8.0-preview.1.25412.6 + +- Updated to depend on Azure.AI.Inference 1.0.0-beta.5. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.0-preview.1.25356.2 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.6.0-preview.1.25310.2 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.5.0-preview.1.25265.7 + +- Added `AsIEmbeddingGenerator` for Azure.AI.Inference `ImageEmbeddingsClient`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + ## 9.4.4-preview.1.25259.16 - Added an `AsIEmbeddingGenerator` extension method for `ImageEmbeddingsClient`. -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.4.3-preview.1.25230.7 -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.4.0-preview.1.25207.5 - Updated to Azure.AI.Inference 1.0.0-beta.4. - Renamed `AsChatClient`/`AsEmbeddingGenerator` extension methods to `AsIChatClient`/`AsIEmbeddingGenerator`. - Removed the public `AzureAIInferenceChatClient`/`AzureAIInferenceEmbeddingGenerator` types. These are only created now via the extension methods. -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.3.0-preview.1.25161.3 -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.3.0-preview.1.25114.11 diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj index 5384a7992d7..e40a2cc34b1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj @@ -1,9 +1,10 @@ - + Microsoft.Extensions.AI Implementation of generative AI abstractions for Azure.AI.Inference. AI + true @@ -15,7 +16,7 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA2227;SA1316;S1067;S1121;S3358 + $(NoWarn);CA1063 true true diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md index 2c2ad628c53..18a8b2c8ea2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/README.md @@ -281,6 +281,10 @@ app.MapPost("/chat", async (IChatClient client, string message) => app.Run(); ``` +## Documentation + +Refer to the [Microsoft.Extensions.AI libraries documentation](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) for more information and API usage examples. + ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs index b0d975edb43..a3224e411cd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs @@ -2,46 +2,66 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Azure.Identity; using Azure.Storage.Files.DataLake; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; using Microsoft.Extensions.AI.Evaluation.Console.Utilities; using Microsoft.Extensions.AI.Evaluation.Reporting; using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Evaluation.Console.Telemetry.TelemetryConstants; namespace Microsoft.Extensions.AI.Evaluation.Console.Commands; -internal sealed class CleanCacheCommand(ILogger logger) +internal sealed class CleanCacheCommand(ILogger logger, TelemetryHelper telemetryHelper) { - internal async Task InvokeAsync(DirectoryInfo? storageRootDir, Uri? endpointUri, CancellationToken cancellationToken = default) + internal async Task InvokeAsync( + DirectoryInfo? storageRootDir, + Uri? endpointUri, + CancellationToken cancellationToken = default) { - IEvaluationResponseCacheProvider cacheProvider; - - if (storageRootDir is not null) - { - string storageRootPath = storageRootDir.FullName; - logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); - logger.LogInformation("Deleting expired cache entries..."); - - cacheProvider = new DiskBasedResponseCacheProvider(storageRootPath); - } - else if (endpointUri is not null) - { - logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); - - var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); - cacheProvider = new AzureStorageResponseCacheProvider(fsClient); - } - else - { - throw new InvalidOperationException("Either --path or --endpoint must be specified"); - } + var telemetryProperties = new Dictionary(); await logger.ExecuteWithCatchAsync( - () => cacheProvider.DeleteExpiredCacheEntriesAsync(cancellationToken)).ConfigureAwait(false); + operation: () => + telemetryHelper.ReportOperationAsync( + operationName: EventNames.CleanCacheCommand, + operation: () => + { + IEvaluationResponseCacheProvider cacheProvider; + + if (storageRootDir is not null) + { + string storageRootPath = storageRootDir.FullName; + logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); + logger.LogInformation("Deleting expired cache entries..."); + + cacheProvider = new DiskBasedResponseCacheProvider(storageRootPath); + + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeDisk; + } + else if (endpointUri is not null) + { + logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); + + var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); + cacheProvider = new AzureStorageResponseCacheProvider(fsClient); + + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeAzure; + } + else + { + throw new InvalidOperationException("Either --path or --endpoint must be specified"); + } + + return cacheProvider.DeleteExpiredCacheEntriesAsync(cancellationToken); + }, + properties: telemetryProperties, + logger: logger)).ConfigureAwait(false); return 0; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs index 8d6617d8302..ec5a8805424 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs @@ -8,14 +8,16 @@ using System.Threading.Tasks; using Azure.Identity; using Azure.Storage.Files.DataLake; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; using Microsoft.Extensions.AI.Evaluation.Console.Utilities; using Microsoft.Extensions.AI.Evaluation.Reporting; using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Evaluation.Console.Telemetry.TelemetryConstants; namespace Microsoft.Extensions.AI.Evaluation.Console.Commands; -internal sealed class CleanResultsCommand(ILogger logger) +internal sealed class CleanResultsCommand(ILogger logger, TelemetryHelper telemetryHelper) { internal async Task InvokeAsync( DirectoryInfo? storageRootDir, @@ -23,61 +25,81 @@ internal async Task InvokeAsync( int lastN, CancellationToken cancellationToken = default) { - IEvaluationResultStore resultStore; - - if (storageRootDir is not null) - { - string storageRootPath = storageRootDir.FullName; - logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); + var telemetryProperties = + new Dictionary + { + [PropertyNames.LastN] = lastN.ToTelemetryPropertyValue() + }; - resultStore = new DiskBasedResultStore(storageRootPath); - } - else if (endpointUri is not null) - { - logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); + await logger.ExecuteWithCatchAsync( + operation: () => + telemetryHelper.ReportOperationAsync( + operationName: EventNames.CleanResultsCommand, + operation: async ValueTask () => + { + IEvaluationResultStore resultStore; - var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); - resultStore = new AzureStorageResultStore(fsClient); - } - else - { - throw new InvalidOperationException("Either --path or --endpoint must be specified"); - } + if (storageRootDir is not null) + { + string storageRootPath = storageRootDir.FullName; + logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); - await logger.ExecuteWithCatchAsync( - async ValueTask () => - { - if (lastN is 0) - { - logger.LogInformation("Deleting all results..."); + resultStore = new DiskBasedResultStore(storageRootPath); - await resultStore.DeleteResultsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - } - else - { - logger.LogInformation("Deleting all results except the {lastN} most recent ones...", lastN); + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeDisk; + } + else if (endpointUri is not null) + { + logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); - HashSet toPreserve = []; + var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); + resultStore = new AzureStorageResultStore(fsClient); - await foreach (string executionName in - resultStore.GetLatestExecutionNamesAsync(lastN, cancellationToken).ConfigureAwait(false)) - { - _ = toPreserve.Add(executionName); - } + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeAzure; + } + else + { + throw new InvalidOperationException("Either --path or --endpoint must be specified"); + } - await foreach (string executionName in - resultStore.GetLatestExecutionNamesAsync( - cancellationToken: cancellationToken).ConfigureAwait(false)) - { - if (!toPreserve.Contains(executionName)) + if (lastN is 0) { + logger.LogInformation("Deleting all results..."); + await resultStore.DeleteResultsAsync( - executionName, cancellationToken: cancellationToken).ConfigureAwait(false); } - } - } - }).ConfigureAwait(false); + else + { + logger.LogInformation( + "Deleting all results except the {lastN} most recent ones...", + lastN); + + HashSet toPreserve = []; + + await foreach (string executionName in + resultStore.GetLatestExecutionNamesAsync( + lastN, + cancellationToken).ConfigureAwait(false)) + { + _ = toPreserve.Add(executionName); + } + + await foreach (string executionName in + resultStore.GetLatestExecutionNamesAsync( + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (!toPreserve.Contains(executionName)) + { + await resultStore.DeleteResultsAsync( + executionName, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + } + }, + properties: telemetryProperties, + logger: logger)).ConfigureAwait(false); return 0; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs index 2611695e542..cf2242eaac6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs @@ -5,19 +5,24 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Azure.Identity; using Azure.Storage.Files.DataLake; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; +using Microsoft.Extensions.AI.Evaluation.Console.Utilities; using Microsoft.Extensions.AI.Evaluation.Reporting; using Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Html; using Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Json; using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Evaluation.Console.Telemetry.TelemetryConstants; namespace Microsoft.Extensions.AI.Evaluation.Console.Commands; -internal sealed partial class ReportCommand(ILogger logger) +internal sealed partial class ReportCommand(ILogger logger, TelemetryHelper telemetryHelper) { internal async Task InvokeAsync( DirectoryInfo? storageRootDir, @@ -28,89 +33,367 @@ internal async Task InvokeAsync( Format format, CancellationToken cancellationToken = default) { - IEvaluationResultStore resultStore; + var telemetryProperties = + new Dictionary + { + [PropertyNames.LastN] = lastN.ToTelemetryPropertyValue(), + [PropertyNames.Format] = format.ToString(), + [PropertyNames.OpenReport] = openReport.ToTelemetryPropertyValue() + }; - if (storageRootDir is not null) - { - string storageRootPath = storageRootDir.FullName; - logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); + await logger.ExecuteWithCatchAsync( + operation: () => + telemetryHelper.ReportOperationAsync( + operationName: EventNames.ReportCommand, + operation: async ValueTask () => + { + IEvaluationResultStore resultStore; - resultStore = new DiskBasedResultStore(storageRootPath); - } - else if (endpointUri is not null) - { - logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); + if (storageRootDir is not null) + { + string storageRootPath = storageRootDir.FullName; + logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); - var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); - resultStore = new AzureStorageResultStore(fsClient); - } - else - { - throw new InvalidOperationException("Either --path or --endpoint must be specified"); - } + resultStore = new DiskBasedResultStore(storageRootPath); - List results = []; + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeDisk; + } + else if (endpointUri is not null) + { + logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); - string? latestExecutionName = null; + var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); + resultStore = new AzureStorageResultStore(fsClient); - await foreach (string executionName in - resultStore.GetLatestExecutionNamesAsync(lastN, cancellationToken).ConfigureAwait(false)) - { - latestExecutionName ??= executionName; + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeAzure; + } + else + { + throw new InvalidOperationException("Either --path or --endpoint must be specified"); + } + + List results = []; + string? latestExecutionName = null; + + int resultId = 0; + var usageDetailsByModel = + new Dictionary<(string? model, string? modelProvider), TurnAndTokenUsageDetails>(); + + await foreach (string executionName in + resultStore.GetLatestExecutionNamesAsync(lastN, cancellationToken).ConfigureAwait(false)) + { + latestExecutionName ??= executionName; + + await foreach (ScenarioRunResult result in + resultStore.ReadResultsAsync( + executionName, + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (result.ExecutionName == latestExecutionName) + { + ReportScenarioRunResult( + ++resultId, + result, + usageDetailsByModel, + cancellationToken); + } + else + { + // Clear the chat data for following executions + result.Messages = []; + result.ModelResponse = new ChatResponse(); + } + + results.Add(result); - await foreach (ScenarioRunResult result in - resultStore.ReadResultsAsync( - executionName, - cancellationToken: cancellationToken).ConfigureAwait(false)) + logger.LogInformation( + "Execution: {executionName} Scenario: {scenarioName} Iteration: {iterationName}", + result.ExecutionName, + result.ScenarioName, + result.IterationName); + } + } + + ReportUsageDetails(usageDetailsByModel, cancellationToken); + + string outputFilePath = outputFile.FullName; + string? outputPath = Path.GetDirectoryName(outputFilePath); + if (outputPath is not null && !Directory.Exists(outputPath)) + { + _ = Directory.CreateDirectory(outputPath); + } + + IEvaluationReportWriter reportWriter = format switch + { + Format.html => new HtmlReportWriter(outputFilePath), + Format.json => new JsonReportWriter(outputFilePath), + _ => throw new NotSupportedException(), + }; + + await reportWriter.WriteReportAsync(results, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Report: {outputFilePath} [{format}]", outputFilePath, format); + + // See the following issues for reasoning behind this check. We want to avoid opening the + // report if this process is running as a service or in a CI pipeline. + // https://github.com/dotnet/runtime/issues/770#issuecomment-564700467 + // https://github.com/dotnet/runtime/issues/66530#issuecomment-1065854289 + bool isRedirected = + System.Console.IsInputRedirected && + System.Console.IsOutputRedirected && + System.Console.IsErrorRedirected; + + bool isInteractive = + Environment.UserInteractive && (OperatingSystem.IsWindows() || !isRedirected); + + if (openReport && isInteractive) + { + // Open the generated report in the default browser. + _ = Process.Start( + new ProcessStartInfo + { + FileName = outputFilePath, + UseShellExecute = true + }); + } + }, + properties: telemetryProperties, + logger: logger)).ConfigureAwait(false); + + return 0; + } + + private void ReportScenarioRunResult( + int resultId, + ScenarioRunResult result, + Dictionary<(string? model, string? modelProvider), TurnAndTokenUsageDetails> usageDetailsByModel, + CancellationToken cancellationToken) + { + logger.ExecuteWithCatch(() => + { + if (result.ChatDetails?.TurnDetails is IList turns) { - if (result.ExecutionName != latestExecutionName) + foreach (ChatTurnDetails turn in turns) { - // Clear the chat data for following executions - result.Messages = []; - result.ModelResponse = new ChatResponse(); + cancellationToken.ThrowIfCancellationRequested(); + + (string? model, string? modelProvider) key = (turn.Model, turn.ModelProvider); + if (!usageDetailsByModel.TryGetValue(key, out TurnAndTokenUsageDetails? usageDetails)) + { + usageDetails = new TurnAndTokenUsageDetails(); + usageDetailsByModel[key] = usageDetails; + } + + usageDetails.Add(turn); } + } + + string resultIdValue = resultId.ToTelemetryPropertyValue(); + ICollection metrics = result.EvaluationResult.Metrics.Values; + + var properties = + new Dictionary + { + [PropertyNames.ScenarioRunResultId] = resultIdValue, + [PropertyNames.MetricsCount] = metrics.Count.ToTelemetryPropertyValue() + }; - results.Add(result); + telemetryHelper.ReportEvent(eventName: EventNames.ScenarioRunResult, properties); - logger.LogInformation("Execution: {executionName} Scenario: {scenarioName} Iteration: {iterationName}", result.ExecutionName, result.ScenarioName, result.IterationName); + foreach (EvaluationMetric metric in metrics) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (metric.IsBuiltIn()) + { + ReportBuiltInMetric(metric, resultIdValue); + } } - } + }, + swallowUnhandledExceptions: true); // Log and ignore exceptions encountered when trying to report telemetry. - string outputFilePath = outputFile.FullName; - string? outputPath = Path.GetDirectoryName(outputFilePath); - if (outputPath is not null && !Directory.Exists(outputPath)) + void ReportBuiltInMetric(EvaluationMetric metric, string resultIdValue) { - _ = Directory.CreateDirectory(outputPath); + // We always want to report the diagnostics counts - even when metric.Diagnostics is null. This is because + // we know that when metric.Diagnostics is null, that means there were no diagnostics (as opposed to + // meaning that diagnostic information was somehow missing or unavailable). + int errorDiagnosticsCount = + metric.Diagnostics?.Count(d => d.Severity == EvaluationDiagnosticSeverity.Error) ?? 0; + int warningDiagnosticsCount = + metric.Diagnostics?.Count(d => d.Severity == EvaluationDiagnosticSeverity.Warning) ?? 0; + int informationalDiagnosticsCount = + metric.Diagnostics?.Count(d => d.Severity == EvaluationDiagnosticSeverity.Informational) ?? 0; + + var properties = + new Dictionary + { + [PropertyNames.MetricName] = metric.Name, + [PropertyNames.ScenarioRunResultId] = resultIdValue, + [PropertyNames.ErrorDiagnosticsCount] = errorDiagnosticsCount.ToTelemetryPropertyValue(), + [PropertyNames.WarningDiagnosticsCount] = warningDiagnosticsCount.ToTelemetryPropertyValue(), + [PropertyNames.InformationalDiagnosticsCount] = + informationalDiagnosticsCount.ToTelemetryPropertyValue() + }; + + // We want to omit reporting the below properties (such as token counts) when the corresponding metadata is + // missing. This is because we know that when the metadata is missing, that means the corresponding + // information was not available. For example, it would be wrong to report the token counts as 0 when in + // reality the token count information is missing because it was not available as part of the ChatResponse + // returned from the IChatClient during evaluation. + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalModelMetadataName) is string model) + { + properties[PropertyNames.Model] = model; + } + + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalInputTokensMetadataName) + is string inputTokenCount) + { + properties[PropertyNames.InputTokenCount] = inputTokenCount; + } + + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalOutputTokensMetadataName) + is string outputTokenCount) + { + properties[PropertyNames.OutputTokenCount] = outputTokenCount; + } + + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalDurationMillisecondsMetadataName) + is string durationInMilliseconds) + { + properties[PropertyNames.DurationInMilliseconds] = durationInMilliseconds; + } + + if (metric.Interpretation?.Failed is bool failed) + { + properties[PropertyNames.IsInterpretedAsFailed] = failed.ToTelemetryPropertyValue(); + } + + telemetryHelper.ReportEvent(eventName: EventNames.BuiltInMetric, properties); + + string? TryGetPropertyValueFromMetadata(string metadataName) + { + if ((metric.Metadata?.TryGetValue(metadataName, out string? value)) is not true || + string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value; + } } + } - IEvaluationReportWriter reportWriter = format switch + private void ReportUsageDetails( + Dictionary<(string? model, string? modelProvider), TurnAndTokenUsageDetails> usageDetailsByModel, + CancellationToken cancellationToken) + { + logger.ExecuteWithCatch(() => { - Format.html => new HtmlReportWriter(outputFilePath), - Format.json => new JsonReportWriter(outputFilePath), - _ => throw new NotSupportedException(), - }; - - await reportWriter.WriteReportAsync(results, cancellationToken).ConfigureAwait(false); - logger.LogInformation("Report: {outputFilePath} [{format}]", outputFilePath, format); - - // See the following issues for reasoning behind this check. We want to avoid opening the report - // if this process is running as a service or in a CI pipeline. - // https://github.com/dotnet/runtime/issues/770#issuecomment-564700467 - // https://github.com/dotnet/runtime/issues/66530#issuecomment-1065854289 - bool isRedirected = System.Console.IsInputRedirected && System.Console.IsOutputRedirected && System.Console.IsErrorRedirected; - bool isInteractive = Environment.UserInteractive && (OperatingSystem.IsWindows() || !(isRedirected)); - - if (openReport && isInteractive) + foreach (((string? model, string? modelProvider), TurnAndTokenUsageDetails usageDetails) + in usageDetailsByModel) + { + cancellationToken.ThrowIfCancellationRequested(); + + string isModelHostWellKnown = ModelInfo.IsModelHostWellKnown(modelProvider).ToTelemetryPropertyValue(); + string isModelHostedLocally = ModelInfo.IsModelHostedLocally(modelProvider).ToTelemetryPropertyValue(); + string cachedTurnCount = usageDetails.CachedTurnCount.ToTelemetryPropertyValue(); + string nonCachedTurnCount = usageDetails.NonCachedTurnCount.ToTelemetryPropertyValue(); + + var properties = + new Dictionary + { + [PropertyNames.Model] = model.ToTelemetryPropertyValue(defaultValue: PropertyValues.Unknown), + [PropertyNames.ModelProvider] = + modelProvider.ToTelemetryPropertyValue(defaultValue: PropertyValues.Unknown), + [PropertyNames.IsModelHostWellKnown] = isModelHostWellKnown, + [PropertyNames.IsModelHostedLocally] = isModelHostedLocally, + [PropertyNames.CachedTurnCount] = cachedTurnCount, + [PropertyNames.NonCachedTurnCount] = nonCachedTurnCount + }; + + // We want to omit reporting the below token counts when the information is not available. It would be + // wrong to report the token counts as 0 when in reality the token count information is missing because + // it was not available as part of the ChatResponses returned from the IChatClients used during + // evaluation. + if (usageDetails.CachedInputTokenCount is long cachedInputTokenCount) + { + properties[PropertyNames.CachedInputTokenCount] = cachedInputTokenCount.ToTelemetryPropertyValue(); + } + + if (usageDetails.CachedOutputTokenCount is long cachedOutputTokenCount) + { + properties[PropertyNames.CachedOutputTokenCount] = + cachedOutputTokenCount.ToTelemetryPropertyValue(); + } + + if (usageDetails.NonCachedInputTokenCount is long nonCachedInputTokenCount) + { + properties[PropertyNames.NonCachedInputTokenCount] = + nonCachedInputTokenCount.ToTelemetryPropertyValue(); + } + + if (usageDetails.NonCachedOutputTokenCount is long nonCachedOutputTokenCount) + { + properties[PropertyNames.NonCachedOutputTokenCount] = + nonCachedOutputTokenCount.ToTelemetryPropertyValue(); + } + + telemetryHelper.ReportEvent(eventName: EventNames.ModelUsageDetails, properties); + } + }, + swallowUnhandledExceptions: true); // Log and ignore exceptions encountered when trying to report telemetry. + } + + private sealed class TurnAndTokenUsageDetails + { + internal long CachedTurnCount { get; private set; } + internal long NonCachedTurnCount { get; private set; } + internal long? CachedInputTokenCount { get; private set; } + internal long? NonCachedInputTokenCount { get; private set; } + internal long? CachedOutputTokenCount { get; private set; } + internal long? NonCachedOutputTokenCount { get; private set; } + + internal void Add(ChatTurnDetails turn) { - // Open the generated report in the default browser. - _ = Process.Start( - new ProcessStartInfo + EnsureTokenCountsInitialized(); + + bool isCached = turn.CacheHit ?? false; + if (isCached) + { + ++CachedTurnCount; + CachedInputTokenCount += turn.Usage?.InputTokenCount; + CachedOutputTokenCount += turn.Usage?.OutputTokenCount; + } + else + { + ++NonCachedTurnCount; + NonCachedInputTokenCount += turn.Usage?.InputTokenCount; + NonCachedOutputTokenCount += turn.Usage?.OutputTokenCount; + } + + void EnsureTokenCountsInitialized() + { + // If any turn (for a particular model and model provider combination) contains token usage details, we + // initialize both the cumulative cached token counts as well as the cumulative non-cached token counts + // (for this model and model provider combination) to 0. This is done so that when all token usage (for + // a particular model and model provider combination) is non-cached, we can report the cumulative + // cached token counts (for this model and model provider combination) as 0 (as opposed to treating the + // cached token counts as unknown and omitting them from the reported event), and vice-versa. The + // assumption here is that if any turn (for a particular model and model provider combination) contains + // token usage details, then all other turns (for the same model and model provider combination) will + // also contain this. + + if (turn.Usage?.InputTokenCount is not null) { - FileName = outputFilePath, - UseShellExecute = true - }); - } + CachedInputTokenCount ??= 0; + NonCachedInputTokenCount ??= 0; + } - return 0; + if (turn.Usage?.OutputTokenCount is not null) + { + CachedOutputTokenCount ??= 0; + NonCachedOutputTokenCount ??= 0; + } + } + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj index a1d9252e8e0..dafcc78f988 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj @@ -13,11 +13,10 @@ AIEval - preview + normal true - false - 8 - 0 + n/a + n/a - 4 - 0 + n/a + n/a @@ -21,6 +20,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.json new file mode 100644 index 00000000000..d09ee7c900a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.json @@ -0,0 +1,298 @@ +{ + "Name": "Microsoft.Extensions.AI.Evaluation.Quality, Version=9.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.CoherenceEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.CoherenceEvaluator.CoherenceEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.CoherenceEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.CoherenceEvaluator.CoherenceMetricName { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.CoherenceEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluator.CompletenessEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluator.CompletenessMetricName { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluatorContext : Microsoft.Extensions.AI.Evaluation.EvaluationContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluatorContext.CompletenessEvaluatorContext(string groundTruth);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluatorContext.GroundTruth { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluatorContext.GroundTruthContextName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluator.EquivalenceEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluator.EquivalenceMetricName { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluatorContext : Microsoft.Extensions.AI.Evaluation.EvaluationContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluatorContext.EquivalenceEvaluatorContext(string groundTruth);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluatorContext.GroundTruth { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.EquivalenceEvaluatorContext.GroundTruthContextName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.FluencyEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.FluencyEvaluator.FluencyEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.FluencyEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.FluencyEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.FluencyEvaluator.FluencyMetricName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluator.GroundednessEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluator.GroundednessMetricName { get; }", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluatorContext : Microsoft.Extensions.AI.Evaluation.EvaluationContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluatorContext.GroundednessEvaluatorContext(string groundingContext);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluatorContext.GroundingContext { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.GroundednessEvaluatorContext.GroundingContextName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.RelevanceEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.RelevanceEvaluator.RelevanceEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.RelevanceEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.RelevanceEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.RelevanceEvaluator.RelevanceMetricName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator.RelevanceTruthAndCompletenessEvaluator();", + "Stage": "Experimental" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator.CompletenessMetricName { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator.EvaluationMetricNames { get; }", + "Stage": "Experimental" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator.RelevanceMetricName { get; }", + "Stage": "Experimental" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.RelevanceTruthAndCompletenessEvaluator.TruthMetricName { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluator.RetrievalEvaluator();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluator.RetrievalMetricName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluatorContext : Microsoft.Extensions.AI.Evaluation.EvaluationContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluatorContext.RetrievalEvaluatorContext(System.Collections.Generic.IEnumerable retrievedContextChunks);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluatorContext.RetrievalEvaluatorContext(params string[] retrievedContextChunks);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluatorContext.RetrievedContextChunks { get; }", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.Quality.RetrievalEvaluatorContext.RetrievedContextChunksContextName { get; }", + "Stage": "Stable" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/README.md b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/README.md index c21e2a299ad..dfc15311489 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/README.md @@ -5,6 +5,8 @@ * [`Microsoft.Extensions.AI.Evaluation`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation) - Defines core abstractions and types for supporting evaluation. * [`Microsoft.Extensions.AI.Evaluation.Quality`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Quality) - Contains evaluators that can be used to evaluate the quality of AI responses in your projects including Relevance, Truth, Completeness, Fluency, Coherence, Retrieval, Equivalence and Groundedness. * [`Microsoft.Extensions.AI.Evaluation.Safety`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Safety) - Contains a set of evaluators that are built atop the Azure AI Foundry Evaluation service that can be used to evaluate the content safety of AI responses in your projects including Protected Material, Groundedness Pro, Ungrounded Attributes, Hate and Unfairness, Self Harm, Violence, Sexual, Code Vulnerability and Indirect Attack. +* [`Microsoft.Extensions.AI.Evaluation.NLP`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.NLP) - Contains a set of evaluators that implement common algorithms for evaluating machine translation and natural +language processing tasks. Evaluators currently include BLEU, GLEU and F1 scores. * [`Microsoft.Extensions.AI.Evaluation.Reporting`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting) - Contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. * [`Microsoft.Extensions.AI.Evaluation.Reporting.Azure`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting.Azure) - Supports the `Microsoft.Extensions.AI.Evaluation.Reporting` library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. * [`Microsoft.Extensions.AI.Evaluation.Console`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Console) - A command line dotnet tool for generating reports and managing evaluation data. @@ -18,6 +20,7 @@ dotnet add package Microsoft.Extensions.AI.Evaluation dotnet add package Microsoft.Extensions.AI.Evaluation.Quality dotnet add package Microsoft.Extensions.AI.Evaluation.Safety dotnet add package Microsoft.Extensions.AI.Evaluation.Reporting +dotnet add package Microsoft.Extensions.AI.Evaluation.NLP ``` Or directly in the C# project file: @@ -28,6 +31,7 @@ Or directly in the C# project file: + ``` diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs index 86fb950e720..31f7a68d510 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs @@ -74,13 +74,13 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(RelevanceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); - if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || - string.IsNullOrWhiteSpace(userRequest.Text)) + if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || string.IsNullOrWhiteSpace(userRequest.Text)) { metric.AddDiagnostics( EvaluationDiagnostic.Error( - $"The ${messages} supplied for evaluation did not contain a user request as the last message.")); + $"The {nameof(messages)} supplied for evaluation did not contain a user request as the last message.")); return result; } @@ -109,7 +109,6 @@ await TimingHelper.ExecuteWithTimingAsync(() => private static List GetEvaluationInstructions(ChatMessage userRequest, ChatResponse modelResponse) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -119,14 +118,12 @@ private static List GetEvaluationInstructions(ChatMessage userReque - **Data**: Your input data include QUERY and RESPONSE. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; string renderedUserRequest = userRequest.RenderText(); string renderedModelResponse = modelResponse.RenderText(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -200,7 +197,6 @@ private static List GetEvaluationInstructions(ChatMessage userReque ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.Rating.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.Rating.cs deleted file mode 100644 index 8ff913fefe7..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.Rating.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI.Evaluation.Quality.Utilities; - -namespace Microsoft.Extensions.AI.Evaluation.Quality; - -public partial class RelevanceTruthAndCompletenessEvaluator -{ - internal sealed class Rating - { - public static Rating Inconclusive { get; } = new Rating(relevance: -1, truth: -1, completeness: -1); - - public int Relevance { get; } - public string? RelevanceReasoning { get; } - public string[] RelevanceReasons { get; } = []; - - public int Truth { get; } - public string? TruthReasoning { get; } - public string[] TruthReasons { get; } = []; - - public int Completeness { get; } - public string? CompletenessReasoning { get; } - public string[] CompletenessReasons { get; } = []; - - public string? Error { get; } - - private const int MinValue = 1; - private const int MaxValue = 5; - -#pragma warning disable S1067 // Expressions should not be too complex. - public bool IsInconclusive => - Error is not null || - Relevance < MinValue || Relevance > MaxValue || - Truth < MinValue || Truth > MaxValue || - Completeness < MinValue || Completeness > MaxValue; -#pragma warning restore S1067 - - public Rating(int relevance, int truth, int completeness, string? error = null) - { - (Relevance, Truth, Completeness, Error) = (relevance, truth, completeness, error); - } - - [JsonConstructor] -#pragma warning disable S107 // Methods should not have too many parameters. - public Rating( - int relevance, string? relevanceReasoning, string[] relevanceReasons, - int truth, string? truthReasoning, string[] truthReasons, - int completeness, string? completenessReasoning, string[] completenessReasons, - string? error = null) -#pragma warning restore S107 - { - (Relevance, RelevanceReasoning, RelevanceReasons, - Truth, TruthReasoning, TruthReasons, - Completeness, CompletenessReasoning, CompletenessReasons, - Error) = - (relevance, relevanceReasoning, relevanceReasons ?? [], - truth, truthReasoning, truthReasons ?? [], - completeness, completenessReasoning, completenessReasons ?? [], - error); - } - - public static Rating FromJson(string jsonResponse) - { - ReadOnlySpan trimmed = JsonOutputFixer.TrimMarkdownDelimiters(jsonResponse); - return JsonSerializer.Deserialize(trimmed, SerializerContext.Default.Rating)!; - } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.SerializerContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.SerializerContext.cs deleted file mode 100644 index 211213d4873..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.SerializerContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI.Evaluation.Quality; - -public partial class RelevanceTruthAndCompletenessEvaluator -{ - [JsonSourceGenerationOptions( - WriteIndented = true, - AllowTrailingCommas = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] - [JsonSerializable(typeof(Rating))] - internal sealed partial class SerializerContext : JsonSerializerContext; -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs index d10c259a4de..a41f7a92824 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs @@ -43,7 +43,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// Tutorial: Evaluate a model's response with response caching and reporting. /// [Experimental("AIEVAL001")] -public sealed partial class RelevanceTruthAndCompletenessEvaluator : IEvaluator +public sealed class RelevanceTruthAndCompletenessEvaluator : IEvaluator { /// /// Gets the of the returned by @@ -90,6 +90,10 @@ public async ValueTask EvaluateAsync( var completeness = new NumericMetric(CompletenessMetricName); var result = new EvaluationResult(relevance, truth, completeness); + relevance.MarkAsBuiltIn(); + truth.MarkAsBuiltIn(); + completeness.MarkAsBuiltIn(); + if (!messages.TryGetUserRequest( out ChatMessage? userRequest, out IReadOnlyList conversationHistory) || @@ -97,7 +101,7 @@ public async ValueTask EvaluateAsync( { result.AddDiagnosticsToAllMetrics( EvaluationDiagnostic.Error( - $"The ${messages} supplied for evaluation did not contain a user request as the last message.")); + $"The {nameof(messages)} supplied for evaluation did not contain a user request as the last message.")); return result; } @@ -139,7 +143,6 @@ private static List GetEvaluationInstructions( string renderedModelResponse = modelResponse.RenderText(); string renderedConversationHistory = conversationHistory.RenderText(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" Read the History, User Query, and Model Response below and produce your response as a single JSON object. @@ -259,7 +262,6 @@ Step 3a. Record your response as the value of the "completeness" property in the ----- """; -#pragma warning restore S103 return [new ChatMessage(ChatRole.User, evaluationPrompt)]; } @@ -271,12 +273,12 @@ private static async ValueTask ParseEvaluationResponseAsync( ChatConfiguration chatConfiguration, CancellationToken cancellationToken) { - Rating rating; + RelevanceTruthAndCompletenessRating rating; string evaluationResponseText = evaluationResponse.Text.Trim(); if (string.IsNullOrEmpty(evaluationResponseText)) { - rating = Rating.Inconclusive; + rating = RelevanceTruthAndCompletenessRating.Inconclusive; result.AddDiagnosticsToAllMetrics( EvaluationDiagnostic.Error("The model failed to produce a valid evaluation response.")); } @@ -284,7 +286,7 @@ private static async ValueTask ParseEvaluationResponseAsync( { try { - rating = Rating.FromJson(evaluationResponseText); + rating = RelevanceTruthAndCompletenessRating.FromJson(evaluationResponseText); } catch (JsonException) { @@ -298,26 +300,26 @@ await JsonOutputFixer.RepairJsonAsync( if (string.IsNullOrWhiteSpace(repairedJson)) { - rating = Rating.Inconclusive; + rating = RelevanceTruthAndCompletenessRating.Inconclusive; result.AddDiagnosticsToAllMetrics( EvaluationDiagnostic.Error( $""" - Failed to repair the following response from the model and parse scores for '{RelevanceMetricName}', '{TruthMetricName}' and '{CompletenessMetricName}'.: + Failed to repair the following response from the model and parse scores for '{RelevanceMetricName}', '{TruthMetricName}' and '{CompletenessMetricName}': {evaluationResponseText} """)); } else { - rating = Rating.FromJson(repairedJson); + rating = RelevanceTruthAndCompletenessRating.FromJson(repairedJson); } } catch (JsonException ex) { - rating = Rating.Inconclusive; + rating = RelevanceTruthAndCompletenessRating.Inconclusive; result.AddDiagnosticsToAllMetrics( EvaluationDiagnostic.Error( $""" - Failed to repair the following response from the model and parse scores for '{RelevanceMetricName}', '{TruthMetricName}' and '{CompletenessMetricName}'.: + Failed to repair the following response from the model and parse scores for '{RelevanceMetricName}', '{TruthMetricName}' and '{CompletenessMetricName}': {evaluationResponseText} {ex} """)); @@ -336,10 +338,7 @@ void UpdateResult() relevance.AddOrUpdateChatMetadata(evaluationResponse, evaluationDuration); relevance.Value = rating.Relevance; relevance.Interpretation = relevance.InterpretScore(); - if (!string.IsNullOrWhiteSpace(rating.RelevanceReasoning)) - { - relevance.Reason = rating.RelevanceReasoning!; - } + relevance.Reason = rating.RelevanceReasoning; if (rating.RelevanceReasons.Any()) { @@ -351,10 +350,7 @@ void UpdateResult() truth.AddOrUpdateChatMetadata(evaluationResponse, evaluationDuration); truth.Value = rating.Truth; truth.Interpretation = truth.InterpretScore(); - if (!string.IsNullOrWhiteSpace(rating.TruthReasoning)) - { - truth.Reason = rating.TruthReasoning!; - } + truth.Reason = rating.TruthReasoning; if (rating.TruthReasons.Any()) { @@ -366,21 +362,13 @@ void UpdateResult() completeness.AddOrUpdateChatMetadata(evaluationResponse, evaluationDuration); completeness.Value = rating.Completeness; completeness.Interpretation = completeness.InterpretScore(); - if (!string.IsNullOrWhiteSpace(rating.CompletenessReasoning)) - { - completeness.Reason = rating.CompletenessReasoning!; - } + completeness.Reason = rating.CompletenessReasoning; if (rating.CompletenessReasons.Any()) { string value = string.Join(Separator, rating.CompletenessReasons); completeness.AddOrUpdateMetadata(name: Rationales, value); } - - if (!string.IsNullOrWhiteSpace(rating.Error)) - { - result.AddDiagnosticsToAllMetrics(EvaluationDiagnostic.Error(rating.Error!)); - } } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs new file mode 100644 index 00000000000..8d4cd88fb5a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI.Evaluation.Quality.JsonSerialization; +using Microsoft.Extensions.AI.Evaluation.Quality.Utilities; + +namespace Microsoft.Extensions.AI.Evaluation.Quality; + +internal sealed class RelevanceTruthAndCompletenessRating +{ + public static RelevanceTruthAndCompletenessRating Inconclusive { get; } = + new RelevanceTruthAndCompletenessRating( + relevance: 0, + relevanceReasoning: string.Empty, + relevanceReasons: [], + truth: 0, + truthReasoning: string.Empty, + truthReasons: [], + completeness: 0, + completenessReasoning: string.Empty, + completenessReasons: []); + + [JsonRequired] + public int Relevance { get; set; } + + [JsonRequired] + public string RelevanceReasoning { get; set; } + + [JsonRequired] + public string[] RelevanceReasons { get; set; } + + [JsonRequired] + public int Truth { get; set; } + + [JsonRequired] + public string TruthReasoning { get; set; } + + [JsonRequired] + public string[] TruthReasons { get; set; } + + [JsonRequired] + public int Completeness { get; set; } + + [JsonRequired] + public string CompletenessReasoning { get; set; } + + [JsonRequired] + public string[] CompletenessReasons { get; set; } + + private const int MinValue = 1; + private const int MaxValue = 5; + + public bool IsInconclusive => + Relevance < MinValue || Relevance > MaxValue || + Truth < MinValue || Truth > MaxValue || + Completeness < MinValue || Completeness > MaxValue; + + [JsonConstructor] + public RelevanceTruthAndCompletenessRating( + int relevance, string relevanceReasoning, string[] relevanceReasons, + int truth, string truthReasoning, string[] truthReasons, + int completeness, string completenessReasoning, string[] completenessReasons) + { + (Relevance, RelevanceReasoning, RelevanceReasons, + Truth, TruthReasoning, TruthReasons, + Completeness, CompletenessReasoning, CompletenessReasons) = + (relevance, relevanceReasoning, relevanceReasons ?? [], + truth, truthReasoning, truthReasons ?? [], + completeness, completenessReasoning, completenessReasons ?? []); + } + + public static RelevanceTruthAndCompletenessRating FromJson(string jsonResponse) + { + ReadOnlySpan trimmed = JsonOutputFixer.TrimMarkdownDelimiters(jsonResponse); + return JsonSerializer.Deserialize(trimmed, SerializerContext.Default.RelevanceTruthAndCompletenessRating)!; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs index 9ecfbb182f5..7bd776f2db6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs @@ -79,13 +79,13 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(RetrievalMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); - if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || - string.IsNullOrWhiteSpace(userRequest.Text)) + if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || string.IsNullOrWhiteSpace(userRequest.Text)) { metric.AddDiagnostics( EvaluationDiagnostic.Error( - $"The ${messages} supplied for evaluation did not contain a user request as the last message.")); + $"The {nameof(messages)} supplied for evaluation did not contain a user request as the last message.")); return result; } @@ -95,7 +95,16 @@ public async ValueTask EvaluateAsync( { metric.AddDiagnostics( EvaluationDiagnostic.Error( - $"A value of type '{nameof(RetrievalEvaluatorContext)}' was not found in the '{nameof(additionalContext)}' collection.")); + $"A value of type {nameof(RetrievalEvaluatorContext)} was not found in the {nameof(additionalContext)} collection.")); + + return result; + } + + if (context.RetrievedContextChunks.Count is 0) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"Supplied {nameof(RetrievalEvaluatorContext)} did not contain any {nameof(RetrievalEvaluatorContext.RetrievedContextChunks)}.")); return result; } @@ -119,7 +128,6 @@ private static List GetEvaluationInstructions( ChatMessage userRequest, RetrievalEvaluatorContext context) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -129,7 +137,6 @@ private static List GetEvaluationInstructions( - **Data**: Your input data include QUERY and CONTEXT. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; @@ -149,7 +156,6 @@ private static List GetEvaluationInstructions( _ = builder.Append(']'); string renderedContext = builder.ToString(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -216,7 +222,6 @@ private static List GetEvaluationInstructions( ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluatorContext.cs index 1b3f94bcdf9..50c80f42fa6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluatorContext.cs @@ -41,12 +41,12 @@ public sealed class RetrievalEvaluatorContext : EvaluationContext /// /// The context chunks that were retrieved in response to the user request being evaluated. /// - public RetrievalEvaluatorContext(IEnumerable retrievedContextChunks) + public RetrievalEvaluatorContext(params string[] retrievedContextChunks) : base( name: RetrievedContextChunksContextName, contents: [.. retrievedContextChunks.Select(c => new TextContent(c))]) { - RetrievedContextChunks = [.. retrievedContextChunks]; + RetrievedContextChunks = retrievedContextChunks; } /// @@ -55,8 +55,8 @@ public RetrievalEvaluatorContext(IEnumerable retrievedContextChunks) /// /// The context chunks that were retrieved in response to the user request being evaluated. /// - public RetrievalEvaluatorContext(params string[] retrievedContextChunks) - : this(retrievedContextChunks as IEnumerable) + public RetrievalEvaluatorContext(IEnumerable retrievedContextChunks) + : this(retrievedContextChunks.ToArray()) { } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs new file mode 100644 index 00000000000..5f447138312 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs @@ -0,0 +1,267 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Evaluation.Quality; + +/// +/// An that evaluates an AI system's effectiveness at adhering to the task assigned to it. +/// +/// +/// +/// measures how accurately an AI system adheres to the task assigned to it by +/// examining the alignment of the supplied response with instructions and definitions present in the conversation +/// history, the accuracy and clarity of the response, and the proper use of tool definitions supplied via +/// . +/// +/// +/// Note that at the moment, only supports evaluating calls to tools that are +/// defined as s. Any other definitions that are supplied via +/// will be ignored. +/// +/// +/// returns a that contains a score for 'Task +/// Adherence'. The score is a number between 1 and 5, with 1 indicating a poor score, and 5 indicating an excellent +/// score. +/// +/// +/// Note: is an AI-based evaluator that uses an AI model to perform its +/// evaluation. While the prompt that this evaluator uses to perform its evaluation is designed to be model-agnostic, +/// the performance of this prompt (and the resulting evaluation) can vary depending on the model used, and can be +/// especially poor when a smaller / local model is used. +/// +/// +/// The prompt that uses has been tested against (and tuned to work well with) +/// the following models. So, using this evaluator with a model from the following list is likely to produce the best +/// results. (The model to be used can be configured via .) +/// +/// +/// GPT-4o +/// +/// +[Experimental("AIEVAL001")] +public sealed class TaskAdherenceEvaluator : IEvaluator +{ + /// + /// Gets the of the returned by + /// . + /// + public static string TaskAdherenceMetricName => "Task Adherence"; + + /// + public IReadOnlyCollection EvaluationMetricNames { get; } = [TaskAdherenceMetricName]; + + private static readonly ChatOptions _chatOptions = + new ChatOptions + { + Temperature = 0.0f, + MaxOutputTokens = 800, + TopP = 1.0f, + PresencePenalty = 0.0f, + FrequencyPenalty = 0.0f, + ResponseFormat = ChatResponseFormat.Text + }; + + /// + public async ValueTask EvaluateAsync( + IEnumerable messages, + ChatResponse modelResponse, + ChatConfiguration? chatConfiguration = null, + IEnumerable? additionalContext = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(modelResponse); + _ = Throw.IfNull(chatConfiguration); + + var metric = new NumericMetric(TaskAdherenceMetricName); + var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); + + if (!messages.Any()) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + "The conversation history supplied for evaluation did not include any messages.")); + + return result; + } + + if (!modelResponse.Messages.Any()) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"The {nameof(modelResponse)} supplied for evaluation did not include any messages.")); + + return result; + } + + TaskAdherenceEvaluatorContext? context = + additionalContext?.OfType().FirstOrDefault(); + + if (context is not null && context.ToolDefinitions.Count is 0) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"Supplied {nameof(TaskAdherenceEvaluatorContext)} did not contain any {nameof(TaskAdherenceEvaluatorContext.ToolDefinitions)}.")); + + return result; + } + + var toolDefinitionNames = new HashSet(context?.ToolDefinitions.Select(td => td.Name) ?? []); + IEnumerable toolCalls = + modelResponse.Messages.SelectMany(m => m.Contents).OfType(); + + if (toolCalls.Any(t => !toolDefinitionNames.Contains(t.Name))) + { + if (context is null) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"The {nameof(modelResponse)} supplied for evaluation contained calls to tools that were not supplied via {nameof(TaskAdherenceEvaluatorContext)}.")); + } + else + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"The {nameof(modelResponse)} supplied for evaluation contained calls to tools that were not included in the supplied {nameof(TaskAdherenceEvaluatorContext)}.")); + } + + return result; + } + + List evaluationInstructions = GetEvaluationInstructions(messages, modelResponse, context); + + (ChatResponse evaluationResponse, TimeSpan evaluationDuration) = + await TimingHelper.ExecuteWithTimingAsync(() => + chatConfiguration.ChatClient.GetResponseAsync( + evaluationInstructions, + _chatOptions, + cancellationToken)).ConfigureAwait(false); + + _ = metric.TryParseEvaluationResponseWithTags(evaluationResponse, evaluationDuration); + + if (context is not null) + { + metric.AddOrUpdateContext(context); + } + + metric.Interpretation = metric.InterpretScore(); + return result; + } + + private static List GetEvaluationInstructions( + IEnumerable messages, + ChatResponse modelResponse, + TaskAdherenceEvaluatorContext? context) + { + string renderedConversation = messages.RenderAsJson(); + string renderedModelResponse = modelResponse.RenderAsJson(); + string? renderedToolDefinitions = context?.ToolDefinitions.RenderAsJson(); + + string systemPrompt = + $$""" + # Instruction + ## Context + ### You are an expert in evaluating the quality of an answer from an intelligent system based on provided definitions and data. Your goal will involve answering the questions below using the information provided. + - **Definition**: Based on the provided query, response, and tool definitions, evaluate the agent's adherence to the assigned task. + - **Data**: Your input data includes query, response, and tool definitions. + - **Questions**: To complete your evaluation you will be asked to evaluate the Data in different ways. + + # Definition + + **Level 1: Fully Inadherent** + + **Definition:** + Response completely ignores instructions or deviates significantly + + **Example:** + **Query:** What is a recommended weekend itinerary in Paris? + **Response:** Paris is a lovely city with a rich history. + + Explanation: This response completely misses the task by not providing any itinerary details. It offers a generic statement about Paris rather than a structured travel plan. + + + **Level 2: Barely Adherent** + + **Definition:** + Response partially aligns with instructions but has critical gaps. + + **Example:** + **Query:** What is a recommended weekend itinerary in Paris? + **Response:** Spend your weekend visiting famous places in Paris. + + Explanation: While the response hints at visiting well-known sites, it is extremely vague and lacks specific details, such as which sites to visit or any order of activities, leaving major gaps in the instructions. + + + **Level 3: Moderately Adherent** + + **Definition:** + Response meets the core requirements but lacks precision or clarity. + + **Example:** + **Query:** What is a recommended weekend itinerary in Paris? + **Response:** Visit the Eiffel Tower and the Louvre on Saturday, and stroll through Montmartre on Sunday. + + Explanation: This answer meets the basic requirement by naming a few key attractions and assigning them to specific days. However, it lacks additional context, such as timings, additional activities, or details to make the itinerary practical and clear. + + + **Level 4: Mostly Adherent** + + **Definition:** + Response is clear, accurate, and aligns with instructions with minor issues. + + **Example:** + **Query:** What is a recommended weekend itinerary in Paris? + **Response:** For a weekend in Paris, start Saturday with a morning visit to the Eiffel Tower, then head to the Louvre in the early afternoon. In the evening, enjoy a leisurely walk along the Seine. On Sunday, begin with a visit to Notre-Dame Cathedral, followed by exploring the art and cafés in Montmartre. This plan offers a mix of cultural visits and relaxing experiences. + + Explanation: This response is clear, structured, and provides a concrete itinerary with specific attractions and a suggested order of activities. It is accurate and useful, though it might benefit from a few more details like exact timings or restaurant suggestions to be perfect. + + + **Level 5: Fully Adherent** + + **Definition:** + Response is flawless, accurate, and follows instructions to the letter. + + **Example:** + **Query:** What is a recommended weekend itinerary in Paris? + **Response:** Here is a detailed weekend itinerary in Paris: + Saturday: + Morning: Begin your day with a visit to the Eiffel Tower to admire the views from the top. + Early Afternoon: Head to the Louvre for a guided tour of its most famous exhibits. + Late Afternoon: Take a relaxing walk along the Seine, stopping at local boutiques. + Evening: Enjoy dinner at a classic Parisian bistro near the river. + Sunday: + Morning: Visit the Notre-Dame Cathedral to explore its architecture and history. + Midday: Wander the charming streets of Montmartre, stopping by art galleries and cafés. + Afternoon: Finish your trip with a scenic boat tour on the Seine. + This itinerary balances cultural immersion, leisure, and local dining experiences, ensuring a well-rounded visit. + + Explanation: This response is comprehensive and meticulously follows the instructions. It provides detailed steps, timings, and a variety of activities that fully address the query, leaving no critical gaps. + + # Data + Query: {{renderedConversation}} + Response: {{renderedModelResponse}} + Tool Definitions: {{renderedToolDefinitions}} + + # Tasks + ## Please provide your assessment Score for the previous answer. Your output should include the following information: + - **ThoughtChain**: To improve the reasoning process, Think Step by Step and include a step-by-step explanation of your thought process as you analyze the data based on the definitions. Keep it brief and Start your ThoughtChain with "Let's think step by step:". + - **Explanation**: a very short explanation of why you think the input data should get that Score. + - **Score**: based on your previous analysis, provide your Score. The answer you give MUST be an integer score ("1", "2", ...) based on the categories of the definitions. + + ## Please provide your answers between the tags: your chain of thoughts, your explanation, your score. + # Output + """; + + List evaluationInstructions = [new ChatMessage(ChatRole.System, systemPrompt)]; + return evaluationInstructions; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs new file mode 100644 index 00000000000..535306b5d4e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Microsoft.Extensions.AI.Evaluation.Quality; + +/// +/// Contextual information that the uses to evaluate an AI system's +/// effectiveness at adhering to the task assigned to it. +/// +/// +/// +/// measures how accurately an AI system adheres to the task assigned to it by +/// examining the alignment of the supplied response with instructions and definitions present in the conversation +/// history, the accuracy and clarity of the response, and the proper use of tool definitions supplied via +/// . +/// +/// +/// Note that at the moment, only supports evaluating calls to tools that are +/// defined as s. Any other definitions that are supplied via +/// will be ignored. +/// +/// +[Experimental("AIEVAL001")] +public sealed class TaskAdherenceEvaluatorContext : EvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// The set of tool definitions (see ) that were used when generating the model + /// response that is being evaluated. + /// + /// + /// Note that at the moment, only supports evaluating calls to tools that + /// are defined as s. Any other definitions will be ignored. + /// + /// + public TaskAdherenceEvaluatorContext(params AITool[] toolDefinitions) + : base(name: ToolDefinitionsContextName, contents: [new TextContent(toolDefinitions.RenderAsJson())]) + { + ToolDefinitions = [.. toolDefinitions]; + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// The set of tool definitions (see ) that were used when generating the model + /// response that is being evaluated. + /// + /// + /// Note that at the moment, only supports evaluating calls to tools that + /// are defined as s. Any other definitions will be ignored. + /// + /// + public TaskAdherenceEvaluatorContext(IEnumerable toolDefinitions) + : this(toolDefinitions.ToArray()) + { + } + + /// + /// Gets the unique that is used for + /// . + /// + public static string ToolDefinitionsContextName => "Tool Definitions (Task Adherence)"; + + /// + /// Gets set of tool definitions (see ) that were used when generating the model + /// response that is being evaluated. + /// + /// + /// + /// measures how accurately an AI system adheres to the task assigned to it by + /// examining the alignment of the supplied response with instructions and definitions present in the conversation + /// history, the accuracy and clarity of the response, and the proper use of tool definitions supplied via + /// . + /// + /// + /// Note that at the moment, only supports evaluating calls to tools that are + /// defined as s. Any other definitions that are supplied via + /// will be ignored. + /// + /// + public IReadOnlyList ToolDefinitions { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs new file mode 100644 index 00000000000..05dbf4bbc1d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI.Evaluation.Quality; + +/// +/// An that evaluates an AI system's effectiveness at using the tools supplied to it. +/// +/// +/// +/// measures how accurately an AI system uses tools by examining tool calls +/// (i.e., s) present in the supplied response to assess the relevance of these tool +/// calls to the conversation, the parameter correctness for these tool calls with regard to the tool definitions +/// supplied via , and the accuracy of the parameter +/// value extraction from the supplied conversation. +/// +/// +/// Note that at the moment, only supports evaluating calls to tools that are +/// defined as s. Any other definitions that are supplied via +/// will be ignored. +/// +/// +/// returns a that contains a score for 'Tool Call +/// Accuracy'. The score is if the tool call is irrelevant or contains information not present +/// in the conversation and if the tool call is relevant with properly extracted parameters +/// from the conversation. +/// +/// +/// Note: is an AI-based evaluator that uses an AI model to perform its +/// evaluation. While the prompt that this evaluator uses to perform its evaluation is designed to be model-agnostic, +/// the performance of this prompt (and the resulting evaluation) can vary depending on the model used, and can be +/// especially poor when a smaller / local model is used. +/// +/// +/// The prompt that uses has been tested against (and tuned to work well with) +/// the following models. So, using this evaluator with a model from the following list is likely to produce the best +/// results. (The model to be used can be configured via .) +/// +/// +/// GPT-4o +/// +/// +[Experimental("AIEVAL001")] +public sealed class ToolCallAccuracyEvaluator : IEvaluator +{ + /// + /// Gets the of the returned by + /// . + /// + public static string ToolCallAccuracyMetricName => "Tool Call Accuracy"; + + /// + public IReadOnlyCollection EvaluationMetricNames { get; } = [ToolCallAccuracyMetricName]; + + private static readonly ChatOptions _chatOptions = + new ChatOptions + { + Temperature = 0.0f, + MaxOutputTokens = 800, + TopP = 1.0f, + PresencePenalty = 0.0f, + FrequencyPenalty = 0.0f, + ResponseFormat = ChatResponseFormat.Text + }; + + /// + public async ValueTask EvaluateAsync( + IEnumerable messages, + ChatResponse modelResponse, + ChatConfiguration? chatConfiguration = null, + IEnumerable? additionalContext = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(modelResponse); + _ = Throw.IfNull(chatConfiguration); + + var metric = new BooleanMetric(ToolCallAccuracyMetricName); + var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); + + if (!messages.Any()) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + "The conversation history supplied for evaluation did not include any messages.")); + + return result; + } + + IEnumerable toolCalls = + modelResponse.Messages.SelectMany(m => m.Contents).OfType(); + + if (!toolCalls.Any()) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error($"The {nameof(modelResponse)} supplied for evaluation did not contain any tool calls (i.e., {nameof(FunctionCallContent)}s).")); + + return result; + } + + if (additionalContext?.OfType().FirstOrDefault() + is not ToolCallAccuracyEvaluatorContext context) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"A value of type {nameof(ToolCallAccuracyEvaluatorContext)} was not found in the {nameof(additionalContext)} collection.")); + + return result; + } + + if (context.ToolDefinitions.Count is 0) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"Supplied {nameof(ToolCallAccuracyEvaluatorContext)} did not contain any {nameof(ToolCallAccuracyEvaluatorContext.ToolDefinitions)}.")); + + return result; + } + + var toolDefinitionNames = new HashSet(context.ToolDefinitions.Select(td => td.Name)); + + if (toolCalls.Any(t => !toolDefinitionNames.Contains(t.Name))) + { + metric.AddDiagnostics( + EvaluationDiagnostic.Error( + $"The {nameof(modelResponse)} supplied for evaluation contained calls to tools that were not included in the supplied {nameof(ToolCallAccuracyEvaluatorContext)}.")); + + return result; + } + + List evaluationInstructions = GetEvaluationInstructions(messages, modelResponse, context); + + (ChatResponse evaluationResponse, TimeSpan evaluationDuration) = + await TimingHelper.ExecuteWithTimingAsync(() => + chatConfiguration.ChatClient.GetResponseAsync( + evaluationInstructions, + _chatOptions, + cancellationToken)).ConfigureAwait(false); + + _ = metric.TryParseEvaluationResponseWithTags(evaluationResponse, evaluationDuration); + metric.AddOrUpdateContext(context); + metric.Interpretation = metric.InterpretScore(); + return result; + } + + private static List GetEvaluationInstructions( + IEnumerable messages, + ChatResponse modelResponse, + ToolCallAccuracyEvaluatorContext context) + { + const string SystemPrompt = + """ + # Instruction + ## Goal + ### You are an expert in evaluating the accuracy of a tool call considering relevance and potential usefulness including syntactic and semantic correctness of a proposed tool call from an intelligent system based on provided definition and data. Your goal will involve answering the questions below using the information provided. + - **Definition**: You are given a definition of the communication trait that is being evaluated to help guide your Score. + - **Data**: Your input data include CONVERSATION , TOOL CALL and TOOL DEFINITION. + - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. + """; + + List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; + + string renderedConversation = messages.RenderText(); + string renderedToolCallsAndResults = modelResponse.RenderToolCallsAndResultsAsJson(); + string renderedToolDefinitions = context.ToolDefinitions.RenderAsJson(); + + string evaluationPrompt = + $$""" + # Definition + **Tool Call Accuracy** refers to the relevance and potential usefulness of a TOOL CALL in the context of an ongoing CONVERSATION and EXTRACTION of RIGHT PARAMETER VALUES from the CONVERSATION.It assesses how likely the TOOL CALL is to contribute meaningfully to the CONVERSATION and help address the user's needs. Focus on evaluating the potential value of the TOOL CALL within the specific context of the given CONVERSATION, without making assumptions beyond the provided information. + Consider the following factors in your evaluation: + + 1. Relevance: How well does the proposed tool call align with the current topic and flow of the conversation? + 2. Parameter Appropriateness: Do the parameters used in the TOOL CALL match the TOOL DEFINITION and are the parameters relevant to the latest user's query? + 3. Parameter Value Correctness: Are the parameters values used in the TOOL CALL present or inferred by CONVERSATION and relevant to the latest user's query? + 4. Potential Value: Is the information this tool call might provide likely to be useful in advancing the conversation or addressing the user expressed or implied needs? + 5. Context Appropriateness: Does the tool call make sense at this point in the conversation, given what has been discussed so far? + + + # Ratings + ## [Tool Call Accuracy: 0] (Irrelevant) + **Definition:** + 1. The TOOL CALL is not relevant and will not help resolve the user's need. + 2. TOOL CALL include parameters values that are not present or inferred from CONVERSATION. + 3. TOOL CALL has parameters that is not present in TOOL DEFINITION. + + ## [Tool Call Accuracy: 1] (Relevant) + **Definition:** + 1. The TOOL CALL is directly relevant and very likely to help resolve the user's need. + 2. TOOL CALL include parameters values that are present or inferred from CONVERSATION. + 3. TOOL CALL has parameters that is present in TOOL DEFINITION. + + # Data + CONVERSATION : {{renderedConversation}} + TOOL CALL: {{renderedToolCallsAndResults}} + TOOL DEFINITION: {{renderedToolDefinitions}} + + + # Tasks + ## Please provide your assessment Score for the previous CONVERSATION , TOOL CALL and TOOL DEFINITION based on the Definitions above. Your output should include the following information: + - **ThoughtChain**: To improve the reasoning process, think step by step and include a step-by-step explanation of your thought process as you analyze the data based on the definitions. Keep it brief and start your ThoughtChain with "Let's think step by step:". + - **Explanation**: a very short explanation of why you think the input Data should get that Score. + - **Score**: based on your previous analysis, provide your Score. The Score you give MUST be a integer score (i.e., "0", "1") based on the levels of the definitions. + + + ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. + # Output + """; + + evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); + + return evaluationInstructions; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs new file mode 100644 index 00000000000..7b01b3f50eb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Microsoft.Extensions.AI.Evaluation.Quality; + +/// +/// Contextual information that the uses to evaluate an AI system's +/// effectiveness at using the tools supplied to it. +/// +/// +/// +/// measures how accurately an AI system uses tools by examining tool calls +/// (i.e., s) present in the supplied response to assess the relevance of these tool +/// calls to the conversation, the parameter correctness for these tool calls with regard to the tool definitions +/// supplied via , and the accuracy of the parameter value extraction from the supplied +/// conversation history. +/// +/// +/// Note that at the moment, only supports evaluating calls to tools that are +/// defined as s. Any other definitions that are supplied via +/// will be ignored. +/// +/// +[Experimental("AIEVAL001")] +public sealed class ToolCallAccuracyEvaluatorContext : EvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// The set of tool definitions (see ) that were used when generating the model + /// response that is being evaluated. + /// + /// + /// Note that at the moment, only supports evaluating calls to tools that + /// are defined as s. Any other definitions will be ignored. + /// + /// + public ToolCallAccuracyEvaluatorContext(params AITool[] toolDefinitions) + : base(name: ToolDefinitionsContextName, contents: [new TextContent(toolDefinitions.RenderAsJson())]) + { + ToolDefinitions = [.. toolDefinitions]; + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// The set of tool definitions (see ) that were used when generating the model + /// response that is being evaluated. + /// + /// + /// Note that at the moment, only supports evaluating calls to tools that + /// are defined as s. Any other definitions will be ignored. + /// + /// + public ToolCallAccuracyEvaluatorContext(IEnumerable toolDefinitions) + : this(toolDefinitions.ToArray()) + { + } + + /// + /// Gets the unique that is used for + /// . + /// + public static string ToolDefinitionsContextName => "Tool Definitions (Tool Call Accuracy)"; + + /// + /// Gets set of tool definitions (see ) that were used when generating the model + /// response that is being evaluated. + /// + /// + /// + /// measures how accurately an AI system uses tools by examining tool calls + /// (i.e., s) present in the supplied response to assess the relevance of these + /// tool calls to the conversation, the parameter correctness for these tool calls with regard to the tool + /// definitions supplied via , and the accuracy of the parameter value extraction from + /// the supplied conversation history. + /// + /// + /// Note that at the moment, only supports evaluating calls to tools that + /// are defined as s. Any other definitions that are supplied via + /// will be ignored. + /// + /// + public IReadOnlyList ToolDefinitions { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs index b36c8d8bd56..da8d699699d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -12,19 +10,16 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; internal static partial class AzureStorageJsonUtilities { - [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Default matches the generated source naming convention.")] internal static class Default { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: true); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: true); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); } internal static class Compact { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: false); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: false); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); } @@ -36,7 +31,6 @@ private static JsonSerializerOptions CreateJsonSerializerOptions(bool writeInden var options = new JsonSerializerOptions(JsonContext.Default.Options) { WriteIndented = writeIndented, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); options.MakeReadOnly(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj index 237df014d0d..ddc986f187a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj @@ -4,17 +4,14 @@ A library that supports the Microsoft.Extensions.AI.Evaluation.Reporting library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.AI.Evaluation.Reporting - - $(NoWarn);EA0002 AIEval - preview + normal true - false - 88 - 0 + n/a + n/a diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.json new file mode 100644 index 00000000000..80e1bcb9377 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.json @@ -0,0 +1,77 @@ +{ + "Name": "Microsoft.Extensions.AI.Evaluation.Reporting.Azure, Version=9.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageReportingConfiguration", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageReportingConfiguration.Create(Azure.Storage.Files.DataLake.DataLakeDirectoryClient client, System.Collections.Generic.IEnumerable evaluators, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, bool enableResponseCaching = true, System.TimeSpan? timeToLiveForCacheEntries = null, System.Collections.Generic.IEnumerable? cachingKeys = null, string executionName = \"Default\", System.Func? evaluationMetricInterpreter = null, System.Collections.Generic.IEnumerable? tags = null);", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResponseCacheProvider : Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResponseCacheProvider.AzureStorageResponseCacheProvider(Azure.Storage.Files.DataLake.DataLakeDirectoryClient client, System.TimeSpan? timeToLiveForCacheEntries = null);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResponseCacheProvider.DeleteExpiredCacheEntriesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResponseCacheProvider.GetCacheAsync(string scenarioName, string iterationName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResponseCacheProvider.ResetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore : Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.AzureStorageResultStore(Azure.Storage.Files.DataLake.DataLakeDirectoryClient client);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.DeleteResultsAsync(string? executionName = null, string? scenarioName = null, string? iterationName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.GetIterationNamesAsync(string executionName, string scenarioName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.GetLatestExecutionNamesAsync(int? count = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.GetScenarioNamesAsync(string executionName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.ReadResultsAsync(string? executionName = null, string? scenarioName = null, string? iterationName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.AzureStorageResultStore.WriteResultsAsync(System.Collections.Generic.IEnumerable results, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/README.md b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/README.md index c21e2a299ad..dfc15311489 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/README.md @@ -5,6 +5,8 @@ * [`Microsoft.Extensions.AI.Evaluation`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation) - Defines core abstractions and types for supporting evaluation. * [`Microsoft.Extensions.AI.Evaluation.Quality`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Quality) - Contains evaluators that can be used to evaluate the quality of AI responses in your projects including Relevance, Truth, Completeness, Fluency, Coherence, Retrieval, Equivalence and Groundedness. * [`Microsoft.Extensions.AI.Evaluation.Safety`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Safety) - Contains a set of evaluators that are built atop the Azure AI Foundry Evaluation service that can be used to evaluate the content safety of AI responses in your projects including Protected Material, Groundedness Pro, Ungrounded Attributes, Hate and Unfairness, Self Harm, Violence, Sexual, Code Vulnerability and Indirect Attack. +* [`Microsoft.Extensions.AI.Evaluation.NLP`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.NLP) - Contains a set of evaluators that implement common algorithms for evaluating machine translation and natural +language processing tasks. Evaluators currently include BLEU, GLEU and F1 scores. * [`Microsoft.Extensions.AI.Evaluation.Reporting`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting) - Contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. * [`Microsoft.Extensions.AI.Evaluation.Reporting.Azure`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting.Azure) - Supports the `Microsoft.Extensions.AI.Evaluation.Reporting` library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. * [`Microsoft.Extensions.AI.Evaluation.Console`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Console) - A command line dotnet tool for generating reports and managing evaluation data. @@ -18,6 +20,7 @@ dotnet add package Microsoft.Extensions.AI.Evaluation dotnet add package Microsoft.Extensions.AI.Evaluation.Quality dotnet add package Microsoft.Extensions.AI.Evaluation.Safety dotnet add package Microsoft.Extensions.AI.Evaluation.Reporting +dotnet add package Microsoft.Extensions.AI.Evaluation.NLP ``` Or directly in the C# project file: @@ -28,6 +31,7 @@ Or directly in the C# project file: + ``` diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs index fafd8639b34..197d795e742 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs @@ -59,7 +59,13 @@ public static class AzureStorageReportingConfiguration /// A that persists s to Azure Storage /// and also uses Azure Storage to cache AI responses. /// -#pragma warning disable S107 // Methods should not have too many parameters + /// + /// Note that when is set to , the cache keys used + /// for the cached responses are not guaranteed to be stable across releases of the library. In other words, when + /// you update your code to reference a newer version of the library, it is possible that old cached responses + /// (persisted to the cache using older versions of the library) will no longer be used - instead new responses + /// will be fetched from the LLM and added to the cache for use in subsequent executions. + /// public static ReportingConfiguration Create( DataLakeDirectoryClient client, IEnumerable evaluators, @@ -70,7 +76,6 @@ public static ReportingConfiguration Create( string executionName = Defaults.DefaultExecutionName, Func? evaluationMetricInterpreter = null, IEnumerable? tags = null) -#pragma warning restore S107 { IEvaluationResponseCacheProvider? responseCacheProvider = chatConfiguration is not null && enableResponseCaching diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs index c40c4bafcf1..eedf15d75ba 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Globalization; using System.IO; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs index 5e83b456a0b..05f4f01eeaf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - #pragma warning disable CA1725 // CA1725: Parameter names should match base declaration. // All functions on 'IDistributedCache' use the parameter name 'token' in place of 'cancellationToken'. However, diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs index 6c6d1431a1a..3507bd3769e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs index 623485a8460..744a1098560 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs @@ -13,17 +13,11 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting; /// public sealed class ChatDetails { -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets the for the LLM chat conversation turns recorded in this /// object. /// public IList TurnDetails { get; set; } -#pragma warning restore CA2227 /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs index f6c8628dd54..2b1ad34e1ca 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs @@ -1,12 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; +using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI.Evaluation.Reporting; @@ -14,39 +10,13 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting; /// A class that records details related to a particular LLM chat conversation turn involved in the execution of a /// particular . /// -/// -/// The duration between the time when the request was sent to the LLM and the time when the response was received for -/// the chat conversation turn. -/// -/// -/// The model that was used in the creation of the response for the chat conversation turn. Can be -/// if this information was not available via . -/// -/// -/// Usage details for the chat conversation turn (including input and output token counts). Can be -/// if usage details were not available via . -/// -/// -/// The cache key for the cached model response for the chat conversation turn if response caching was enabled; -/// otherwise. -/// -/// -/// if response caching was enabled and the model response for the chat conversation turn was -/// retrieved from the cache; if response caching was enabled and the model response was not -/// retrieved from the cache; if response caching was disabled. -/// -public sealed class ChatTurnDetails( - TimeSpan latency, - string? model = null, - UsageDetails? usage = null, - string? cacheKey = null, - bool? cacheHit = null) +public sealed class ChatTurnDetails { /// /// Gets or sets the duration between the time when the request was sent to the LLM and the time when the response /// was received for the chat conversation turn. /// - public TimeSpan Latency { get; set; } = latency; + public TimeSpan Latency { get; set; } /// /// Gets or sets the model that was used in the creation of the response for the chat conversation turn. @@ -54,7 +24,16 @@ public sealed class ChatTurnDetails( /// /// Returns if this information was not available via . /// - public string? Model { get; set; } = model; + public string? Model { get; set; } + + /// + /// Gets or sets the name of the provider for the model identified by . + /// + /// + /// Can be if this information was not available via the + /// for the . + /// + public string? ModelProvider { get; set; } /// /// Gets or sets usage details for the chat conversation turn (including input and output token counts). @@ -62,7 +41,7 @@ public sealed class ChatTurnDetails( /// /// Returns if usage details were not available via . /// - public UsageDetails? Usage { get; set; } = usage; + public UsageDetails? Usage { get; set; } /// /// Gets or sets the cache key for the cached model response for the chat conversation turn. @@ -70,7 +49,7 @@ public sealed class ChatTurnDetails( /// /// Returns if response caching was disabled. /// - public string? CacheKey { get; set; } = cacheKey; + public string? CacheKey { get; set; } /// /// Gets or sets a value indicating whether the model response was retrieved from the cache. @@ -78,5 +57,85 @@ public sealed class ChatTurnDetails( /// /// Returns if response caching was disabled. /// - public bool? CacheHit { get; set; } = cacheHit; + public bool? CacheHit { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The duration between the time when the request was sent to the LLM and the time when the response was received + /// for the chat conversation turn. + /// + /// + /// The model that was used in the creation of the response for the chat conversation turn. Can be + /// if this information was not available via . + /// + /// + /// Usage details for the chat conversation turn (including input and output token counts). Can be + /// if usage details were not available via . + /// + /// + /// The cache key for the cached model response for the chat conversation turn if response caching was enabled; + /// otherwise. + /// + /// + /// if response caching was enabled and the model response for the chat conversation turn + /// was retrieved from the cache; if response caching was enabled and the model response + /// was not retrieved from the cache; if response caching was disabled. + /// + public ChatTurnDetails( + TimeSpan latency, + string? model = null, + UsageDetails? usage = null, + string? cacheKey = null, + bool? cacheHit = null) + : this(latency, model, modelProvider: null, usage, cacheKey, cacheHit) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The duration between the time when the request was sent to the LLM and the time when the response was received + /// for the chat conversation turn. + /// + /// + /// The model that was used in the creation of the response for the chat conversation turn. Can be + /// if this information was not available via . + /// + /// + /// The name of the provider for the model identified by . Can be + /// if this information was not available via the for the + /// . + /// + /// + /// Usage details for the chat conversation turn (including input and output token counts). Can be + /// if usage details were not available via . + /// + /// + /// The cache key for the cached model response for the chat conversation turn if response caching was enabled; + /// otherwise. + /// + /// + /// if response caching was enabled and the model response for the chat conversation turn + /// was retrieved from the cache; if response caching was enabled and the model response + /// was not retrieved from the cache; if response caching was disabled. + /// + [JsonConstructor] + public ChatTurnDetails( + TimeSpan latency, + string? model, + string? modelProvider, + UsageDetails? usage = null, + string? cacheKey = null, + bool? cacheHit = null) + { + Latency = latency; + Model = model; + ModelProvider = modelProvider; + Usage = usage; + CacheKey = cacheKey; + CacheHit = cacheHit; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs index 1fb8b6c5ec9..146d3ae999b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs index 97c1fdca15e..70b6492a17c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs @@ -17,7 +17,7 @@ public interface IEvaluationReportWriter /// Writes a report containing all the s present in the supplied /// s. /// - /// An enumeration of s. + /// A collection of run results from which to generate the report. /// A that can cancel the operation. /// A that represents the asynchronous operation. ValueTask WriteReportAsync( diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs index 3a8c2af1ce2..a94282ad7f3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -13,11 +11,9 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; internal static partial class JsonUtilities { - [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Default matches the generated source naming convention.")] internal static class Default { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: true); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: true); internal static JsonTypeInfo DatasetTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); @@ -25,8 +21,7 @@ internal static class Default internal static class Compact { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: false); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: false); internal static JsonTypeInfo DatasetTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); @@ -39,7 +34,6 @@ private static JsonSerializerOptions CreateJsonSerializerOptions(bool writeInden var options = new JsonSerializerOptions(JsonContext.Default.Options) { WriteIndented = writeIndented, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); options.MakeReadOnly(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj index a06db14fffd..8ee31bc2b1a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj @@ -11,19 +11,20 @@ A library that contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.AI.Evaluation.Reporting - - $(NoWarn);EA0002 AIEval - preview + normal true - false - 66 - 0 + n/a + n/a + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json new file mode 100644 index 00000000000..c5b0186f0bd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json @@ -0,0 +1,442 @@ +{ + "Name": "Microsoft.Extensions.AI.Evaluation.Reporting, Version=9.9.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails.ChatDetails(System.Collections.Generic.IList turnDetails);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails.ChatDetails(System.Collections.Generic.IEnumerable turnDetails);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails.ChatDetails(params Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails[] turnDetails);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails.TurnDetails { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetailsExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetailsExtensions.AddTurnDetails(this Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails chatDetails, System.Collections.Generic.IEnumerable turnDetails);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetailsExtensions.AddTurnDetails(this Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails chatDetails, params Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails[] turnDetails);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.ChatTurnDetails(System.TimeSpan latency, string? model = null, Microsoft.Extensions.AI.UsageDetails? usage = null, string? cacheKey = null, bool? cacheHit = null);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.ChatTurnDetails(System.TimeSpan latency, string? model, string? modelProvider, Microsoft.Extensions.AI.UsageDetails? usage = null, string? cacheKey = null, bool? cacheHit = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.CacheHit { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.CacheKey { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.TimeSpan Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.Latency { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.Model { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.ModelProvider { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.Usage { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.Reporting.Defaults", + "Stage": "Stable", + "Fields": [ + { + "Member": "const string Microsoft.Extensions.AI.Evaluation.Reporting.Defaults.DefaultExecutionName", + "Stage": "Stable", + "Value": "Default" + }, + { + "Member": "const string Microsoft.Extensions.AI.Evaluation.Reporting.Defaults.DefaultIterationName", + "Stage": "Stable", + "Value": "1" + } + ], + "Properties": [ + { + "Member": "static System.TimeSpan Microsoft.Extensions.AI.Evaluation.Reporting.Defaults.DefaultTimeToLiveForCacheEntries { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedReportingConfiguration", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedReportingConfiguration.Create(string storageRootPath, System.Collections.Generic.IEnumerable evaluators, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, bool enableResponseCaching = true, System.TimeSpan? timeToLiveForCacheEntries = null, System.Collections.Generic.IEnumerable? cachingKeys = null, string executionName = \"Default\", System.Func? evaluationMetricInterpreter = null, System.Collections.Generic.IEnumerable? tags = null);", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResponseCacheProvider : Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResponseCacheProvider.DiskBasedResponseCacheProvider(string storageRootPath, System.TimeSpan? timeToLiveForCacheEntries = null);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResponseCacheProvider.DeleteExpiredCacheEntriesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResponseCacheProvider.GetCacheAsync(string scenarioName, string iterationName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResponseCacheProvider.ResetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore : Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.DiskBasedResultStore(string storageRootPath);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.DeleteResultsAsync(string? executionName = null, string? scenarioName = null, string? iterationName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.GetIterationNamesAsync(string executionName, string scenarioName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.GetLatestExecutionNamesAsync(int? count = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.GetScenarioNamesAsync(string executionName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.ReadResultsAsync(string? executionName = null, string? scenarioName = null, string? iterationName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Storage.DiskBasedResultStore.WriteResultsAsync(System.Collections.Generic.IEnumerable results, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Html.HtmlReportWriter : Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationReportWriter", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Html.HtmlReportWriter.HtmlReportWriter(string reportFilePath);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Html.HtmlReportWriter.WriteReportAsync(System.Collections.Generic.IEnumerable scenarioRunResults, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationReportWriter", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationReportWriter.WriteReportAsync(System.Collections.Generic.IEnumerable scenarioRunResults, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider.DeleteExpiredCacheEntriesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider.GetCacheAsync(string scenarioName, string iterationName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider.ResetAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore.DeleteResultsAsync(string? executionName = null, string? scenarioName = null, string? iterationName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore.GetIterationNamesAsync(string executionName, string scenarioName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore.GetLatestExecutionNamesAsync(int? count = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore.GetScenarioNamesAsync(string executionName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore.ReadResultsAsync(string? executionName = null, string? scenarioName = null, string? iterationName = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore.WriteResultsAsync(System.Collections.Generic.IEnumerable results, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Json.JsonReportWriter : Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationReportWriter", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Json.JsonReportWriter.JsonReportWriter(string reportFilePath);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Json.JsonReportWriter.WriteReportAsync(System.Collections.Generic.IEnumerable scenarioRunResults, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.ReportingConfiguration(System.Collections.Generic.IEnumerable evaluators, Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore resultStore, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider? responseCacheProvider = null, System.Collections.Generic.IEnumerable? cachingKeys = null, string executionName = \"Default\", System.Func? evaluationMetricInterpreter = null, System.Collections.Generic.IEnumerable? tags = null);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.CreateScenarioRunAsync(string scenarioName, string iterationName = \"1\", System.Collections.Generic.IEnumerable? additionalCachingKeys = null, System.Collections.Generic.IEnumerable? additionalTags = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.CachingKeys { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.ChatConfiguration? Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.ChatConfiguration { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Func? Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.EvaluationMetricInterpreter { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.Evaluators { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.ExecutionName { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResponseCacheProvider? Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.ResponseCacheProvider { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.IEvaluationResultStore Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.ResultStore { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList? Microsoft.Extensions.AI.Evaluation.Reporting.ReportingConfiguration.Tags { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun : System.IAsyncDisposable", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun.DisposeAsync();", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.ChatConfiguration? Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun.ChatConfiguration { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun.ExecutionName { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun.IterationName { get; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun.ScenarioName { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun scenarioRun, string modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun scenarioRun, string userRequest, string modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun scenarioRun, Microsoft.Extensions.AI.ChatMessage modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun scenarioRun, Microsoft.Extensions.AI.ChatResponse modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun scenarioRun, Microsoft.Extensions.AI.ChatMessage userRequest, Microsoft.Extensions.AI.ChatMessage modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRun scenarioRun, Microsoft.Extensions.AI.ChatMessage userRequest, Microsoft.Extensions.AI.ChatResponse modelResponse, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.ScenarioRunResult(string scenarioName, string iterationName, string executionName, System.DateTime creationTime, System.Collections.Generic.IList messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.EvaluationResult evaluationResult, Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails? chatDetails = null, System.Collections.Generic.IList? tags = null, int? formatVersion = null);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.ScenarioRunResult(string scenarioName, string iterationName, string executionName, System.DateTime creationTime, System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.EvaluationResult evaluationResult, Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails? chatDetails = null, System.Collections.Generic.IEnumerable? tags = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails? Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.ChatDetails { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.DateTime Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.CreationTime { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationResult Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.EvaluationResult { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.ExecutionName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.FormatVersion { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.IterationName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.Messages { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatResponse Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.ModelResponse { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.ScenarioName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult.Tags { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResultExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static bool Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResultExtensions.ContainsDiagnostics(this Microsoft.Extensions.AI.Evaluation.Reporting.ScenarioRunResult result, System.Func? predicate = null);", + "Stage": "Stable" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/README.md b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/README.md index c21e2a299ad..dfc15311489 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/README.md @@ -5,6 +5,8 @@ * [`Microsoft.Extensions.AI.Evaluation`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation) - Defines core abstractions and types for supporting evaluation. * [`Microsoft.Extensions.AI.Evaluation.Quality`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Quality) - Contains evaluators that can be used to evaluate the quality of AI responses in your projects including Relevance, Truth, Completeness, Fluency, Coherence, Retrieval, Equivalence and Groundedness. * [`Microsoft.Extensions.AI.Evaluation.Safety`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Safety) - Contains a set of evaluators that are built atop the Azure AI Foundry Evaluation service that can be used to evaluate the content safety of AI responses in your projects including Protected Material, Groundedness Pro, Ungrounded Attributes, Hate and Unfairness, Self Harm, Violence, Sexual, Code Vulnerability and Indirect Attack. +* [`Microsoft.Extensions.AI.Evaluation.NLP`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.NLP) - Contains a set of evaluators that implement common algorithms for evaluating machine translation and natural +language processing tasks. Evaluators currently include BLEU, GLEU and F1 scores. * [`Microsoft.Extensions.AI.Evaluation.Reporting`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting) - Contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. * [`Microsoft.Extensions.AI.Evaluation.Reporting.Azure`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting.Azure) - Supports the `Microsoft.Extensions.AI.Evaluation.Reporting` library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. * [`Microsoft.Extensions.AI.Evaluation.Console`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Console) - A command line dotnet tool for generating reports and managing evaluation data. @@ -18,6 +20,7 @@ dotnet add package Microsoft.Extensions.AI.Evaluation dotnet add package Microsoft.Extensions.AI.Evaluation.Quality dotnet add package Microsoft.Extensions.AI.Evaluation.Safety dotnet add package Microsoft.Extensions.AI.Evaluation.Reporting +dotnet add package Microsoft.Extensions.AI.Evaluation.NLP ``` Or directly in the C# project file: @@ -28,6 +31,7 @@ Or directly in the C# project file: + ``` diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs index 130586de930..e74ca096f2c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs @@ -132,7 +132,6 @@ public sealed class ReportingConfiguration /// A optional set of text tags applicable to all s created using this /// . /// -#pragma warning disable S107 // Methods should not have too many parameters public ReportingConfiguration( IEnumerable evaluators, IEvaluationResultStore resultStore, @@ -142,7 +141,6 @@ public ReportingConfiguration( string executionName = Defaults.DefaultExecutionName, Func? evaluationMetricInterpreter = null, IEnumerable? tags = null) -#pragma warning restore S107 { Evaluators = [.. evaluators]; ResultStore = resultStore; @@ -274,12 +272,6 @@ private static IEnumerable GetCachingKeysForChatClient(IChatClient chatC yield return providerName!; } - Uri? providerUri = metadata?.ProviderUri; - if (providerUri is not null) - { - yield return providerUri.AbsoluteUri; - } - string? modelId = metadata?.DefaultModelId; if (!string.IsNullOrWhiteSpace(modelId)) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs index 79baf3be88c..c983cb87a03 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs @@ -1,21 +1,21 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Extensions.Caching.Distributed; namespace Microsoft.Extensions.AI.Evaluation.Reporting; internal sealed class ResponseCachingChatClient : DistributedCachingChatClient { - private readonly IReadOnlyList _cachingKeys; private readonly ChatDetails _chatDetails; private readonly ConcurrentDictionary _stopWatches; + private readonly ChatClientMetadata? _metadata; internal ResponseCachingChatClient( IChatClient originalChatClient, @@ -24,9 +24,11 @@ internal ResponseCachingChatClient( ChatDetails chatDetails) : base(originalChatClient, cache) { - _cachingKeys = [.. cachingKeys]; + CacheKeyAdditionalValues = [.. cachingKeys]; + _chatDetails = chatDetails; _stopWatches = new ConcurrentDictionary(); + _metadata = this.GetService(); } protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) @@ -43,10 +45,14 @@ internal ResponseCachingChatClient( { stopwatch.Stop(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage, cacheKey: key, cacheHit: true)); @@ -73,10 +79,14 @@ internal ResponseCachingChatClient( stopwatch.Stop(); ChatResponse response = updates.ToChatResponse(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage, cacheKey: key, cacheHit: true)); @@ -93,10 +103,14 @@ protected override async Task WriteCacheAsync(string key, ChatResponse value, Ca { stopwatch.Stop(); + string? model = value.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: value.ModelId, + model, + modelProvider, usage: value.Usage, cacheKey: key, cacheHit: false)); @@ -115,16 +129,17 @@ protected override async Task WriteCacheStreamingAsync( stopwatch.Stop(); ChatResponse response = value.ToChatResponse(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage, cacheKey: key, cacheHit: false)); } } - - protected override string GetCacheKey(IEnumerable messages, ChatOptions? options, params ReadOnlySpan additionalValues) - => base.GetCacheKey(messages, options, [.. additionalValues, .. _cachingKeys]); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs index 5fa46e7e4ec..80ca411edb3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs @@ -100,7 +100,6 @@ public sealed class ScenarioRun : IAsyncDisposable private ScenarioRunResult? _result; -#pragma warning disable S107 // Methods should not have too many parameters internal ScenarioRun( string scenarioName, string iterationName, @@ -111,7 +110,6 @@ internal ScenarioRun( Func? evaluationMetricInterpreter = null, ChatDetails? chatDetails = null, IEnumerable? tags = null) -#pragma warning restore { ScenarioName = scenarioName; IterationName = iterationName; @@ -150,10 +148,8 @@ public async ValueTask EvaluateAsync( { if (_result is not null) { -#pragma warning disable S103 // Lines should not be too long throw new InvalidOperationException( $"The {nameof(ScenarioRun)} with {nameof(ScenarioName)}: {ScenarioName}, {nameof(IterationName)}: {IterationName} and {nameof(ExecutionName)}: {ExecutionName} has already been evaluated. Do not call {nameof(EvaluateAsync)} more than once on a given {nameof(ScenarioRun)}."); -#pragma warning restore S103 } EvaluationResult evaluationResult = diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs index af2c1d08a4c..e851a48dfdd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -126,17 +121,11 @@ public ScenarioRunResult( /// public DateTime CreationTime { get; set; } = creationTime; -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets the conversation history including the request that produced the being /// evaluated in this . /// public IList Messages { get; set; } = messages; -#pragma warning restore CA2227 /// /// Gets or sets the response being evaluated in this . @@ -165,16 +154,10 @@ public ScenarioRunResult( /// public ChatDetails? ChatDetails { get; set; } = chatDetails; -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets a set of text tags applicable to this . /// public IList? Tags { get; set; } = tags; -#pragma warning restore CA2227 /// /// Gets or sets the version of the format used to persist the current . diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs index 8ef344ab982..875d6a6c26a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs @@ -6,17 +6,20 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; namespace Microsoft.Extensions.AI.Evaluation.Reporting; internal sealed class SimpleChatClient : DelegatingChatClient { private readonly ChatDetails _chatDetails; + private readonly ChatClientMetadata? _metadata; internal SimpleChatClient(IChatClient originalChatClient, ChatDetails chatDetails) : base(originalChatClient) { _chatDetails = chatDetails; + _metadata = this.GetService(); } public async override Task GetResponseAsync( @@ -37,10 +40,14 @@ public async override Task GetResponseAsync( if (response is not null) { + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage)); } } @@ -74,10 +81,14 @@ public override async IAsyncEnumerable GetStreamingResponseA if (updates is not null) { ChatResponse response = updates.ToChatResponse(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage)); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs index e967fdd1db9..ad28389aa30 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs @@ -59,7 +59,13 @@ public static class DiskBasedReportingConfiguration /// A that persists s to disk and also uses the /// disk to cache AI responses. /// -#pragma warning disable S107 // Methods should not have too many parameters + /// + /// Note that when is set to , the cache keys used + /// for the cached responses are not guaranteed to be stable across releases of the library. In other words, when + /// you update your code to reference a newer version of the library, it is possible that old cached responses + /// (persisted to the cache using older versions of the library) will no longer be used - instead new responses + /// will be fetched from the LLM and added to the cache for use in subsequent executions. + /// public static ReportingConfiguration Create( string storageRootPath, IEnumerable evaluators, @@ -70,7 +76,6 @@ public static ReportingConfiguration Create( string executionName = Defaults.DefaultExecutionName, Func? evaluationMetricInterpreter = null, IEnumerable? tags = null) -#pragma warning restore S107 { storageRootPath = Path.GetFullPath(storageRootPath); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs index 6d5000c0395..12ac20923ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Globalization; using System.IO; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs index d0a107d8710..3d2e53a9ff4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - #pragma warning disable CA1725 // CA1725: Parameter names should match base declaration. // All functions on 'IDistributedCache' use the parameter name 'token' in place of 'cancellationToken'. However, diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs index 8b60fe5a272..cfbdb207c0c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs index 4662857ec59..72bed04f2cd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs @@ -177,11 +177,9 @@ public ValueTask DeleteResultsAsync( } /// -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously. public async IAsyncEnumerable GetLatestExecutionNamesAsync( int? count = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) -#pragma warning restore CS1998 { if (count.HasValue && count <= 0) { @@ -204,11 +202,9 @@ public async IAsyncEnumerable GetLatestExecutionNamesAsync( } /// -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously. public async IAsyncEnumerable GetScenarioNamesAsync( string executionName, [EnumeratorCancellation] CancellationToken cancellationToken = default) -#pragma warning restore CS1998 { IEnumerable executionDirs = EnumerateExecutionDirs(executionName, cancellationToken); @@ -224,12 +220,10 @@ public async IAsyncEnumerable GetScenarioNamesAsync( } /// -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously. public async IAsyncEnumerable GetIterationNamesAsync( string executionName, string scenarioName, [EnumeratorCancellation] CancellationToken cancellationToken = default) -#pragma warning restore CS1998 { IEnumerable resultFiles = EnumerateResultFiles(executionName, scenarioName, cancellationToken: cancellationToken); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/vss-extension.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/vss-extension.json index 48b88200efe..c596e90dfa2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/vss-extension.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report/vss-extension.json @@ -7,7 +7,7 @@ "description": "Display an AI Evaluation report tab in Azure DevOps build results", "public": false, "categories": ["Azure Pipelines"], - "tags": ["Preview"], + "tags": [], "targets": [ { "id": "Microsoft.VisualStudio.Services" diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx index b38c691bbb3..6d73c8220e1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/App.tsx @@ -52,7 +52,7 @@ export const App = () => { const classes = useStyles(); const { dataset, scoreSummary, selectedTags, clearFilters, searchValue, setSearchValue } = useReportContext(); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const { renderMarkdown, setRenderMarkdown } = useReportContext(); + const { renderMarkdown, setRenderMarkdown, prettifyJson, setPrettifyJson } = useReportContext(); const { globalTags, filterableTags } = categorizeAndSortTags(dataset, scoreSummary.primaryResult.executionName); const toggleSettings = () => setIsSettingsOpen(!isSettingsOpen); @@ -127,6 +127,11 @@ export const App = () => { onChange={(_ev, data) => setRenderMarkdown(data.checked)} label={Render markdown for conversations} /> + setPrettifyJson(data.checked)} + label={Pretty print JSON content} + /> diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx index b05dcca1eae..545f181220d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx @@ -15,7 +15,8 @@ export const ChatDetailsSection = ({ chatDetails }: { chatDetails: ChatDetails; const hasCacheKey = chatDetails.turnDetails.some(turn => turn.cacheKey !== undefined); const hasCacheStatus = chatDetails.turnDetails.some(turn => turn.cacheHit !== undefined); - const hasModelInfo = chatDetails.turnDetails.some(turn => turn.model !== undefined); + const hasModel = chatDetails.turnDetails.some(turn => turn.model !== undefined); + const hasModelProvider = chatDetails.turnDetails.some(turn => turn.modelProvider !== undefined); const hasInputTokens = chatDetails.turnDetails.some(turn => turn.usage?.inputTokenCount !== undefined); const hasOutputTokens = chatDetails.turnDetails.some(turn => turn.usage?.outputTokenCount !== undefined); const hasTotalTokens = chatDetails.turnDetails.some(turn => turn.usage?.totalTokenCount !== undefined); @@ -42,13 +43,14 @@ export const ChatDetailsSection = ({ chatDetails }: { chatDetails: ChatDetails; {isExpanded && (
- +
{hasCacheKey && Cache Key} {hasCacheStatus && Cache Status} Latency (s) - {hasModelInfo && Model Used} + {hasModel && Model} + {hasModelProvider && Model Provider} {hasInputTokens && Input Tokens} {hasOutputTokens && Output Tokens} {hasTotalTokens && Total Tokens} @@ -92,7 +94,8 @@ export const ChatDetailsSection = ({ chatDetails }: { chatDetails: ChatDetails; )} {turn.latency.toFixed(2)} - {hasModelInfo && {turn.model || '-'}} + {hasModel && {turn.model || '-'}} + {hasModelProvider && {turn.modelProvider || '-'}} {hasInputTokens && {turn.usage?.inputTokenCount || '-'}} {hasOutputTokens && {turn.usage?.outputTokenCount || '-'}} {hasTotalTokens && {turn.usage?.totalTokenCount || '-'}} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ConversationDetails.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ConversationDetails.tsx index 6acf38673de..9cf40a7a574 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ConversationDetails.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ConversationDetails.tsx @@ -18,7 +18,7 @@ export const ConversationDetails = ({ messages, model, usage, selectedMetric }: }) => { const classes = useStyles(); const [isExpanded, setIsExpanded] = useState(true); - const { renderMarkdown } = useReportContext(); + const { renderMarkdown, prettifyJson } = useReportContext(); const isUserSide = (role: string) => role.toLowerCase() === 'user' || role.toLowerCase() === 'system'; @@ -29,14 +29,33 @@ export const ConversationDetails = ({ messages, model, usage, selectedMetric }: usage?.totalTokenCount && `Total Tokens: ${usage.totalTokenCount}`, ].filter(Boolean).join(' • '); + const isValidJson = (text: string): { isValid: boolean; parsedJson?: any } => { + try { + const parsedJson = JSON.parse(text.trim()); + return { isValid: true, parsedJson }; + } catch { + return { isValid: false }; + } + }; + const renderContent = (content: AIContent) => { if (isTextContent(content)) { - return renderMarkdown ? - {content.text} : -
{content.text}
; + const { isValid, parsedJson } = isValidJson(content.text); + if (isValid) { + const jsonContent = JSON.stringify(parsedJson, null, prettifyJson ? 2 : 0); + return
{jsonContent}
; + } else { + return renderMarkdown ? + {content.text} : +
{content.text}
; + } } else if (isImageContent(content)) { const imageUrl = (content as UriContent).uri || (content as DataContent).uri; return Content; + } else { + // For any other content type, display the serialized JSON + const jsonContent = JSON.stringify(content, null, prettifyJson ? 2 : 0); + return
{jsonContent}
; } }; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts index 2b6d84b6086..4d52836975c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts @@ -39,6 +39,7 @@ type ChatDetails = { type ChatTurnDetails = { latency: number; model?: string; + modelProvider?: string; usage?: UsageDetails; cacheKey?: string; cacheHit?: boolean; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx index 5f770298bf4..18476474697 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx @@ -242,9 +242,14 @@ export const MetricDisplay = ({ metric }: { metric: MetricWithNoValue | NumericM const classes = useCardStyles(); const { fg, bg } = useCardColors(metric.interpretation); - const pillClass = mergeClasses( - bg, - classes.metricPill, + const pillClass = mergeClasses(bg, classes.metricPill); + const valueClass = mergeClasses(fg, classes.metricValueText); + + return ( + +
+ {metricValue} +
+
); - return (
{metricValue}
); }; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ReportContext.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ReportContext.tsx index 64a1e4a3c20..74a645c70b7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ReportContext.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ReportContext.tsx @@ -11,6 +11,8 @@ export type ReportContextType = { selectScenarioLevel: (key: string) => void, renderMarkdown: boolean, setRenderMarkdown: (renderMarkdown: boolean) => void, + prettifyJson: boolean, + setPrettifyJson: (prettifyJson: boolean) => void, searchValue: string, setSearchValue: (searchValue: string) => void, selectedTags: string[], @@ -38,6 +40,10 @@ const defaultReportContext = createContext({ setRenderMarkdown: (_renderMarkdown: boolean) => { throw new Error("setRenderMarkdown function not implemented"); }, + prettifyJson: true, + setPrettifyJson: (_prettifyJson: boolean) => { + throw new Error("setPrettifyJson function not implemented"); + }, searchValue: '', setSearchValue: (_searchValue: string | undefined) => { throw new Error("setSearchValue function not implemented"); }, selectedTags: [], @@ -65,6 +71,7 @@ export const useReportContext = () => { const useProvideReportContext = (dataset: Dataset, scoreSummary: ScoreSummary): ReportContextType => { const [selectedScenarioLevel, setSelectedScenarioLevel] = useState(undefined); const [renderMarkdown, setRenderMarkdown] = useState(true); + const [prettifyJson, setPrettifyJson] = useState(true); const [selectedTags, setSelectedTags] = useState([]); const [searchValue, setSearchValue] = useState(""); @@ -114,7 +121,7 @@ const useProvideReportContext = (dataset: Dataset, scoreSummary: ScoreSummary): } return null; - }; + }; return srch(node); } @@ -126,6 +133,8 @@ const useProvideReportContext = (dataset: Dataset, scoreSummary: ScoreSummary): selectScenarioLevel, renderMarkdown, setRenderMarkdown, + prettifyJson, + setPrettifyJson, searchValue, setSearchValue, selectedTags, @@ -133,4 +142,4 @@ const useProvideReportContext = (dataset: Dataset, scoreSummary: ScoreSummary): clearFilters, filterTree, }; -}; \ No newline at end of file +}; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json index 6e76e88aa03..1282ff91ef4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json @@ -33,7 +33,7 @@ "tfx-cli": "^0.21.0", "typescript": "^5.5.3", "typescript-eslint": "^8.27.0", - "vite": "^6.3.4", + "vite": "^6.4.1", "vite-plugin-singlefile": "^2.0.2" } }, @@ -747,10 +747,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz", - "integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -786,10 +787,11 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -800,19 +802,21 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -856,12 +860,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -869,35 +877,25 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", @@ -3114,7 +3112,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/mdast": { "version": "4.0.4", @@ -3298,10 +3297,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3398,10 +3398,11 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3467,26 +3468,6 @@ "integrity": "sha512-OKap/l8oElrynRMEbtwubVW5M5G16LKz9Wxo0DYBmce595lao0EbmD4O82j7qo9yukVWMTDriWvgrbt6yPnb9A==", "dev": true }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/archiver": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/archiver/-/archiver-2.0.3.tgz", @@ -3743,10 +3724,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3989,16 +3971,21 @@ } }, "node_modules/clipboardy": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-1.2.3.tgz", - "integrity": "sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", + "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", "dev": true, + "license": "MIT", "dependencies": { - "arch": "^2.1.0", - "execa": "^0.8.0" + "execa": "^8.0.1", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/color-convert": { @@ -4551,19 +4538,20 @@ } }, "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4574,9 +4562,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4632,10 +4620,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4648,10 +4637,11 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4660,14 +4650,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4693,6 +4684,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4728,89 +4720,29 @@ } }, "node_modules/execa": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz", - "integrity": "sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==", - "dev": true, - "dependencies": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/execa/node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "node_modules/execa/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/execa/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, + "license": "MIT", "dependencies": { - "shebang-regex": "^1.0.0" + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/execa/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" + "node": ">=16.17" }, - "bin": { - "which": "bin/which" + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5093,12 +5025,16 @@ } }, "node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-symbol-description": { @@ -5154,10 +5090,11 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -5363,6 +5300,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5626,6 +5573,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5698,6 +5661,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5792,12 +5774,16 @@ } }, "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-string": { @@ -5891,6 +5877,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6292,6 +6310,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6735,6 +6760,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -6837,24 +6875,32 @@ } }, "node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, + "license": "MIT", "dependencies": { - "path-key": "^2.0.0" + "path-key": "^4.0.0" }, "engines": { - "node": ">=4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm-run-path/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/object-assign": { @@ -6945,6 +6991,22 @@ "node": ">=0.4.8" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6971,15 +7033,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6997,15 +7050,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7257,12 +7301,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8124,13 +8162,17 @@ "node": ">=8" } }, - "node_modules/strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -8190,6 +8232,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tabster": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.5.4.tgz", @@ -8219,15 +8274,16 @@ } }, "node_modules/tfx-cli": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tfx-cli/-/tfx-cli-0.21.1.tgz", - "integrity": "sha512-tQD3XqynSmlR1Teawp6ogUNSXLXsmRfSNSRXXcQ/FePGRp09avIGsZfiF8F03NwzzhMU2HSqbGThY2vc94OBEw==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/tfx-cli/-/tfx-cli-0.21.3.tgz", + "integrity": "sha512-EygWAziQ8Mdh9k38zkvNs47PVgU6Mb0QmdyHClOXqJ2u+ZKtREMKt1tYgaSHEwXV2F64ei6+CykFXfzAz7dJrg==", "dev": true, + "license": "MIT", "dependencies": { "app-root-path": "1.0.0", "archiver": "2.0.3", "azure-devops-node-api": "^14.0.0", - "clipboardy": "~1.2.3", + "clipboardy": "^4.0.0", "colors": "~1.3.0", "glob": "7.1.2", "jju": "^1.4.0", @@ -8241,7 +8297,7 @@ "prompt": "^1.3.0", "read": "^1.0.6", "shelljs": "^0.8.5", - "tmp": "0.0.26", + "tmp": "^0.2.4", "tracer": "0.7.4", "util.promisify": "^1.0.0", "uuid": "^3.0.1", @@ -8329,15 +8385,13 @@ } }, "node_modules/tmp": { - "version": "0.0.26", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.26.tgz", - "integrity": "sha512-XYEM7aFncfdEdU4/3jUG2edvFAryxtKbahJXTv8WK34MoOmexbbyNyneT3nY8yPVD3h0J1b5fL6kqlDoyuebQQ==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.0" - }, + "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=14.14" } }, "node_modules/to-buffer": { @@ -8774,10 +8828,11 @@ } }, "node_modules/validator": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", - "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha1-BU6SOBCVOKG/Rq4+EpCEWmT6IYY=", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -8809,9 +8864,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vite/-/vite-6.4.1.tgz", + "integrity": "sha1-r74UUYzdaIfiQKSwIhq20M5zP5Y=", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json index 0e32f4ae6f7..7cc3384d10b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json @@ -34,7 +34,7 @@ "tfx-cli": "^0.21.0", "typescript": "^5.5.3", "typescript-eslint": "^8.27.0", - "vite": "^6.3.4", + "vite": "^6.4.1", "vite-plugin-singlefile": "^2.0.2" } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs index e4788bcdc81..0935e5a1f58 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs @@ -30,7 +30,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; /// , and /// . /// -#pragma warning disable SA1118 // Parameter should not span multiple lines +#pragma warning disable SA1118 // Parameter should not span multiple lines. public class ContentHarmEvaluator(IDictionary? metricNames = null) : ContentSafetyEvaluator( contentSafetyServiceAnnotationTask: "content harm", diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs index 69b47670935..01618997069 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs @@ -1,26 +1,21 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; internal sealed class ContentSafetyChatClient : IChatClient { - private const string Moniker = "Azure AI Foundry Evaluation"; - private readonly ContentSafetyService _service; private readonly IChatClient? _originalChatClient; private readonly ChatClientMetadata _metadata; @@ -33,28 +28,23 @@ public ContentSafetyChatClient( _originalChatClient = originalChatClient; ChatClientMetadata? originalMetadata = _originalChatClient?.GetService(); - - string providerName = - $"{Moniker} (" + - $"Subscription: {contentSafetyServiceConfiguration.SubscriptionId}, " + - $"Resource Group: {contentSafetyServiceConfiguration.ResourceGroupName}, " + - $"Project: {contentSafetyServiceConfiguration.ProjectName})"; - - if (originalMetadata?.ProviderName is string originalProviderName && - !string.IsNullOrWhiteSpace(originalProviderName)) + if (originalMetadata is null) { - providerName = $"{originalProviderName}; {providerName}"; + _metadata = + new ChatClientMetadata( + providerName: ModelInfo.KnownModelProviders.AzureAIFoundry, + defaultModelId: ModelInfo.KnownModels.AzureAIFoundryEvaluation); } - - string modelId = Moniker; - - if (originalMetadata?.DefaultModelId is string originalModelId && - !string.IsNullOrWhiteSpace(originalModelId)) + else { - modelId = $"{originalModelId}; {modelId}"; + // If we are wrapping an existing client, prefer its metadata. Preserving the metadata of the inner client + // (when available) ensures that the contained information remains available for requests that are + // delegated to the inner client and serviced by an LLM endpoint. For requests that are not delegated, the + // ChatResponse.ModelId for the produced response would be sufficient to identify that the model used was + // the finetuned model provided by the Azure AI Foundry Evaluation service (even though the outer client's + // metadata will not reflect this). + _metadata = originalMetadata; } - - _metadata = new ChatClientMetadata(providerName, originalMetadata?.ProviderUri, modelId); } public async Task GetResponseAsync( @@ -76,20 +66,18 @@ await _service.AnnotateAsync( return new ChatResponse(new ChatMessage(ChatRole.Assistant, annotationResult)) { - ModelId = Moniker + ModelId = ModelInfo.KnownModels.AzureAIFoundryEvaluation }; } - else if (_originalChatClient is not null) + else { + ValidateOriginalChatClientNotNull(); + return await _originalChatClient.GetResponseAsync( messages, options, cancellationToken).ConfigureAwait(false); } - else - { - throw new NotSupportedException(); - } } public async IAsyncEnumerable GetStreamingResponseAsync( @@ -111,11 +99,13 @@ await _service.AnnotateAsync( yield return new ChatResponseUpdate(ChatRole.Assistant, annotationResult) { - ModelId = Moniker + ModelId = ModelInfo.KnownModels.AzureAIFoundryEvaluation }; } - else if (_originalChatClient is not null) + else { + ValidateOriginalChatClientNotNull(); + await foreach (var update in _originalChatClient.GetStreamingResponseAsync( messages, @@ -125,10 +115,6 @@ await _service.AnnotateAsync( yield return update; } } - else - { - throw new NotSupportedException(); - } } public object? GetService(Type serviceType, object? serviceKey = null) @@ -171,4 +157,20 @@ private static void ValidateSingleMessage(IEnumerable messages) Throw.ArgumentException(nameof(messages), ErrorMessage); } } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] // Inline if possible. + [MemberNotNull(nameof(_originalChatClient))] + private void ValidateOriginalChatClientNotNull([CallerMemberName] string? callerMemberName = null) + { + if (_originalChatClient is null) + { + string errorMessage = + $""" + Failed to invoke '{nameof(IChatClient)}.{callerMemberName}()'. + Did you forget to specify the argument value for 'originalChatClient' or 'originalChatConfiguration' when calling '{nameof(ContentSafetyServiceConfiguration)}.ToChatConfiguration()'? + """; + + Throw.ArgumentNullException(nameof(_originalChatClient), errorMessage); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs index 741bca9f790..c59f585b4ad 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs @@ -1,15 +1,27 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; -internal sealed class ContentSafetyChatOptions(string annotationTask, string evaluatorName) : ChatOptions +internal sealed class ContentSafetyChatOptions : ChatOptions { - internal string AnnotationTask { get; } = annotationTask; - internal string EvaluatorName { get; } = evaluatorName; + public ContentSafetyChatOptions(string annotationTask, string evaluatorName) + { + AnnotationTask = annotationTask; + EvaluatorName = evaluatorName; + } + + private ContentSafetyChatOptions(ContentSafetyChatOptions other) + : base(Throw.IfNull(other)) + { + AnnotationTask = other.AnnotationTask; + EvaluatorName = other.EvaluatorName; + } + + public string AnnotationTask { get; } + public string EvaluatorName { get; } + + public override ChatOptions Clone() => new ContentSafetyChatOptions(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs index afe90b0ac1d..5f173bfb0b3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Linq; @@ -30,11 +25,9 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; /// AI Foundry Evaluation service, to the s of the s /// returned by this . /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class ContentSafetyEvaluator( string contentSafetyServiceAnnotationTask, IDictionary metricNames) : IEvaluator -#pragma warning restore S1694 { /// public IReadOnlyCollection EvaluationMetricNames { get; } = [.. metricNames.Values]; @@ -115,13 +108,11 @@ protected async ValueTask EvaluateContentSafetyAsync( { IReadOnlyList? relevantContext = FilterAdditionalContext(additionalContext); -#pragma warning disable S1067 // Expressions should not be too complex if (relevantContext is not null && relevantContext.Any() && relevantContext.SelectMany(c => c.Contents) is IEnumerable contents && contents.Any() && contents.OfType() is IEnumerable textContents && textContents.Any() && string.Join(Environment.NewLine, textContents.Select(c => c.Text)) is string contextString && !string.IsNullOrWhiteSpace(contextString)) -#pragma warning restore S1067 { // Currently we only support supplying a context for the last conversation turn (which is the main one // that is being evaluated). @@ -166,6 +157,7 @@ EvaluationResult UpdateMetrics() metric.Name = metricName; } + metric.MarkAsBuiltIn(); metric.AddOrUpdateChatMetadata(annotationResponse, annotationDuration); metric.Interpretation = @@ -181,7 +173,7 @@ EvaluationResult UpdateMetrics() metric.AddDiagnostics(diagnostics); } -#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S125 // Sections of code should not be commented out. // The following commented code can be useful for debugging purposes. // metric.LogJsonData(payload); // metric.LogJsonData(annotationResult); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs index 41be29e9ed3..9454cb7f0e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; namespace Microsoft.Extensions.AI.Evaluation.Safety; @@ -18,30 +13,23 @@ private sealed class UrlCacheKey(ContentSafetyServiceConfiguration configuration internal ContentSafetyServiceConfiguration Configuration { get; } = configuration; internal string AnnotationTask { get; } = annotationTask; - public bool Equals(UrlCacheKey? other) - { - if (other is null) - { - return false; - } - else - { - return - other.Configuration.SubscriptionId == Configuration.SubscriptionId && - other.Configuration.ResourceGroupName == Configuration.ResourceGroupName && - other.Configuration.ProjectName == Configuration.ProjectName && - other.AnnotationTask == AnnotationTask; - } - } + public bool Equals(UrlCacheKey? other) => + other is not null && + other.Configuration.SubscriptionId == Configuration.SubscriptionId && + other.Configuration.ResourceGroupName == Configuration.ResourceGroupName && + other.Configuration.ProjectName == Configuration.ProjectName && + other.Configuration.Endpoint == Configuration.Endpoint && + other.AnnotationTask == AnnotationTask; - public override bool Equals(object? other) - => other is UrlCacheKey otherKey && Equals(otherKey); + public override bool Equals(object? other) => + other is UrlCacheKey otherKey && Equals(otherKey); public override int GetHashCode() => HashCode.Combine( Configuration.SubscriptionId, Configuration.ResourceGroupName, Configuration.ProjectName, + Configuration.Endpoint, AnnotationTask); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs index 6028a82544c..3f81cdbc1e6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Concurrent; using System.Diagnostics; @@ -22,15 +17,13 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; internal sealed partial class ContentSafetyService(ContentSafetyServiceConfiguration serviceConfiguration) { - private static HttpClient? _sharedHttpClient; - private static HttpClient SharedHttpClient - { - get - { - _sharedHttpClient ??= new HttpClient(); - return _sharedHttpClient; - } - } + private const string APIVersionForServiceDiscoveryInHubBasedProjects = "?api-version=2023-08-01-preview"; + private const string APIVersionForNonHubBasedProjects = "?api-version=2025-05-15-preview"; + + private static HttpClient SharedHttpClient => + field ?? + Interlocked.CompareExchange(ref field, new(), null) ?? + field; private static readonly ConcurrentDictionary _serviceUrlCache = new ConcurrentDictionary(); @@ -41,7 +34,7 @@ private static HttpClient SharedHttpClient internal static EvaluationResult ParseAnnotationResult(string annotationResponse) { -#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S125 // Sections of code should not be commented out. // Example annotation response: // [ // { @@ -168,20 +161,27 @@ private async ValueTask GetServiceUrlAsync( return _serviceUrl; } - string discoveryUrl = - await GetServiceDiscoveryUrlAsync(evaluatorName, cancellationToken).ConfigureAwait(false); - - serviceUrl = - $"{discoveryUrl}/raisvc/v1.0" + - $"/subscriptions/{serviceConfiguration.SubscriptionId}" + - $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + - $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}"; + if (serviceConfiguration.IsHubBasedProject) + { + string discoveryUrl = + await GetServiceDiscoveryUrlAsync(evaluatorName, cancellationToken).ConfigureAwait(false); + + serviceUrl = + $"{discoveryUrl}/raisvc/v1.0" + + $"/subscriptions/{serviceConfiguration.SubscriptionId}" + + $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + + $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}"; + } + else + { + serviceUrl = $"{serviceConfiguration.Endpoint.AbsoluteUri}/evaluations"; + } await EnsureServiceAvailabilityAsync( - serviceUrl, - capability: annotationTask, - evaluatorName, - cancellationToken).ConfigureAwait(false); + serviceUrl, + capability: annotationTask, + evaluatorName, + cancellationToken).ConfigureAwait(false); _ = _serviceUrlCache.TryAdd(key, serviceUrl); _serviceUrl = serviceUrl; @@ -196,7 +196,7 @@ private async ValueTask GetServiceDiscoveryUrlAsync( $"https://management.azure.com/subscriptions/{serviceConfiguration.SubscriptionId}" + $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}" + - $"?api-version=2023-08-01-preview"; + $"{APIVersionForServiceDiscoveryInHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -244,7 +244,10 @@ private async ValueTask EnsureServiceAvailabilityAsync( string evaluatorName, CancellationToken cancellationToken) { - string serviceAvailabilityUrl = $"{serviceUrl}/checkannotation"; + string serviceAvailabilityUrl = + serviceConfiguration.IsHubBasedProject + ? $"{serviceUrl}/checkannotation" + : $"{serviceUrl}/checkannotation{APIVersionForNonHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -297,7 +300,10 @@ private async ValueTask SubmitAnnotationRequestAsync( string evaluatorName, CancellationToken cancellationToken) { - string annotationUrl = $"{serviceUrl}/submitannotation"; + string annotationUrl = + serviceConfiguration.IsHubBasedProject + ? $"{serviceUrl}/submitannotation" + : $"{serviceUrl}/submitannotation{APIVersionForNonHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -376,9 +382,7 @@ await GetResponseAsync( } else { -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test await Task.Delay(InitialDelayInMilliseconds * attempts, cancellationToken).ConfigureAwait(false); -#pragma warning restore EA0002 } } } @@ -426,16 +430,18 @@ private async ValueTask AddHeadersAsync( httpRequestMessage.Headers.Add("User-Agent", userAgent); + TokenRequestContext context = + serviceConfiguration.IsHubBasedProject + ? new TokenRequestContext(scopes: ["https://management.azure.com/.default"]) + : new TokenRequestContext(scopes: ["https://ai.azure.com/.default"]); + AccessToken token = - await serviceConfiguration.Credential.GetTokenAsync( - new TokenRequestContext(scopes: ["https://management.azure.com/.default"]), - cancellationToken).ConfigureAwait(false); + await serviceConfiguration.Credential.GetTokenAsync(context, cancellationToken).ConfigureAwait(false); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); - if (httpRequestMessage.Content is not null) - { - httpRequestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - } +#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499). + httpRequestMessage.Content?.Headers.ContentType = new MediaTypeHeaderValue("application/json"); +#pragma warning restore IDE0058 } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs index ec721fa59c7..3e814f430e3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs @@ -1,69 +1,62 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - +using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Azure.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; /// -/// Specifies configuration parameters such as the Azure AI project that should be used, and the credentials that -/// should be used, when a communicates with the Azure AI Foundry Evaluation +/// Specifies configuration parameters, such as the Azure AI Foundry project and the credentials +/// that should be used, when a communicates with the Azure AI Foundry Evaluation /// service to perform evaluations. /// -/// -/// The Azure that should be used when authenticating requests. -/// -/// -/// The ID of the Azure subscription that contains the project identified by . -/// -/// -/// The name of the Azure resource group that contains the project identified by . -/// -/// -/// The name of the Azure AI project. -/// -/// -/// The that should be used when communicating with the Azure AI Foundry Evaluation service. -/// While the parameter is optional, it is recommended to supply an that is configured with -/// robust resilience and retry policies. -/// -/// -/// The timeout (in seconds) after which a should stop retrying failed attempts -/// to communicate with the Azure AI Foundry Evaluation service when performing evaluations. -/// -public sealed class ContentSafetyServiceConfiguration( - TokenCredential credential, - string subscriptionId, - string resourceGroupName, - string projectName, - HttpClient? httpClient = null, - int timeoutInSecondsForRetries = 300) // 5 minutes +/// +/// +/// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also +/// known simply as Foundry projects). See Create a project for Azure AI Foundry. +/// +/// +/// Hub-based projects are configured by specifying the , +/// , and for the project. Non-Hub-based projects, on the +/// other hand, are configured by specifying only the for the project. Use the appropriate +/// constructor overload to initialize based on the kind of project you +/// are working with. +/// +/// +public sealed class ContentSafetyServiceConfiguration { + private const int DefaultTimeoutInSecondsForRetries = 300; // 5 minutes + /// /// Gets the Azure that should be used when authenticating requests. /// - public TokenCredential Credential { get; } = credential; + public TokenCredential Credential { get; } + + /// + /// Gets the ID of the Azure subscription that contains the project identified by if the + /// project is a Hub-based project. + /// + public string? SubscriptionId { get; } /// - /// Gets the ID of the Azure subscription that contains the project identified by . + /// Gets the name of the Azure resource group that contains the project identified by if + /// the project is a Hub-based project. /// - public string SubscriptionId { get; } = subscriptionId; + public string? ResourceGroupName { get; } /// - /// Gets the name of the Azure resource group that contains the project identified by . + /// Gets the name of the Azure AI Foundry project if the project is a Hub-based project. /// - public string ResourceGroupName { get; } = resourceGroupName; + public string? ProjectName { get; } /// - /// Gets the name of the Azure AI project. + /// Gets the endpoint for the Azure AI Foundry project if the project is a non-Hub-based project. /// - public string ProjectName { get; } = projectName; + public Uri? Endpoint { get; } /// /// Gets the that should be used when communicating with the Azure AI Foundry Evaluation @@ -73,11 +66,152 @@ public sealed class ContentSafetyServiceConfiguration( /// While supplying an is optional, it is recommended to supply one that is configured /// with robust resilience and retry policies. /// - public HttpClient? HttpClient { get; } = httpClient; + public HttpClient? HttpClient { get; } /// /// Gets the timeout (in seconds) after which a should stop retrying failed /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. /// - public int TimeoutInSecondsForRetries { get; } = timeoutInSecondsForRetries; + public int TimeoutInSecondsForRetries { get; } + + [MemberNotNullWhen(true, nameof(SubscriptionId), nameof(ResourceGroupName), nameof(ProjectName))] + [MemberNotNullWhen(false, nameof(Endpoint))] + internal bool IsHubBasedProject => + !string.IsNullOrWhiteSpace(SubscriptionId) && + !string.IsNullOrWhiteSpace(ResourceGroupName) && + !string.IsNullOrWhiteSpace(ProjectName) && + Endpoint is null; + + /// + /// Initializes a new instance of the class for a Hub-based Azure + /// AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The ID of the Azure subscription that contains the Hub-based AI Foundry project identified by + /// . + /// + /// + /// The name of the Azure resource group that contains the Hub-based AI Foundry project identified by + /// . + /// + /// + /// The name of the Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See Create a project for Azure AI Foundry. + /// + /// + /// Use this constructor overload if you are working with a Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + string subscriptionId, + string resourceGroupName, + string projectName, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + { + Credential = Throw.IfNull(credential); + SubscriptionId = Throw.IfNullOrWhitespace(subscriptionId); + ResourceGroupName = Throw.IfNullOrWhitespace(resourceGroupName); + ProjectName = Throw.IfNullOrWhitespace(projectName); + HttpClient = httpClient; + TimeoutInSecondsForRetries = timeoutInSecondsForRetries; + } + + /// + /// Initializes a new instance of the class for a non-Hub-based + /// Azure AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The endpoint for the non-Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See Create a project for Azure AI Foundry. + /// + /// + /// Use this constructor overload if you are working with a non-Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + Uri endpoint, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + { + Credential = Throw.IfNull(credential); + Endpoint = Throw.IfNull(endpoint); + HttpClient = httpClient; + TimeoutInSecondsForRetries = timeoutInSecondsForRetries; + } + + /// + /// Initializes a new instance of the class for a non-Hub-based + /// Azure AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The endpoint URL for the non-Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See Create a project for Azure AI Foundry. + /// + /// + /// Use this constructor overload if you are working with a non-Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + string endpointUrl, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + : this( + credential, + endpoint: new Uri(Throw.IfNullOrWhitespace(endpointUrl)), + httpClient, + timeoutInSecondsForRetries) + { + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfigurationExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfigurationExtensions.cs index eded31ec0f8..1d7f15ee724 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfigurationExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfigurationExtensions.cs @@ -40,14 +40,17 @@ public static ChatConfiguration ToChatConfiguration( #pragma warning disable CA2000 // Dispose objects before they go out of scope. // We can't dispose newChatClient here because it is returned to the caller. - var newChatClient = contentSafetyServiceConfiguration.ToIChatClient(originalChatConfiguration?.ChatClient); + var newChatClient = + new ContentSafetyChatClient( + contentSafetyServiceConfiguration, + originalChatClient: originalChatConfiguration?.ChatClient); #pragma warning restore CA2000 return new ChatConfiguration(newChatClient); } /// - /// Returns an that can be used to communicate with the Azure AI Foundry Evaluation + /// Returns a that can be used to communicate with the Azure AI Foundry Evaluation /// service for performing content safety evaluations. /// /// @@ -56,21 +59,27 @@ public static ChatConfiguration ToChatConfiguration( /// content safety evaluations. /// /// - /// The original , if any. If specified, the returned - /// will be a wrapper around that can be used both - /// to communicate with the AI model that is configured to communicate with, - /// as well as to communicate with the Azure AI Foundry Evaluation service. + /// The original . The returned will be a + /// wrapper around that can be used both to communicate with the AI model + /// that is configured to communicate with, as well as to communicate with + /// the Azure AI Foundry Evaluation service. /// /// /// A that can be used to communicate with the Azure AI Foundry Evaluation service /// for performing content safety evaluations. /// - public static IChatClient ToIChatClient( + public static ChatConfiguration ToChatConfiguration( this ContentSafetyServiceConfiguration contentSafetyServiceConfiguration, - IChatClient? originalChatClient = null) + IChatClient originalChatClient) { _ = Throw.IfNull(contentSafetyServiceConfiguration); - return new ContentSafetyChatClient(contentSafetyServiceConfiguration, originalChatClient); +#pragma warning disable CA2000 // Dispose objects before they go out of scope. + // We can't dispose newChatClient here because it is returned to the caller. + + var newChatClient = new ContentSafetyChatClient(contentSafetyServiceConfiguration, originalChatClient); +#pragma warning restore CA2000 + + return new ChatConfiguration(newChatClient); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs index feecec3be46..1d2f0768a1f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs @@ -74,7 +74,6 @@ internal static (string payload, IReadOnlyList? diagnostic _ => throw new NotSupportedException($"The payload kind '{payloadFormat}' is not supported."), }; -#pragma warning disable S107 // Methods should not have too many parameters private static (string payload, IReadOnlyList? diagnostics) GetUserTextListPayloadWithEmbeddedXml( IEnumerable conversation, @@ -87,7 +86,6 @@ private static (string payload, IReadOnlyList? diagnostics string contextElementName = "Context", ContentSafetyServicePayloadStrategy strategy = ContentSafetyServicePayloadStrategy.AnnotateConversation, CancellationToken cancellationToken = default) -#pragma warning restore S107 { List> turns; List? normalizedPerTurnContext; @@ -162,7 +160,6 @@ private static (string payload, IReadOnlyList? diagnostics return (payload.ToJsonString(), diagnostics); } -#pragma warning disable S107 // Methods should not have too many parameters private static (string payload, IReadOnlyList? diagnostics) GetUserTextListPayloadWithEmbeddedJson( IEnumerable conversation, @@ -175,7 +172,6 @@ private static (string payload, IReadOnlyList? diagnostics string contextPropertyName = "context", ContentSafetyServicePayloadStrategy strategy = ContentSafetyServicePayloadStrategy.AnnotateLastTurn, CancellationToken cancellationToken = default) -#pragma warning restore S107 { if (strategy is ContentSafetyServicePayloadStrategy.AnnotateConversation) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/EvaluationMetricExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/EvaluationMetricExtensions.cs index 8a0ffcbd31b..f6ab393750b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/EvaluationMetricExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/EvaluationMetricExtensions.cs @@ -73,7 +73,7 @@ internal static EvaluationMetricInterpretation InterpretContentSafetyScore( : new EvaluationMetricInterpretation( rating, failed: true, - reason: $"{metric.Name} is {passValue}."); + reason: $"{metric.Name} is not {passValue}."); } internal static void LogJsonData(this EvaluationMetric metric, string data) diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluator.cs index f65ddae4662..24408d5a1ad 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluator.cs @@ -87,6 +87,6 @@ private static GroundednessProEvaluatorContext GetRelevantContext( } throw new InvalidOperationException( - $"A value of type '{nameof(GroundednessProEvaluatorContext)}' was not found in the '{nameof(additionalContext)}' collection."); + $"A value of type {nameof(GroundednessProEvaluatorContext)} was not found in the {nameof(additionalContext)} collection."); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs index 677fd4154b3..56af247350d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Safety; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj index 12512e6884c..8cfc9e1b538 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj @@ -17,6 +17,8 @@ + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/README.md b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/README.md index c042da70deb..9bf406ba052 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/README.md @@ -5,6 +5,8 @@ * [`Microsoft.Extensions.AI.Evaluation`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation) - Defines core abstractions and types for supporting evaluation. * [`Microsoft.Extensions.AI.Evaluation.Quality`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Quality) - Contains evaluators that can be used to evaluate the quality of AI responses in your projects including Relevance, Truth, Completeness, Fluency, Coherence, Retrieval, Equivalence and Groundedness. * [`Microsoft.Extensions.AI.Evaluation.Safety`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Safety) - Contains a set of evaluators that are built atop the Azure AI Foundry Evaluation service that can be used to evaluate the content safety of AI responses in your projects including Protected Material, Groundedness Pro, Ungrounded Attributes, Hate and Unfairness, Self Harm, Violence, Sexual, Code Vulnerability and Indirect Attack. +* [`Microsoft.Extensions.AI.Evaluation.NLP`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.NLP) - Contains a set of evaluators that implement common algorithms for evaluating machine translation and natural +language processing tasks. Evaluators currently include BLEU, GLEU and F1 scores. * [`Microsoft.Extensions.AI.Evaluation.Reporting`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting) - Contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. * [`Microsoft.Extensions.AI.Evaluation.Reporting.Azure`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting.Azure) - Supports the `Microsoft.Extensions.AI.Evaluation.Reporting` library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. * [`Microsoft.Extensions.AI.Evaluation.Console`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Console) - A command line dotnet tool for generating reports and managing evaluation data. @@ -18,6 +20,7 @@ dotnet add package Microsoft.Extensions.AI.Evaluation dotnet add package Microsoft.Extensions.AI.Evaluation.Quality dotnet add package Microsoft.Extensions.AI.Evaluation.Safety dotnet add package Microsoft.Extensions.AI.Evaluation.Reporting +dotnet add package Microsoft.Extensions.AI.Evaluation.NLP ``` Or directly in the C# project file: @@ -28,6 +31,7 @@ Or directly in the C# project file: + ``` diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluator.cs index 06019969345..4b3fe84cb4e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluator.cs @@ -91,6 +91,6 @@ private static UngroundedAttributesEvaluatorContext GetRelevantContext( } throw new InvalidOperationException( - $"A value of type '{nameof(UngroundedAttributesEvaluatorContext)}' was not found in the '{nameof(additionalContext)}' collection."); + $"A value of type {nameof(UngroundedAttributesEvaluatorContext)} was not found in the {nameof(additionalContext)} collection."); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs index b3273b93798..ef72729f6bb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Safety; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs index 881816b198b..0d1db0ed487 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs index e3d0fad4caf..07e2636d7f9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs @@ -50,10 +50,8 @@ public CompositeEvaluator(IEnumerable evaluators) { if (evaluator.EvaluationMetricNames.Count == 0) { -#pragma warning disable S103 // Lines should not be too long throw new InvalidOperationException( $"The '{nameof(evaluator.EvaluationMetricNames)}' property on '{evaluator.GetType().FullName}' returned an empty collection. An evaluator must advertise the names of the metrics that it supports."); -#pragma warning restore S103 } foreach (string metricName in evaluator.EvaluationMetricNames) @@ -149,10 +147,8 @@ async ValueTask EvaluateAsync(IEvaluator e) if (e.EvaluationMetricNames.Count == 0) { -#pragma warning disable S103 // Lines should not be too long throw new InvalidOperationException( $"The '{nameof(e.EvaluationMetricNames)}' property on '{e.GetType().FullName}' returned an empty collection. An evaluator must advertise the names of the metrics that it supports."); -#pragma warning restore S103 } foreach (string metricName in e.EvaluationMetricNames) diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs index 203d86a84fa..05bdac6e68e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs @@ -42,20 +42,13 @@ namespace Microsoft.Extensions.AI.Evaluation; /// contextual information that is modeled by the . /// /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class EvaluationContext -#pragma warning restore S1694 { /// /// Gets or sets the name for this . /// public string Name { get; set; } -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this property to be fully mutable for serialization purposes and for - // general convenience. - /// /// Gets or sets a list of objects that include all the information present in this /// . @@ -97,7 +90,6 @@ public abstract class EvaluationContext /// . /// public IList Contents { get; set; } -#pragma warning restore CA2227 /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs index 67ec3b13ebb..64ec8e913b9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs index 19d05c20bc4..112cd53d382 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System.Collections.Generic; using System.Text.Json.Serialization; @@ -43,11 +38,6 @@ public class EvaluationMetric(string name, string? reason = null) /// public EvaluationMetricInterpretation? Interpretation { get; set; } -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets any s that were considered by the as part /// of the evaluation that produced the current . @@ -65,5 +55,4 @@ public class EvaluationMetric(string name, string? reason = null) /// . /// public IDictionary? Metadata { get; set; } -#pragma warning restore CA2227 } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs index d3012030cec..1940ee562d5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs @@ -3,9 +3,10 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Shared.Diagnostics; +using Microsoft.Shared.Text; namespace Microsoft.Extensions.AI.Evaluation; @@ -85,7 +86,7 @@ public static void AddDiagnostics(this EvaluationMetric metric, IEnumerable(); + metric.Diagnostics ??= []; foreach (EvaluationDiagnostic diagnostic in diagnostics) { @@ -143,7 +144,8 @@ public static void AddOrUpdateMetadata(this EvaluationMetric metric, IDictionary /// The that contains metadata to be added or updated. /// /// An optional duration that represents the amount of time that it took for the AI model to produce the supplied - /// . If supplied, the duration will also be included as part of the added metadata. + /// . If supplied, the duration (in milliseconds) will also be included as part of the + /// added metadata. /// public static void AddOrUpdateChatMetadata( this EvaluationMetric metric, @@ -154,31 +156,52 @@ public static void AddOrUpdateChatMetadata( if (!string.IsNullOrWhiteSpace(response.ModelId)) { - metric.AddOrUpdateMetadata(name: "evaluation-model-used", value: response.ModelId!); + metric.AddOrUpdateMetadata(name: BuiltInMetricUtilities.EvalModelMetadataName, value: response.ModelId!); } if (response.Usage is UsageDetails usage) { if (usage.InputTokenCount is not null) { - metric.AddOrUpdateMetadata(name: "evaluation-input-tokens-used", value: $"{usage.InputTokenCount}"); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalInputTokensMetadataName, + value: usage.InputTokenCount.Value.ToInvariantString()); } if (usage.OutputTokenCount is not null) { - metric.AddOrUpdateMetadata(name: "evaluation-output-tokens-used", value: $"{usage.OutputTokenCount}"); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalOutputTokensMetadataName, + value: usage.OutputTokenCount.Value.ToInvariantString()); } if (usage.TotalTokenCount is not null) { - metric.AddOrUpdateMetadata(name: "evaluation-total-tokens-used", value: $"{usage.TotalTokenCount}"); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalTotalTokensMetadataName, + value: usage.TotalTokenCount.Value.ToInvariantString()); } } if (duration is not null) { - string durationText = $"{duration.Value.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; - metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); + metric.AddOrUpdateDurationMetadata(duration.Value); } } + + /// + /// Adds or updates metadata identifying the amount of time (in milliseconds) that it took to perform the + /// evaluation in the supplied 's dictionary. + /// + /// The . + /// + /// The amount of time that it took to perform the evaluation that produced the supplied . + /// + public static void AddOrUpdateDurationMetadata(this EvaluationMetric metric, TimeSpan duration) + { + string durationInMilliseconds = duration.ToMillisecondsText(); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalDurationMillisecondsMetadataName, + value: durationInMilliseconds); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs index 5206324edb6..b54f92e57c9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs index d2745069bc5..73965d9527d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs index 94ce86abfd9..a60ebd71e42 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs @@ -16,17 +16,11 @@ namespace Microsoft.Extensions.AI.Evaluation; /// public sealed class EvaluationResult { -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets a collection of one or more s that represent the result of an /// evaluation. /// public IDictionary Metrics { get; set; } -#pragma warning restore CA2227 /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs index 7ea21dec91f..ba199e26de1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs @@ -173,7 +173,8 @@ public static void AddOrUpdateMetadataInAllMetrics( /// The that contains metadata to be added or updated. /// /// An optional duration that represents the amount of time that it took for the AI model to produce the supplied - /// . If supplied, the duration will also be included as part of the added metadata. + /// . If supplied, the duration (in milliseconds) will also be included as part of the + /// added metadata. /// public static void AddOrUpdateChatMetadataInAllMetrics( this EvaluationResult result, @@ -187,4 +188,24 @@ public static void AddOrUpdateChatMetadataInAllMetrics( metric.AddOrUpdateChatMetadata(response, duration); } } + + /// + /// Adds or updates metadata identifying the amount of time (in milliseconds) that it took to perform the + /// evaluation in all s contained in the supplied . + /// + /// + /// The containing the s that are to be altered. + /// + /// + /// The amount of time that it took to perform the evaluation that produced the supplied . + /// + public static void AddOrUpdateDurationMetadataInAllMetrics(this EvaluationResult result, TimeSpan duration) + { + _ = Throw.IfNull(result); + + foreach (EvaluationMetric metric in result.Metrics.Values) + { + metric.AddOrUpdateDurationMetadata(duration); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj index 3f098cf3026..129ae2aab89 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj @@ -1,4 +1,4 @@ - + A library that defines core abstractions and types for supporting evaluation. @@ -8,11 +8,14 @@ AIEval - preview + normal true - false - 56 - 0 + n/a + n/a + + + + true diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json new file mode 100644 index 00000000000..d9b464ef6b4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json @@ -0,0 +1,499 @@ +{ + "Name": "Microsoft.Extensions.AI.Evaluation, Version=9.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.BooleanMetric : Microsoft.Extensions.AI.Evaluation.EvaluationMetric", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.BooleanMetric.BooleanMetric(string name, bool? value = null, string? reason = null);", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.ChatConfiguration", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.ChatConfiguration.ChatConfiguration(Microsoft.Extensions.AI.IChatClient chatClient);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.IChatClient Microsoft.Extensions.AI.Evaluation.ChatConfiguration.ChatClient { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.ChatMessageExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.ChatMessageExtensions.RenderText(this Microsoft.Extensions.AI.ChatMessage message);", + "Stage": "Stable" + }, + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.ChatMessageExtensions.RenderText(this System.Collections.Generic.IEnumerable messages);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.Evaluation.ChatMessageExtensions.TryGetUserRequest(this System.Collections.Generic.IEnumerable messages, out Microsoft.Extensions.AI.ChatMessage? userRequest);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.Evaluation.ChatMessageExtensions.TryGetUserRequest(this System.Collections.Generic.IEnumerable messages, out Microsoft.Extensions.AI.ChatMessage? userRequest, out System.Collections.Generic.IReadOnlyList remainingMessages);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.ChatResponseExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static string Microsoft.Extensions.AI.Evaluation.ChatResponseExtensions.RenderText(this Microsoft.Extensions.AI.ChatResponse response);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.CompositeEvaluator : Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.CompositeEvaluator.CompositeEvaluator(params Microsoft.Extensions.AI.Evaluation.IEvaluator[] evaluators);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.CompositeEvaluator.CompositeEvaluator(System.Collections.Generic.IEnumerable evaluators);", + "Stage": "Stable" + }, + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.CompositeEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.CompositeEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.Evaluation.EvaluationContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationContext.EvaluationContext(string name, System.Collections.Generic.IEnumerable contents);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationContext.EvaluationContext(string name, params Microsoft.Extensions.AI.AIContent[] contents);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationContext.EvaluationContext(string name, string content);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.Evaluation.EvaluationContext.Contents { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.EvaluationContext.Name { get; set; }", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.EvaluationDiagnostic(Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity severity, string message);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.Error(string message);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.Informational(string message);", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.ToString();", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.Warning(string message);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.Message { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic.Severity { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "enum Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity.EvaluationDiagnosticSeverity();", + "Stage": "Stable" + } + ], + "Fields": [ + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity.Error", + "Stage": "Stable", + "Value": "2" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity.Informational", + "Stage": "Stable", + "Value": "0" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity Microsoft.Extensions.AI.Evaluation.EvaluationDiagnosticSeverity.Warning", + "Stage": "Stable", + "Value": "1" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "class Microsoft.Extensions.AI.Evaluation.EvaluationMetric", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationMetric.EvaluationMetric(string name, string? reason = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IDictionary? Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Context { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Diagnostics { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation? Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Interpretation { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IDictionary? Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Metadata { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Name { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Reason { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.Evaluation.EvaluationMetric : Microsoft.Extensions.AI.Evaluation.EvaluationMetric", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationMetric.EvaluationMetric(string name, T? value, string? reason = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "T? Microsoft.Extensions.AI.Evaluation.EvaluationMetric.Value { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddDiagnostics(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, System.Collections.Generic.IEnumerable diagnostics);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddDiagnostics(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, params Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic[] diagnostics);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateChatMetadata(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, Microsoft.Extensions.AI.ChatResponse response, System.TimeSpan? duration = null);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateContext(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, System.Collections.Generic.IEnumerable context);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateContext(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, params Microsoft.Extensions.AI.Evaluation.EvaluationContext[] context);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateDurationMetadata(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, System.TimeSpan duration);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateMetadata(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, string name, string value);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateMetadata(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, System.Collections.Generic.IDictionary metadata);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.ContainsDiagnostics(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, System.Func? predicate = null);", + "Stage": "Stable" + } + ] + }, + { + // After generating the baseline, manually edit this file to remove primary constructor portion + // This is needed until ICSharpCode.Decompiler adds support for primary constructors + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation.EvaluationMetricInterpretation(Microsoft.Extensions.AI.Evaluation.EvaluationRating rating = Microsoft.Extensions.AI.Evaluation.EvaluationRating.Unknown, bool failed = false, string? reason = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation.Failed { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation.Rating { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.Evaluation.EvaluationMetricInterpretation.Reason { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "enum Microsoft.Extensions.AI.Evaluation.EvaluationRating", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationRating.EvaluationRating();", + "Stage": "Stable" + } + ], + "Fields": [ + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Average", + "Stage": "Stable", + "Value": "4" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Exceptional", + "Stage": "Stable", + "Value": "6" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Good", + "Stage": "Stable", + "Value": "5" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Inconclusive", + "Stage": "Stable", + "Value": "1" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Poor", + "Stage": "Stable", + "Value": "3" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Unacceptable", + "Stage": "Stable", + "Value": "2" + }, + { + "Member": "const Microsoft.Extensions.AI.Evaluation.EvaluationRating Microsoft.Extensions.AI.Evaluation.EvaluationRating.Unknown", + "Stage": "Stable", + "Value": "0" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.EvaluationResult", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationResult.EvaluationResult(System.Collections.Generic.IDictionary metrics);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationResult.EvaluationResult(System.Collections.Generic.IEnumerable metrics);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.EvaluationResult.EvaluationResult(params Microsoft.Extensions.AI.Evaluation.EvaluationMetric[] metrics);", + "Stage": "Stable" + }, + { + "Member": "T Microsoft.Extensions.AI.Evaluation.EvaluationResult.Get(string metricName);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.Evaluation.EvaluationResult.TryGet(string metricName, out T? value);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IDictionary Microsoft.Extensions.AI.Evaluation.EvaluationResult.Metrics { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddDiagnosticsToAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.Collections.Generic.IEnumerable diagnostics);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddDiagnosticsToAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, params Microsoft.Extensions.AI.Evaluation.EvaluationDiagnostic[] diagnostics);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateChatMetadataInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, Microsoft.Extensions.AI.ChatResponse response, System.TimeSpan? duration = null);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateContextInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.Collections.Generic.IEnumerable context);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateContextInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, params Microsoft.Extensions.AI.Evaluation.EvaluationContext[] context);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateDurationMetadataInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.TimeSpan duration);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateMetadataInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, string name, string value);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateMetadataInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.Collections.Generic.IDictionary metadata);", + "Stage": "Stable" + }, + { + "Member": "static bool Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.ContainsDiagnostics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.Func? predicate = null);", + "Stage": "Stable" + }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.Interpret(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.Func interpretationProvider);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.IEvaluator evaluator, string modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.IEvaluator evaluator, string userRequest, string modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.IEvaluator evaluator, Microsoft.Extensions.AI.ChatMessage modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.IEvaluator evaluator, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.IEvaluator evaluator, Microsoft.Extensions.AI.ChatMessage userRequest, Microsoft.Extensions.AI.ChatMessage modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.EvaluatorExtensions.EvaluateAsync(this Microsoft.Extensions.AI.Evaluation.IEvaluator evaluator, Microsoft.Extensions.AI.ChatMessage userRequest, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "interface Microsoft.Extensions.AI.Evaluation.IEvaluator", + "Stage": "Stable", + "Methods": [ + { + "Member": "System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.Evaluation.IEvaluator.EvaluateAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatResponse modelResponse, Microsoft.Extensions.AI.Evaluation.ChatConfiguration? chatConfiguration = null, System.Collections.Generic.IEnumerable? additionalContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyCollection Microsoft.Extensions.AI.Evaluation.IEvaluator.EvaluationMetricNames { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.NumericMetric : Microsoft.Extensions.AI.Evaluation.EvaluationMetric", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.NumericMetric.NumericMetric(string name, double? value = null, string? reason = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.Evaluation.StringMetric : Microsoft.Extensions.AI.Evaluation.EvaluationMetric", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.Evaluation.StringMetric.StringMetric(string name, string? value = null, string? reason = null);", + "Stage": "Stable" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/README.md b/src/Libraries/Microsoft.Extensions.AI.Evaluation/README.md index c21e2a299ad..dfc15311489 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/README.md @@ -5,6 +5,8 @@ * [`Microsoft.Extensions.AI.Evaluation`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation) - Defines core abstractions and types for supporting evaluation. * [`Microsoft.Extensions.AI.Evaluation.Quality`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Quality) - Contains evaluators that can be used to evaluate the quality of AI responses in your projects including Relevance, Truth, Completeness, Fluency, Coherence, Retrieval, Equivalence and Groundedness. * [`Microsoft.Extensions.AI.Evaluation.Safety`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Safety) - Contains a set of evaluators that are built atop the Azure AI Foundry Evaluation service that can be used to evaluate the content safety of AI responses in your projects including Protected Material, Groundedness Pro, Ungrounded Attributes, Hate and Unfairness, Self Harm, Violence, Sexual, Code Vulnerability and Indirect Attack. +* [`Microsoft.Extensions.AI.Evaluation.NLP`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.NLP) - Contains a set of evaluators that implement common algorithms for evaluating machine translation and natural +language processing tasks. Evaluators currently include BLEU, GLEU and F1 scores. * [`Microsoft.Extensions.AI.Evaluation.Reporting`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting) - Contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. * [`Microsoft.Extensions.AI.Evaluation.Reporting.Azure`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Reporting.Azure) - Supports the `Microsoft.Extensions.AI.Evaluation.Reporting` library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. * [`Microsoft.Extensions.AI.Evaluation.Console`](https://www.nuget.org/packages/Microsoft.Extensions.AI.Evaluation.Console) - A command line dotnet tool for generating reports and managing evaluation data. @@ -18,6 +20,7 @@ dotnet add package Microsoft.Extensions.AI.Evaluation dotnet add package Microsoft.Extensions.AI.Evaluation.Quality dotnet add package Microsoft.Extensions.AI.Evaluation.Safety dotnet add package Microsoft.Extensions.AI.Evaluation.Reporting +dotnet add package Microsoft.Extensions.AI.Evaluation.NLP ``` Or directly in the C# project file: @@ -28,6 +31,7 @@ Or directly in the C# project file: + ``` diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInMetricUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInMetricUtilities.cs new file mode 100644 index 00000000000..5718cf95a6e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInMetricUtilities.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI.Evaluation.Utilities; + +internal static class BuiltInMetricUtilities +{ + internal const string EvalModelMetadataName = "eval-model"; + internal const string EvalInputTokensMetadataName = "eval-input-tokens"; + internal const string EvalOutputTokensMetadataName = "eval-output-tokens"; + internal const string EvalTotalTokensMetadataName = "eval-total-tokens"; + internal const string EvalDurationMillisecondsMetadataName = "eval-duration-ms"; + internal const string BuiltInEvalMetadataName = "built-in-eval"; + + internal static void MarkAsBuiltIn(this EvaluationMetric metric) => + metric.AddOrUpdateMetadata(name: BuiltInEvalMetadataName, value: bool.TrueString); + + internal static bool IsBuiltIn(this EvaluationMetric metric) => + metric.Metadata?.TryGetValue(BuiltInEvalMetadataName, out string? stringValue) is true && + bool.TryParse(stringValue, out bool value) && + value; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/ModelInfo.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/ModelInfo.cs new file mode 100644 index 00000000000..483e1fbe77f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/ModelInfo.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.RegularExpressions; + +namespace Microsoft.Extensions.AI.Evaluation.Utilities; + +internal static class ModelInfo +{ + internal static class KnownModels + { + internal const string AzureAIFoundryEvaluation = "azure.ai.foundry.evaluation"; + } + + internal static class KnownModelProviders + { + internal const string AzureAIFoundry = "azure.ai.foundry"; + } + + internal static class KnownModelHostMonikers + { + internal const string LocalMachine = "local"; + internal const string AzureAIFoundry = "azure.ai.foundry"; + internal const string AzureOpenAI = "azure.openai"; + internal const string AzureML = "azure.ml"; + internal const string GitHubModels = "github.models"; + internal const string Azure = "azure"; + internal const string GitHub = "github"; + internal const string Microsoft = "microsoft"; + } + + private const string LocalMachineHost = "localhost"; + + private static Regex LocalMachineHostMonikerRegex { get; } = + new Regex($"\\({Regex.Escape(KnownModelHostMonikers.LocalMachine)}\\)$"); + + // NOTE: Order more specific patterns first. + private static (string hostPattern, string hostMoniker)[] KnownHostMonikers { get; } = + [ + ("services.ai.azure.", KnownModelHostMonikers.AzureAIFoundry), + ("openai.azure.", KnownModelHostMonikers.AzureOpenAI), + ("ml.azure.", KnownModelHostMonikers.AzureML), + ("models.github.ai", KnownModelHostMonikers.GitHubModels), + ("models.inference.ai.azure.", KnownModelHostMonikers.GitHubModels), + (".azure.", KnownModelHostMonikers.Azure), + (".github.", KnownModelHostMonikers.GitHub), + (".microsoft.", KnownModelHostMonikers.Microsoft) + ]; + + private static Regex KnownHostMonikersRegex { get; } = + new Regex( + $"\\((" + + $"{Regex.Escape(KnownModelHostMonikers.AzureAIFoundry)}|" + + $"{Regex.Escape(KnownModelHostMonikers.AzureOpenAI)}|" + + $"{Regex.Escape(KnownModelHostMonikers.AzureML)}|" + + $"{Regex.Escape(KnownModelHostMonikers.GitHubModels)}|" + + $"{Regex.Escape(KnownModelHostMonikers.Azure)}|" + + $"{Regex.Escape(KnownModelHostMonikers.GitHub)}|" + + $"{Regex.Escape(KnownModelHostMonikers.Microsoft)}" + + $")\\)$"); + + /// + /// Returns a string with format {provider} ({host}) where {provider} is the name of the model + /// provider (available via - for example, openai) and + /// {host} is a moniker that identifies the hosting service (for example, azure.openai or + /// github.models). If the hosting service is not recognized, only the name of the model provider is + /// returned. + /// + /// + /// The that identifies the model that produced a particular response. + /// + /// + /// The for the that was used to communicate with the + /// model. + /// + internal static string? GetModelProvider(string? model, ChatClientMetadata? metadata) + { +#pragma warning disable S2219 // Runtime type checking should be simplified. + if (model is KnownModels.AzureAIFoundryEvaluation) +#pragma warning restore S2219 + { + // We know that the model provider and the host are both Azure AI Foundry in this case. + return $"{KnownModelProviders.AzureAIFoundry} ({KnownModelHostMonikers.AzureAIFoundry})"; + } + + if (metadata is null) + { + return null; + } + + string? provider = metadata.ProviderName; + string? host = metadata.ProviderUri?.Host; + + if (!string.IsNullOrWhiteSpace(host)) + { + if (string.Equals(host, LocalMachineHost, StringComparison.OrdinalIgnoreCase)) + { + return $"{provider} ({KnownModelHostMonikers.LocalMachine})"; + } + + foreach (var (hostPattern, hostMoniker) in KnownHostMonikers) + { +#if NET + if (host.Contains(hostPattern, StringComparison.OrdinalIgnoreCase)) +#else + if (host!.IndexOf(hostPattern, StringComparison.OrdinalIgnoreCase) >= 0) +#endif + { + return $"{provider} ({hostMoniker})"; + } + } + } + + return provider; + } + + /// + /// Returns if the specified indicates that the model is + /// hosted by a well-known (Microsoft-owned) service; otherwise. + /// + internal static bool IsModelHostWellKnown(string? modelProvider) + => !string.IsNullOrWhiteSpace(modelProvider) && KnownHostMonikersRegex.IsMatch(modelProvider); + + /// + /// Returns if the specified indicates that the model is + /// hosted locally (using ollama, for example); otherwise. + /// + internal static bool IsModelHostedLocally(string? modelProvider) + => !string.IsNullOrWhiteSpace(modelProvider) && LocalMachineHostMonikerRegex.IsMatch(modelProvider); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs index 74b68b25b9e..fdd16aef3e2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs @@ -3,12 +3,16 @@ using System; using System.Diagnostics; +using System.Globalization; using System.Threading.Tasks; namespace Microsoft.Extensions.AI.Evaluation.Utilities; internal static class TimingHelper { + internal static string ToMillisecondsText(this TimeSpan duration) => + duration.TotalMilliseconds.ToString("F2", CultureInfo.InvariantCulture); + internal static TimeSpan ExecuteWithTiming(Action operation) { Stopwatch stopwatch = Stopwatch.StartNew(); @@ -42,7 +46,7 @@ internal static (TResult result, TimeSpan duration) ExecuteWithTiming(F return (result, duration: stopwatch.Elapsed); } -#pragma warning disable EA0014 // The async method doesn't support cancellation +#pragma warning disable EA0014 // The async method doesn't support cancellation. internal static async ValueTask ExecuteWithTimingAsync(Func operation) { Stopwatch stopwatch = Stopwatch.StartNew(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md deleted file mode 100644 index e90fed2cdba..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# Release History - -## 9.4.4-preview.1.25259.16 - -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. - -## 9.4.3-preview.1.25230.7 - -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. - -## 9.4.0-preview.1.25207.5 - -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. - -## 9.3.0-preview.1.25161.3 - -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. - -## 9.3.0-preview.1.25114.11 - -- Ensures that all yielded `ChatResponseUpdates` include a `ResponseId`. -- Ensures that error HTTP status codes are correctly propagated as exceptions. - -## 9.1.0-preview.1.25064.3 - -- Added support for function calling when doing streaming operations. -- Added support for native structured output. - -## 9.0.1-preview.1.24570.5 - -- Made the `ToolCallJsonSerializerOptions` property non-nullable. - -## 9.0.0-preview.9.24525.1 - -- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. -- Added additional constructors to `OllamaChatClient` and `OllamaEmbeddingGenerator` that accept `string` endpoints, in addition to the existing ones accepting `Uri` endpoints. - -## 9.0.0-preview.9.24507.7 - -- Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs deleted file mode 100644 index 6de0144c7cf..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -[JsonSerializable(typeof(OllamaChatRequest))] -[JsonSerializable(typeof(OllamaChatRequestMessage))] -[JsonSerializable(typeof(OllamaChatResponse))] -[JsonSerializable(typeof(OllamaChatResponseMessage))] -[JsonSerializable(typeof(OllamaFunctionCallContent))] -[JsonSerializable(typeof(OllamaFunctionResultContent))] -[JsonSerializable(typeof(OllamaFunctionTool))] -[JsonSerializable(typeof(OllamaFunctionToolCall))] -[JsonSerializable(typeof(OllamaFunctionToolParameter))] -[JsonSerializable(typeof(OllamaFunctionToolParameters))] -[JsonSerializable(typeof(OllamaRequestOptions))] -[JsonSerializable(typeof(OllamaTool))] -[JsonSerializable(typeof(OllamaToolCall))] -[JsonSerializable(typeof(OllamaEmbeddingRequest))] -[JsonSerializable(typeof(OllamaEmbeddingResponse))] -internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj deleted file mode 100644 index 4189a7fb466..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - Microsoft.Extensions.AI - Implementation of generative AI abstractions for Ollama. - AI - - - - preview - false - 78 - 0 - - - - $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;SA1316;S1121;EA0002 - true - true - - - - true - true - true - true - true - - - - - - - - - - - - - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs deleted file mode 100644 index 28f8eb8c3ad..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ /dev/null @@ -1,495 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S3358 // Ternary operators should not be nested - -namespace Microsoft.Extensions.AI; - -/// Represents an for Ollama. -public sealed class OllamaChatClient : IChatClient -{ - private static readonly JsonElement _schemalessJsonResponseFormatValue = JsonDocument.Parse("\"json\"").RootElement; - - private static readonly AIJsonSchemaTransformCache _schemaTransformCache = new(new() - { - ConvertBooleanSchemas = true, - }); - - /// Metadata about the client. - private readonly ChatClientMetadata _metadata; - - /// The api/chat endpoint URI. - private readonly Uri _apiChatEndpoint; - - /// The to use for sending requests. - private readonly HttpClient _httpClient; - - /// The use for any serialization activities related to tool call arguments and results. - private JsonSerializerOptions _toolCallJsonSerializerOptions = AIJsonUtilities.DefaultOptions; - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - public OllamaChatClient(string endpoint, string? modelId = null, HttpClient? httpClient = null) - : this(new Uri(Throw.IfNull(endpoint)), modelId, httpClient) - { - } - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - /// is . - /// is empty or composed entirely of whitespace. - public OllamaChatClient(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) - { - _ = Throw.IfNull(endpoint); - if (modelId is not null) - { - _ = Throw.IfNullOrWhitespace(modelId); - } - - _apiChatEndpoint = new Uri(endpoint, "api/chat"); - _httpClient = httpClient ?? OllamaUtilities.SharedClient; - - _metadata = new ChatClientMetadata("ollama", endpoint, modelId); - } - - /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions ToolCallJsonSerializerOptions - { - get => _toolCallJsonSerializerOptions; - set => _toolCallJsonSerializerOptions = Throw.IfNull(value); - } - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - using var httpResponse = await _httpClient.PostAsJsonAsync( - _apiChatEndpoint, - ToOllamaChatRequest(messages, options, stream: false), - JsonContext.Default.OllamaChatRequest, - cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - var response = (await httpResponse.Content.ReadFromJsonAsync( - JsonContext.Default.OllamaChatResponse, - cancellationToken).ConfigureAwait(false))!; - - if (!string.IsNullOrEmpty(response.Error)) - { - throw new InvalidOperationException($"Ollama error: {response.Error}"); - } - - var responseId = Guid.NewGuid().ToString("N"); - - return new(FromOllamaMessage(response.Message!, responseId)) - { - CreatedAt = DateTimeOffset.TryParse(response.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - FinishReason = ToFinishReason(response), - ModelId = response.Model ?? options?.ModelId ?? _metadata.DefaultModelId, - ResponseId = responseId, - Usage = ParseOllamaChatResponseUsage(response), - }; - } - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - using HttpRequestMessage request = new(HttpMethod.Post, _apiChatEndpoint) - { - Content = JsonContent.Create(ToOllamaChatRequest(messages, options, stream: true), JsonContext.Default.OllamaChatRequest) - }; - using var httpResponse = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - // Ollama doesn't set a response ID on streamed chunks, so we need to generate one. - var responseId = Guid.NewGuid().ToString("N"); - - using var httpResponseStream = await httpResponse.Content -#if NET - .ReadAsStreamAsync(cancellationToken) -#else - .ReadAsStreamAsync() -#endif - .ConfigureAwait(false); - - using var streamReader = new StreamReader(httpResponseStream); -#if NET - while ((await streamReader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is { } line) -#else - while ((await streamReader.ReadLineAsync().ConfigureAwait(false)) is { } line) -#endif - { - var chunk = JsonSerializer.Deserialize(line, JsonContext.Default.OllamaChatResponse); - if (chunk is null) - { - continue; - } - - string? modelId = chunk.Model ?? _metadata.DefaultModelId; - - ChatResponseUpdate update = new() - { - CreatedAt = DateTimeOffset.TryParse(chunk.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - FinishReason = ToFinishReason(chunk), - ModelId = modelId, - ResponseId = responseId, - MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID - Role = chunk.Message?.Role is not null ? new ChatRole(chunk.Message.Role) : null, - }; - - if (chunk.Message is { } message) - { - if (message.ToolCalls is { Length: > 0 }) - { - foreach (var toolCall in message.ToolCalls) - { - if (toolCall.Function is { } function) - { - update.Contents.Add(ToFunctionCallContent(function)); - } - } - } - - // Equivalent rule to the nonstreaming case - if (message.Content?.Length > 0 || update.Contents.Count == 0) - { - update.Contents.Insert(0, new TextContent(message.Content)); - } - } - - if (ParseOllamaChatResponseUsage(chunk) is { } usage) - { - update.Contents.Add(new UsageContent(usage)); - } - - yield return update; - } - } - - /// - object? IChatClient.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public void Dispose() - { - if (_httpClient != OllamaUtilities.SharedClient) - { - _httpClient.Dispose(); - } - } - - private static UsageDetails? ParseOllamaChatResponseUsage(OllamaChatResponse response) - { - AdditionalPropertiesDictionary? additionalCounts = null; - OllamaUtilities.TransferNanosecondsTime(response, static r => r.LoadDuration, "load_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.TotalDuration, "total_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.PromptEvalDuration, "prompt_eval_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.EvalDuration, "eval_duration", ref additionalCounts); - - if (additionalCounts is not null || response.PromptEvalCount is not null || response.EvalCount is not null) - { - return new() - { - InputTokenCount = response.PromptEvalCount, - OutputTokenCount = response.EvalCount, - TotalTokenCount = response.PromptEvalCount.GetValueOrDefault() + response.EvalCount.GetValueOrDefault(), - AdditionalCounts = additionalCounts, - }; - } - - return null; - } - - private static ChatFinishReason? ToFinishReason(OllamaChatResponse response) => - response.DoneReason switch - { - null => null, - "length" => ChatFinishReason.Length, - "stop" => ChatFinishReason.Stop, - _ => new ChatFinishReason(response.DoneReason), - }; - - private static ChatMessage FromOllamaMessage(OllamaChatResponseMessage message, string responseId) - { - List contents = []; - - // Add any tool calls. - if (message.ToolCalls is { Length: > 0 }) - { - foreach (var toolCall in message.ToolCalls) - { - if (toolCall.Function is { } function) - { - contents.Add(ToFunctionCallContent(function)); - } - } - } - - // Ollama frequently sends back empty content with tool calls. Rather than always adding an empty - // content, we only add the content if either it's not empty or there weren't any tool calls. - if (message.Content?.Length > 0 || contents.Count == 0) - { - contents.Insert(0, new TextContent(message.Content)); - } - - // Ollama doesn't have per-message IDs, so use the response ID in the same way we do when streaming - return new ChatMessage(new(message.Role), contents) { MessageId = responseId }; - } - - private static FunctionCallContent ToFunctionCallContent(OllamaFunctionToolCall function) - { -#if NET - var id = System.Security.Cryptography.RandomNumberGenerator.GetHexString(8); -#else - var id = Guid.NewGuid().ToString().Substring(0, 8); -#endif - return new FunctionCallContent(id, function.Name, function.Arguments); - } - - private static JsonElement? ToOllamaChatResponseFormat(ChatResponseFormat? format) - { - if (format is ChatResponseFormatJson jsonFormat) - { - return _schemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) ?? _schemalessJsonResponseFormatValue; - } - else - { - return null; - } - } - - private OllamaChatRequest ToOllamaChatRequest(IEnumerable messages, ChatOptions? options, bool stream) - { - OllamaChatRequest request = new() - { - Format = ToOllamaChatResponseFormat(options?.ResponseFormat), - Messages = messages.SelectMany(ToOllamaChatRequestMessages).ToArray(), - Model = options?.ModelId ?? _metadata.DefaultModelId ?? string.Empty, - Stream = stream, - Tools = options?.ToolMode is not NoneChatToolMode && options?.Tools is { Count: > 0 } tools ? tools.OfType().Select(ToOllamaTool) : null, - }; - - if (options is not null) - { - TransferMetadataValue(nameof(OllamaRequestOptions.embedding_only), (options, value) => options.embedding_only = value); - TransferMetadataValue(nameof(OllamaRequestOptions.f16_kv), (options, value) => options.f16_kv = value); - TransferMetadataValue(nameof(OllamaRequestOptions.logits_all), (options, value) => options.logits_all = value); - TransferMetadataValue(nameof(OllamaRequestOptions.low_vram), (options, value) => options.low_vram = value); - TransferMetadataValue(nameof(OllamaRequestOptions.main_gpu), (options, value) => options.main_gpu = value); - TransferMetadataValue(nameof(OllamaRequestOptions.min_p), (options, value) => options.min_p = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat), (options, value) => options.mirostat = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat_eta), (options, value) => options.mirostat_eta = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat_tau), (options, value) => options.mirostat_tau = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_batch), (options, value) => options.num_batch = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_ctx), (options, value) => options.num_ctx = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_gpu), (options, value) => options.num_gpu = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_keep), (options, value) => options.num_keep = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_thread), (options, value) => options.num_thread = value); - TransferMetadataValue(nameof(OllamaRequestOptions.numa), (options, value) => options.numa = value); - TransferMetadataValue(nameof(OllamaRequestOptions.penalize_newline), (options, value) => options.penalize_newline = value); - TransferMetadataValue(nameof(OllamaRequestOptions.repeat_last_n), (options, value) => options.repeat_last_n = value); - TransferMetadataValue(nameof(OllamaRequestOptions.repeat_penalty), (options, value) => options.repeat_penalty = value); - TransferMetadataValue(nameof(OllamaRequestOptions.tfs_z), (options, value) => options.tfs_z = value); - TransferMetadataValue(nameof(OllamaRequestOptions.typical_p), (options, value) => options.typical_p = value); - TransferMetadataValue(nameof(OllamaRequestOptions.use_mmap), (options, value) => options.use_mmap = value); - TransferMetadataValue(nameof(OllamaRequestOptions.use_mlock), (options, value) => options.use_mlock = value); - TransferMetadataValue(nameof(OllamaRequestOptions.vocab_only), (options, value) => options.vocab_only = value); - - if (options.FrequencyPenalty is float frequencyPenalty) - { - (request.Options ??= new()).frequency_penalty = frequencyPenalty; - } - - if (options.MaxOutputTokens is int maxOutputTokens) - { - (request.Options ??= new()).num_predict = maxOutputTokens; - } - - if (options.PresencePenalty is float presencePenalty) - { - (request.Options ??= new()).presence_penalty = presencePenalty; - } - - if (options.StopSequences is { Count: > 0 }) - { - (request.Options ??= new()).stop = [.. options.StopSequences]; - } - - if (options.Temperature is float temperature) - { - (request.Options ??= new()).temperature = temperature; - } - - if (options.TopP is float topP) - { - (request.Options ??= new()).top_p = topP; - } - - if (options.TopK is int topK) - { - (request.Options ??= new()).top_k = topK; - } - - if (options.Seed is long seed) - { - (request.Options ??= new()).seed = seed; - } - } - - return request; - - void TransferMetadataValue(string propertyName, Action setOption) - { - if (options.AdditionalProperties?.TryGetValue(propertyName, out T? t) is true) - { - request.Options ??= new(); - setOption(request.Options, t); - } - } - } - - private IEnumerable ToOllamaChatRequestMessages(ChatMessage content) - { - // In general, we return a single request message for each understood content item. - // However, various image models expect both text and images in the same request message. - // To handle that, attach images to a previous text message if one exists. - - OllamaChatRequestMessage? currentTextMessage = null; - foreach (var item in content.Contents) - { - if (item is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) - { - IList images = currentTextMessage?.Images ?? []; - images.Add(dataContent.Base64Data.ToString()); - - if (currentTextMessage is not null) - { - currentTextMessage.Images = images; - } - else - { - yield return new OllamaChatRequestMessage - { - Role = content.Role.Value, - Images = images, - }; - } - } - else - { - if (currentTextMessage is not null) - { - yield return currentTextMessage; - currentTextMessage = null; - } - - switch (item) - { - case TextContent textContent: - currentTextMessage = new OllamaChatRequestMessage - { - Role = content.Role.Value, - Content = textContent.Text, - }; - break; - - case FunctionCallContent fcc: - { - yield return new OllamaChatRequestMessage - { - Role = "assistant", - Content = JsonSerializer.Serialize(new OllamaFunctionCallContent - { - CallId = fcc.CallId, - Name = fcc.Name, - Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary))), - }, JsonContext.Default.OllamaFunctionCallContent) - }; - break; - } - - case FunctionResultContent frc: - { - JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); - yield return new OllamaChatRequestMessage - { - Role = "tool", - Content = JsonSerializer.Serialize(new OllamaFunctionResultContent - { - CallId = frc.CallId, - Result = jsonResult, - }, JsonContext.Default.OllamaFunctionResultContent) - }; - break; - } - } - } - } - - if (currentTextMessage is not null) - { - yield return currentTextMessage; - } - } - - private static OllamaTool ToOllamaTool(AIFunction function) - { - return new() - { - Type = "function", - Function = new OllamaFunctionTool - { - Name = function.Name, - Description = function.Description, - Parameters = JsonSerializer.Deserialize(_schemaTransformCache.GetOrCreateTransformedSchema(function), JsonContext.Default.OllamaFunctionToolParameters)!, - } - }; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs deleted file mode 100644 index a5b23d567a4..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatRequest -{ - public required string Model { get; set; } - public required OllamaChatRequestMessage[] Messages { get; set; } - public JsonElement? Format { get; set; } - public bool Stream { get; set; } - public IEnumerable? Tools { get; set; } - public OllamaRequestOptions? Options { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs deleted file mode 100644 index 5a377b1eb34..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatRequestMessage -{ - public required string Role { get; set; } - public string? Content { get; set; } - public IList? Images { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs deleted file mode 100644 index 8c39f9ab598..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatResponse -{ - public string? Model { get; set; } - public string? CreatedAt { get; set; } - public long? TotalDuration { get; set; } - public long? LoadDuration { get; set; } - public string? DoneReason { get; set; } - public int? PromptEvalCount { get; set; } - public long? PromptEvalDuration { get; set; } - public int? EvalCount { get; set; } - public long? EvalDuration { get; set; } - public OllamaChatResponseMessage? Message { get; set; } - public bool Done { get; set; } - public string? Error { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs deleted file mode 100644 index bf73c08d793..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatResponseMessage -{ - public required string Role { get; set; } - public required string Content { get; set; } - public OllamaToolCall[]? ToolCalls { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs deleted file mode 100644 index 0b0d4d3b344..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable S3358 // Ternary operators should not be nested - -namespace Microsoft.Extensions.AI; - -/// Represents an for Ollama. -public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator> -{ - /// Metadata about the embedding generator. - private readonly EmbeddingGeneratorMetadata _metadata; - - /// The api/embeddings endpoint URI. - private readonly Uri _apiEmbeddingsEndpoint; - - /// The to use for sending requests. - private readonly HttpClient _httpClient; - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - public OllamaEmbeddingGenerator(string endpoint, string? modelId = null, HttpClient? httpClient = null) - : this(new Uri(Throw.IfNull(endpoint)), modelId, httpClient) - { - } - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - /// is . - /// is empty or composed entirely of whitespace. - public OllamaEmbeddingGenerator(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) - { - _ = Throw.IfNull(endpoint); - if (modelId is not null) - { - _ = Throw.IfNullOrWhitespace(modelId); - } - - _apiEmbeddingsEndpoint = new Uri(endpoint, "api/embed"); - _httpClient = httpClient ?? OllamaUtilities.SharedClient; - _metadata = new("ollama", endpoint, modelId); - } - - /// - object? IEmbeddingGenerator.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public void Dispose() - { - if (_httpClient != OllamaUtilities.SharedClient) - { - _httpClient.Dispose(); - } - } - - /// - public async Task>> GenerateAsync( - IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(values); - - // Create request. - string[] inputs = values.ToArray(); - string? requestModel = options?.ModelId ?? _metadata.DefaultModelId; - var request = new OllamaEmbeddingRequest - { - Model = requestModel ?? string.Empty, - Input = inputs, - }; - - if (options?.AdditionalProperties is { } requestProps) - { - if (requestProps.TryGetValue("keep_alive", out long keepAlive)) - { - request.KeepAlive = keepAlive; - } - - if (requestProps.TryGetValue("truncate", out bool truncate)) - { - request.Truncate = truncate; - } - } - - // Send request and get response. - var httpResponse = await _httpClient.PostAsJsonAsync( - _apiEmbeddingsEndpoint, - request, - JsonContext.Default.OllamaEmbeddingRequest, - cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - var response = (await httpResponse.Content.ReadFromJsonAsync( - JsonContext.Default.OllamaEmbeddingResponse, - cancellationToken).ConfigureAwait(false))!; - - // Validate response. - if (!string.IsNullOrEmpty(response.Error)) - { - throw new InvalidOperationException($"Ollama error: {response.Error}"); - } - - if (response.Embeddings is null || response.Embeddings.Length != inputs.Length) - { - throw new InvalidOperationException($"Ollama generated {response.Embeddings?.Length ?? 0} embeddings but {inputs.Length} were expected."); - } - - // Convert response into result objects. - AdditionalPropertiesDictionary? additionalCounts = null; - OllamaUtilities.TransferNanosecondsTime(response, r => r.TotalDuration, "total_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, r => r.LoadDuration, "load_duration", ref additionalCounts); - - UsageDetails? usage = null; - if (additionalCounts is not null || response.PromptEvalCount is not null) - { - usage = new() - { - InputTokenCount = response.PromptEvalCount, - TotalTokenCount = response.PromptEvalCount, - AdditionalCounts = additionalCounts, - }; - } - - return new(response.Embeddings.Select(e => - new Embedding(e) - { - CreatedAt = DateTimeOffset.UtcNow, - ModelId = response.Model ?? requestModel, - })) - { - Usage = usage, - }; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs deleted file mode 100644 index 07e3530b8ed..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaEmbeddingRequest -{ - public required string Model { get; set; } - public required string[] Input { get; set; } - public OllamaRequestOptions? Options { get; set; } - public bool? Truncate { get; set; } - public long? KeepAlive { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs deleted file mode 100644 index c4fd2cde87c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaEmbeddingResponse -{ - [JsonPropertyName("model")] - public string? Model { get; set; } - [JsonPropertyName("embeddings")] - public float[][]? Embeddings { get; set; } - [JsonPropertyName("total_duration")] - public long? TotalDuration { get; set; } - [JsonPropertyName("load_duration")] - public long? LoadDuration { get; set; } - [JsonPropertyName("prompt_eval_count")] - public int? PromptEvalCount { get; set; } - public string? Error { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs deleted file mode 100644 index f518413586a..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionCallContent -{ - public string? CallId { get; set; } - public string? Name { get; set; } - public JsonElement Arguments { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs deleted file mode 100644 index ba3eab607b8..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionResultContent -{ - public string? CallId { get; set; } - public JsonElement Result { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs deleted file mode 100644 index 880e37bec2a..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionTool -{ - public required string Name { get; set; } - public required string Description { get; set; } - public required OllamaFunctionToolParameters Parameters { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs deleted file mode 100644 index c94d41bd3f3..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolCall -{ - public required string Name { get; set; } - public IDictionary? Arguments { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs deleted file mode 100644 index 77ba2a5561c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolParameter -{ - public string? Type { get; set; } - public string? Description { get; set; } - public IEnumerable? Enum { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs deleted file mode 100644 index 9fa7d0d2adc..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolParameters -{ - public string Type { get; set; } = "object"; - public required IDictionary Properties { get; set; } - public IList? Required { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs deleted file mode 100644 index cc8b548c1a1..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -#pragma warning disable IDE1006 // Naming Styles - -internal sealed class OllamaRequestOptions -{ - public bool? embedding_only { get; set; } - public bool? f16_kv { get; set; } - public float? frequency_penalty { get; set; } - public bool? logits_all { get; set; } - public bool? low_vram { get; set; } - public int? main_gpu { get; set; } - public float? min_p { get; set; } - public int? mirostat { get; set; } - public float? mirostat_eta { get; set; } - public float? mirostat_tau { get; set; } - public int? num_batch { get; set; } - public int? num_ctx { get; set; } - public int? num_gpu { get; set; } - public int? num_keep { get; set; } - public int? num_predict { get; set; } - public int? num_thread { get; set; } - public bool? numa { get; set; } - public bool? penalize_newline { get; set; } - public float? presence_penalty { get; set; } - public int? repeat_last_n { get; set; } - public float? repeat_penalty { get; set; } - public long? seed { get; set; } - public string[]? stop { get; set; } - public float? temperature { get; set; } - public float? tfs_z { get; set; } - public int? top_k { get; set; } - public float? top_p { get; set; } - public float? typical_p { get; set; } - public bool? use_mlock { get; set; } - public bool? use_mmap { get; set; } - public bool? vocab_only { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs deleted file mode 100644 index 457793dc476..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaTool -{ - public required string Type { get; set; } - public required OllamaFunctionTool Function { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs deleted file mode 100644 index ea2625bd50e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI; - -internal static class OllamaUtilities -{ - /// Gets a singleton used when no other instance is supplied. - public static HttpClient SharedClient { get; } = new() - { - // Expected use is localhost access for non-production use. Typical production use should supply - // an HttpClient configured with whatever more robust resilience policy / handlers are appropriate. - Timeout = Timeout.InfiniteTimeSpan, - }; - - public static void TransferNanosecondsTime(TResponse response, Func getNanoseconds, string key, ref AdditionalPropertiesDictionary? metadata) - { - if (getNanoseconds(response) is long duration) - { - try - { - (metadata ??= [])[key] = duration; - } - catch (OverflowException) - { - // Ignore options that don't convert - } - } - } - - [DoesNotReturn] - public static async ValueTask ThrowUnsuccessfulOllamaResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) - { - Debug.Assert(!response.IsSuccessStatusCode, "must only be invoked for unsuccessful responses."); - - // Read the entire response content into a string. - string errorContent = -#if NET - await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); -#else - await response.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif - - // The response content *could* be JSON formatted, try to extract the error field. - -#pragma warning disable CA1031 // Do not catch general exception types - try - { - using JsonDocument document = JsonDocument.Parse(errorContent); - if (document.RootElement.TryGetProperty("error", out JsonElement errorElement) && - errorElement.ValueKind is JsonValueKind.String) - { - errorContent = errorElement.GetString()!; - } - } - catch - { - // Ignore JSON parsing errors. - } -#pragma warning restore CA1031 // Do not catch general exception types - - throw new InvalidOperationException($"Ollama error: {errorContent}"); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md deleted file mode 100644 index 0b7f43a5b89..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md +++ /dev/null @@ -1,280 +0,0 @@ -# Microsoft.Extensions.AI.Ollama - -Provides an implementation of the `IChatClient` interface for Ollama. - -## Install the package - -From the command-line: - -```console -dotnet add package Microsoft.Extensions.AI.Ollama -``` - -Or directly in the C# project file: - -```xml - - - -``` - -## Usage Examples - -### Chat - -```csharp -using Microsoft.Extensions.AI; - -IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -Console.WriteLine(await client.GetResponseAsync("What is AI?")); -``` - -### Chat + Conversation History - -```csharp -using Microsoft.Extensions.AI; - -IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -Console.WriteLine(await client.GetResponseAsync( -[ - new ChatMessage(ChatRole.System, "You are a helpful AI assistant"), - new ChatMessage(ChatRole.User, "What is AI?"), -])); -``` - -### Chat Streaming - -```csharp -using Microsoft.Extensions.AI; - -IChatClient client = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -await foreach (var update in client.GetStreamingResponseAsync("What is AI?")) -{ - Console.Write(update); -} -``` - -### Tool Calling - -Known limitations: - -- Only a subset of models provided by Ollama support tool calling. -- Tool calling is currently not supported with streaming requests. - -```csharp -using System.ComponentModel; -using Microsoft.Extensions.AI; - -IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -IChatClient client = new ChatClientBuilder(ollamaClient) - .UseFunctionInvocation() - .Build(); - -ChatOptions chatOptions = new() -{ - Tools = [AIFunctionFactory.Create(GetWeather)] -}; - -Console.WriteLine(await client.GetResponseAsync("Do I need an umbrella?", chatOptions)); - -[Description("Gets the weather")] -static string GetWeather() => Random.Shared.NextDouble() > 0.5 ? "It's sunny" : "It's raining"; -``` - -### Caching - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); - -IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -IChatClient client = new ChatClientBuilder(ollamaClient) - .UseDistributedCache(cache) - .Build(); - -for (int i = 0; i < 3; i++) -{ - await foreach (var message in client.GetStreamingResponseAsync("In less than 100 words, what is AI?")) - { - Console.Write(message); - } - - Console.WriteLine(); - Console.WriteLine(); -} -``` - -### Telemetry - -```csharp -using Microsoft.Extensions.AI; -using OpenTelemetry.Trace; - -// Configure OpenTelemetry exporter -var sourceName = Guid.NewGuid().ToString(); -var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddConsoleExporter() - .Build(); - -IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -IChatClient client = new ChatClientBuilder(ollamaClient) - .UseOpenTelemetry(sourceName: sourceName, configure: c => c.EnableSensitiveData = true) - .Build(); - -Console.WriteLine(await client.GetResponseAsync("What is AI?")); -``` - -### Telemetry, Caching, and Tool Calling - -```csharp -using System.ComponentModel; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using OpenTelemetry.Trace; - -// Configure telemetry -var sourceName = Guid.NewGuid().ToString(); -var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource(sourceName) - .AddConsoleExporter() - .Build(); - -// Configure caching -IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); - -// Configure tool calling -var chatOptions = new ChatOptions -{ - Tools = [AIFunctionFactory.Create(GetPersonAge)] -}; - -IChatClient ollamaClient = new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1"); - -IChatClient client = new ChatClientBuilder(ollamaClient) - .UseDistributedCache(cache) - .UseFunctionInvocation() - .UseOpenTelemetry(sourceName: sourceName, configure: c => c.EnableSensitiveData = true) - .Build(); - -for (int i = 0; i < 3; i++) -{ - Console.WriteLine(await client.GetResponseAsync("How much older is Alice than Bob?", chatOptions)); -} - -[Description("Gets the age of a person specified by name.")] -static int GetPersonAge(string personName) => - personName switch - { - "Alice" => 42, - "Bob" => 35, - _ => 26, - }; -``` - -### Text embedding generation - -```csharp -using Microsoft.Extensions.AI; - -IEmbeddingGenerator> generator = - new OllamaEmbeddingGenerator(new Uri("http://localhost:11434/"), "all-minilm"); - -var embeddings = await generator.GenerateAsync("What is AI?"); - -Console.WriteLine(string.Join(", ", embeddings[0].Vector.ToArray())); -``` - -### Text embedding generation with caching - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); - -IEmbeddingGenerator> ollamaGenerator = - new OllamaEmbeddingGenerator(new Uri("http://localhost:11434/"), "all-minilm"); - -IEmbeddingGenerator> generator = new EmbeddingGeneratorBuilder>(ollamaGenerator) - .UseDistributedCache(cache) - .Build(); - -foreach (var prompt in new[] { "What is AI?", "What is .NET?", "What is AI?" }) -{ - var embeddings = await generator.GenerateAsync(prompt); - - Console.WriteLine(string.Join(", ", embeddings[0].Vector.ToArray())); -} -``` - -### Dependency Injection - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -// App Setup -var builder = Host.CreateApplicationBuilder(); -builder.Services.AddDistributedMemoryCache(); -builder.Services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Trace)); - -builder.Services.AddChatClient(new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1")) - .UseDistributedCache() - .UseLogging(); - -var app = builder.Build(); - -// Elsewhere in the app -var chatClient = app.Services.GetRequiredService(); -Console.WriteLine(await chatClient.GetResponseAsync("What is AI?")); -``` - -### Minimal Web API - -```csharp -using Microsoft.Extensions.AI; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddChatClient( - new OllamaChatClient(new Uri("http://localhost:11434/"), "llama3.1")); - -builder.Services.AddEmbeddingGenerator(new OllamaEmbeddingGenerator(endpoint, "all-minilm")); - -var app = builder.Build(); - -app.MapPost("/chat", async (IChatClient client, string message) => -{ - var response = await client.GetResponseAsync(message, cancellationToken: default); - return response.Message; -}); - -app.MapPost("/embedding", async (IEmbeddingGenerator> client, string message) => -{ - var response = await client.GenerateAsync(message); - return response[0].Vector; -}); - -app.Run(); -``` - -## Feedback & Contributing - -We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index ad915d06aa7..491624332da 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -1,16 +1,86 @@ # Release History +## 9.10.2-preview.1.25552.1 + +- Updated to depend on OpenAI 2.6.0. +- Updated the OpenAI Responses `IChatClient` to allow either conversation or response ID for `ChatOptions.ConversationId`. +- Updated the OpenAI Responses `IChatClient` to support `AIFunction`s that return `AIContent` like `DataContent`. +- Updated the OpenAI Chat Completion `IChatClient`, the Responses `IChatClient`, and the `IEmbeddingGenerator` to support per-request `ModelId` overrides. +- Added an `AITool` to `ResponseTool` conversion utility. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.10.1-preview.1.25521.4 + +- Updated the OpenAI Responses `IChatClient` to support connectors with `HostedMcpServerTool`. +- Fixed the OpenAI Responses `IChatClient` to roundtrip a `ResponseItem` stored in an `AIContent` in a `ChatRole.User` message. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.10.0-preview.1.25513.3 + +- Fixed issue with the OpenAI Assistants `IChatClient` where a chat history including unrelated function calls would cause an exception. +- Fixed issue with the OpenAI Assistants `IChatClient` sending a tool in `ChatOptions.Tools` that had the same name as a function configured with the Assistant would cause an exception. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.9.1-preview.1.25474.6 + +- Updated to depend on OpenAI 2.5.0. +- Added M.E.AI to OpenAI conversions for response format types. +- Added `ResponseTool` to `AITool` conversions. +- Fixed the handling of `HostedCodeInterpreterTool` with Responses when no file IDs were provided. +- Fixed an issue where requests would fail when AllowMultipleToolCalls was set with no tools provided. +- Fixed an issue where requests would fail when an AuthorName was provided containing invalid characters. + +## 9.9.0-preview.1.25458.4 + +- Updated to depend on OpenAI 2.4.0. +- Updated tool mappings to recognize any `AIFunctionDeclaration`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. +- Fixed handling of annotated but empty content in the `AsIChatClient` for `AssistantClient`. + +## 9.8.0-preview.1.25412.6 + +- Updated to depend on OpenAI 2.3.0. +- Added more conversion helpers for converting bidirectionally between Microsoft.Extensions.AI messages and OpenAI messages. +- Fixed handling of multiple response messages in the OpenAI Responses `IChatClient`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.1-preview.1.25365.4 + +- Added some conversion helpers for converting Microsoft.Extensions.AI messages to OpenAI messages. +- Enabled specifying "strict" via ChatOptions for OpenAI clients. + +## 9.7.0-preview.1.25356.2 + +- Updated to depend on OpenAI 2.2.0. +- Added conversion helpers from `AIFunction` to various OpenAI tool representations. +- Added `AsIChatClient` extension method for OpenAI's `AssistantClient`, enabling `IChatClient` to be used with OpenAI Assistants. +- Tweaked how JSON schemas for functions are transformed for better compatibility with OpenAI `strict` constraints. +- Improved handling of `RawRepresentation` in `IChatClients` for Responses and Chat Completion APIs. +- Improved `ISpeechToTextClient` implementation to support streaming transcriptions. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.6.0-preview.1.25310.2 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.5.0-preview.1.25265.7 + +- Added PDF support to `IChatClient` implementations. +- Disabled use of `strict` schema handling by default. +- Added support for creating `ErrorContent` in `IChatClient` implementations, such as for refusals. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + ## 9.4.4-preview.1.25259.16 - Made `IChatClient` implementation more resilient with non-OpenAI services. - Added `ErrorContent` to represent refusals. -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.4.3-preview.1.25230.7 - Reverted previous change that enabled `strict` schemas by default. - Updated `IChatClient` implementations to support `DataContent`s for PDFs. -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.4.0-preview.1.25207.5 @@ -20,11 +90,11 @@ - Removed the public `OpenAIChatClient`/`OpenAIEmbeddingGenerator` types. These are only created now via the extension methods. - Removed serialization/deserialization helpers. - Updated to support pulling propagating image detail from `AdditionalProperties`. -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.3.0-preview.1.25161.3 -- Updated to accomodate the changes in `Microsoft.Extensions.AI.Abstractions`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. ## 9.3.0-preview.1.25114.11 diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index 552d45f0fc6..1cad3704cbb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -1,9 +1,10 @@ - + Microsoft.Extensions.AI Implementation of generative AI abstractions for OpenAI-compatible endpoints. AI + true @@ -15,8 +16,8 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002;OPENAI002 - $(NoWarn);MEAI001 + $(NoWarn);CA1063 + $(NoWarn);OPENAI001;OPENAI002;MEAI001;SCME0001 true true true @@ -26,8 +27,6 @@ true true true - true - true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs new file mode 100644 index 00000000000..793c906bd9d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Assistants; + +/// Provides extension methods for working with content associated with OpenAI.Assistants. +public static class MicrosoftExtensionsAIAssistantsExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunctionDeclaration function) => + OpenAIAssistantsChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs new file mode 100644 index 00000000000..acdc42be3e0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs @@ -0,0 +1,290 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Chat; + +/// Provides extension methods for working with content associated with OpenAI.Chat. +public static class MicrosoftExtensionsAIChatExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ChatTool AsOpenAIChatTool(this AIFunctionDeclaration function) => + OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); + + /// + /// Creates an OpenAI from a . + /// + /// The format. + /// The options to use when interpreting the format. + /// The converted OpenAI . + public static ChatResponseFormat? AsOpenAIChatResponseFormat(this Microsoft.Extensions.AI.ChatResponseFormat? format, ChatOptions? options = null) => + OpenAIChatClient.ToOpenAIChatResponseFormat(format, options); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// The options employed while processing . + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages, ChatOptions? options = null) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), options); + + /// Creates an OpenAI from a . + /// The to convert to a . + /// A converted . + /// is . + public static ChatCompletion AsOpenAIChatCompletion(this ChatResponse response) + { + _ = Throw.IfNull(response); + + if (response.RawRepresentation is ChatCompletion chatCompletion) + { + return chatCompletion; + } + + var lastMessage = response.Messages.LastOrDefault(); + + ChatMessageRole role = ToChatMessageRole(lastMessage?.Role); + + ChatFinishReason finishReason = ToChatFinishReason(response.FinishReason); + + ChatTokenUsage usage = OpenAIChatModelFactory.ChatTokenUsage( + (int?)response.Usage?.OutputTokenCount ?? 0, + (int?)response.Usage?.InputTokenCount ?? 0, + (int?)response.Usage?.TotalTokenCount ?? 0); + + IEnumerable? toolCalls = lastMessage?.Contents + .OfType().Select(c => ChatToolCall.CreateFunctionToolCall(c.CallId, c.Name, + new BinaryData(JsonSerializer.SerializeToUtf8Bytes(c.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); + + return OpenAIChatModelFactory.ChatCompletion( + response.ResponseId, + finishReason, + new(OpenAIChatClient.ToOpenAIChatContent(lastMessage?.Contents ?? [])), + toolCalls: toolCalls, + role: role, + createdAt: response.CreatedAt ?? default, + model: response.ModelId, + usage: usage, + outputAudio: lastMessage?.Contents.OfType().Where(dc => dc.HasTopLevelMediaType("audio")).Select(a => OpenAIChatModelFactory.ChatOutputAudio(new(a.Data))).FirstOrDefault(), + messageAnnotations: ConvertAnnotations(lastMessage?.Contents)); + + static IEnumerable ConvertAnnotations(IEnumerable? contents) + { + if (contents is null) + { + yield break; + } + + foreach (var content in contents) + { + if (content.Annotations is null) + { + continue; + } + + foreach (var annotation in content.Annotations) + { + if (annotation is not CitationAnnotation citation) + { + continue; + } + + if (citation.AnnotatedRegions?.OfType().ToArray() is { Length: > 0 } regions) + { + foreach (var region in regions) + { + yield return OpenAIChatModelFactory.ChatMessageAnnotation(region.StartIndex ?? 0, region.EndIndex ?? 0, citation.Url, citation.Title); + } + } + else + { + yield return OpenAIChatModelFactory.ChatMessageAnnotation(0, 0, citation.Url, citation.Title); + } + } + } + } + } + + /// + /// Creates a sequence of OpenAI instances from the specified + /// sequence of instances. + /// + /// The update instances. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static async IAsyncEnumerable AsOpenAIStreamingChatCompletionUpdatesAsync( + this IAsyncEnumerable responseUpdates, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(responseUpdates); + + await foreach (var update in responseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (update.RawRepresentation is StreamingChatCompletionUpdate streamingUpdate) + { + yield return streamingUpdate; + continue; + } + + var usage = update.Contents.FirstOrDefault(c => c is UsageContent) is UsageContent usageContent ? + OpenAIChatModelFactory.ChatTokenUsage( + (int?)usageContent.Details.OutputTokenCount ?? 0, + (int?)usageContent.Details.InputTokenCount ?? 0, + (int?)usageContent.Details.TotalTokenCount ?? 0) : + null; + + var toolCallUpdates = update.Contents.OfType().Select((fcc, index) => + OpenAIChatModelFactory.StreamingChatToolCallUpdate( + index, fcc.CallId, ChatToolCallKind.Function, fcc.Name, + new(JsonSerializer.SerializeToUtf8Bytes(fcc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))) + .ToList(); + + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + update.ResponseId, + new(OpenAIChatClient.ToOpenAIChatContent(update.Contents)), + toolCallUpdates: toolCallUpdates, + role: ToChatMessageRole(update.Role), + finishReason: ToChatFinishReason(update.FinishReason), + createdAt: update.CreatedAt ?? default, + model: update.ModelId, + usage: usage); + } + } + + /// Creates a sequence of instances from the specified input messages. + /// The input messages to convert. + /// A sequence of Microsoft.Extensions.AI chat messages. + /// is . + public static IEnumerable AsChatMessages(this IEnumerable messages) + { + _ = Throw.IfNull(messages); + + foreach (var message in messages) + { + Microsoft.Extensions.AI.ChatMessage resultMessage = new() + { + RawRepresentation = message, + }; + + switch (message) + { + case AssistantChatMessage acm: + resultMessage.Role = ChatRole.Assistant; + resultMessage.AuthorName = acm.ParticipantName; + OpenAIChatClient.ConvertContentParts(acm.Content, resultMessage.Contents); + foreach (var toolCall in acm.ToolCalls) + { + var fcc = OpenAIClientExtensions.ParseCallContent(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + fcc.RawRepresentation = toolCall; + resultMessage.Contents.Add(fcc); + } + + break; + + case UserChatMessage ucm: + resultMessage.Role = ChatRole.User; + resultMessage.AuthorName = ucm.ParticipantName; + OpenAIChatClient.ConvertContentParts(ucm.Content, resultMessage.Contents); + break; + + case DeveloperChatMessage dcm: + resultMessage.Role = ChatRole.System; + resultMessage.AuthorName = dcm.ParticipantName; + OpenAIChatClient.ConvertContentParts(dcm.Content, resultMessage.Contents); + break; + + case SystemChatMessage scm: + resultMessage.Role = ChatRole.System; + resultMessage.AuthorName = scm.ParticipantName; + OpenAIChatClient.ConvertContentParts(scm.Content, resultMessage.Contents); + break; + + case ToolChatMessage tcm: + resultMessage.Role = ChatRole.Tool; + resultMessage.Contents.Add(new FunctionResultContent(tcm.ToolCallId, ToToolResult(tcm.Content)) + { + RawRepresentation = tcm, + }); + + static object ToToolResult(ChatMessageContent content) + { + if (content.Count == 1 && content[0] is { Text: { } text }) + { + return text; + } + + MemoryStream ms = new(); + using Utf8JsonWriter writer = new(ms, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + foreach (IJsonModel part in content) + { + part.Write(writer, ModelReaderWriterOptions.Json); + } + + return JsonSerializer.Deserialize(ms.GetBuffer().AsSpan(0, (int)ms.Position), AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!; + } + + break; + } + + yield return resultMessage; + } + } + + /// Creates a Microsoft.Extensions.AI from a . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + /// is . + public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) => + OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options); + + /// + /// Creates a sequence of Microsoft.Extensions.AI instances from the specified + /// sequence of OpenAI instances. + /// + /// The update instances. + /// The options employed in the creation of the response. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static IAsyncEnumerable AsChatResponseUpdatesAsync( + this IAsyncEnumerable chatCompletionUpdates, ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => + OpenAIChatClient.FromOpenAIStreamingChatCompletionAsync(Throw.IfNull(chatCompletionUpdates), options, cancellationToken); + + /// Converts the to a . + private static ChatMessageRole ToChatMessageRole(ChatRole? role) => + role?.Value switch + { + "user" => ChatMessageRole.User, + "function" => ChatMessageRole.Function, + "tool" => ChatMessageRole.Tool, + "developer" => ChatMessageRole.Developer, + "system" => ChatMessageRole.System, + _ => ChatMessageRole.Assistant, + }; + + /// Converts the to a . + private static ChatFinishReason ToChatFinishReason(Microsoft.Extensions.AI.ChatFinishReason? finishReason) => + finishReason?.Value switch + { + "length" => ChatFinishReason.Length, + "content_filter" => ChatFinishReason.ContentFilter, + "tool_calls" => ChatFinishReason.ToolCalls, + "function_call" => ChatFinishReason.FunctionCall, + _ => ChatFinishReason.Stop, + }; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs new file mode 100644 index 00000000000..903c6253dde --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Realtime; + +/// Provides extension methods for working with content associated with OpenAI.Realtime. +public static class MicrosoftExtensionsAIRealtimeExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunctionDeclaration function) => + OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs new file mode 100644 index 00000000000..6d989c0b56d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3254 // Default parameter values should not be passed as arguments + +namespace OpenAI.Responses; + +/// Provides extension methods for working with content associated with OpenAI.Responses. +public static class MicrosoftExtensionsAIResponsesExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration function) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(function)); + + /// Creates an OpenAI from an . + /// The tool to convert. + /// An OpenAI representing or if there is no mapping. + /// is . + /// + /// This method is only able to create s for types + /// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library. + /// + public static ResponseTool? AsOpenAIResponseTool(this AITool tool) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool)); + + /// + /// Creates an OpenAI from a . + /// + /// The format. + /// The options to use when interpreting the format. + /// The converted OpenAI . + public static ResponseTextFormat? AsOpenAIResponseTextFormat(this ChatResponseFormat? format, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToOpenAIResponseTextFormat(format, options); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// The options employed while processing . + /// A sequence of OpenAI response items. + /// is . + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages), options); + + /// Creates a sequence of instances from the specified input items. + /// The input messages to convert. + /// A sequence of instances. + /// is . + public static IEnumerable AsChatMessages(this IEnumerable items) => + OpenAIResponsesChatClient.ToChatMessages(Throw.IfNull(items)); + + /// Creates a Microsoft.Extensions.AI from an . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + /// is . + public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => + OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options, conversationId: null); + + /// + /// Creates a sequence of Microsoft.Extensions.AI instances from the specified + /// sequence of OpenAI instances. + /// + /// The update instances. + /// The options employed in the creation of the response. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static IAsyncEnumerable AsChatResponseUpdatesAsync( + this IAsyncEnumerable responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => + OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, conversationId: null, cancellationToken: cancellationToken); + + /// Creates an OpenAI from a . + /// The response to convert. + /// The options employed in the creation of the response. + /// The created . + public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOptions? options = null) + { + _ = Throw.IfNull(response); + + if (response.RawRepresentation is OpenAIResponse openAIResponse) + { + return openAIResponse; + } + + return OpenAIResponsesModelFactory.OpenAIResponse( + response.ResponseId, + response.CreatedAt ?? default, + ResponseStatus.Completed, + usage: null, // No way to construct a ResponseTokenUsage right now from external to the OpenAI library + maxOutputTokenCount: options?.MaxOutputTokens, + outputItems: OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options), + parallelToolCallsEnabled: options?.AllowMultipleToolCalls ?? false, + model: response.ModelId ?? options?.ModelId, + temperature: options?.Temperature, + topP: options?.TopP, + previousResponseId: options?.ConversationId, + instructions: options?.Instructions); + } + + /// Adds the to the list of s. + /// The list of s to which the provided tool should be added. + /// The to add. + /// + /// does not derive from , so it cannot be added directly to a list of s. + /// Instead, this method wraps the provided in an and adds that to the list. + /// The returned by will + /// be able to unwrap the when it processes the list of tools and use the provided as-is. + /// + public static void Add(this IList tools, ResponseTool tool) + { + _ = Throw.IfNull(tools); + + tools.Add(AsAITool(tool)); + } + + /// Creates an to represent a raw . + /// The tool to wrap as an . + /// The wrapped as an . + /// + /// + /// The returned tool is only suitable for use with the returned by + /// (or s that delegate + /// to such an instance). It is likely to be ignored by any other implementation. + /// + /// + /// When a tool has a corresponding -derived type already defined in Microsoft.Extensions.AI, + /// such as , , , or + /// , those types should be preferred instead of this method, as they are more portable, + /// capable of being respected by any implementation. This method does not attempt to + /// map the supplied to any of those types, it simply wraps it as-is: + /// the returned by will + /// be able to unwrap the when it processes the list of tools. + /// + /// + public static AITool AsAITool(this ResponseTool tool) + { + _ = Throw.IfNull(tool); + + return new OpenAIResponsesChatClient.ResponseToolAITool(tool); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs new file mode 100644 index 00000000000..065ad80d23a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -0,0 +1,620 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI.Assistants; + +#pragma warning disable SA1005 // Single line comments should begin with single space +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S1751 // Loops with at most one iteration should be refactored +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// Represents an for an OpenAI . +internal sealed class OpenAIAssistantsChatClient : IChatClient +{ + /// The underlying . + private readonly AssistantClient _client; + + /// Metadata for the client. + private readonly ChatClientMetadata _metadata; + + /// The ID of the agent to use. + private readonly string _assistantId; + + /// The thread ID to use if none is supplied in . + private readonly string? _defaultThreadId; + + /// List of tools associated with the assistant. + private IReadOnlyList? _assistantTools; + + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) + { + _client = Throw.IfNull(assistantClient); + _assistantId = Throw.IfNullOrWhitespace(assistantId); + _defaultThreadId = defaultThreadId; + + _metadata = new("openai", assistantClient.Endpoint); + } + + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId) + : this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId) + { + _assistantTools = assistant.Tools; + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : + serviceKey is not null ? null : + serviceType == typeof(ChatClientMetadata) ? _metadata : + serviceType == typeof(AssistantClient) ? _client : + serviceType.IsInstanceOfType(this) ? this : + null; + + /// + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken); + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Extract necessary state from messages and options. + (RunCreationOptions runOptions, ToolResources? toolResources, List? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false); + + // Get the thread ID. + string? threadId = options?.ConversationId ?? _defaultThreadId; + + // Get any active run ID for this thread. This is necessary in case a thread has been left with an + // active run, in which case all attempts other than submitting tools will fail. We thus need to cancel + // any active run on the thread if we're not submitting tool results to it. + ThreadRun? threadRun = null; + if (threadId is not null) + { + await foreach (var run in _client.GetRunsAsync( + threadId, + new RunCollectionOptions { Order = RunCollectionOrder.Descending, PageSizeLimit = 1 }, + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired) + { + threadRun = run; + } + + break; + } + } + + // Submit the request. + IAsyncEnumerable updates; + if (threadRun is not null && + ConvertFunctionResultsToToolOutput(toolResults, out List? toolOutputs) is { } toolRunId && + toolRunId == threadRun.Id) + { + // There's an active run and, critically, we have tool results to submit for that exact run, so submit the results and continue streaming. + // This is going to ignore any additional messages in the run options, as we are only submitting tool outputs, + // but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare. + updates = _client.SubmitToolOutputsToRunStreamingAsync(threadRun.ThreadId, threadRun.Id, toolOutputs, cancellationToken); + } + else + { + if (threadId is null) + { + // No thread ID was provided, so create a new thread. + ThreadCreationOptions threadCreationOptions = new() + { + ToolResources = toolResources, + }; + + foreach (var message in runOptions.AdditionalMessages) + { + threadCreationOptions.InitialMessages.Add(message); + } + + runOptions.AdditionalMessages.Clear(); + + var thread = await _client.CreateThreadAsync(threadCreationOptions, cancellationToken).ConfigureAwait(false); + threadId = thread.Value.Id; + } + else if (threadRun is not null) + { + // There was an active run; we need to cancel it before starting a new run. + _ = await _client.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false); + threadRun = null; + } + + // Now create a new run and stream the results. + updates = _client.CreateRunStreamingAsync( + threadId: threadId, + _assistantId, + runOptions, + cancellationToken); + } + + // Process each update. + string? responseId = null; + await foreach (var update in updates.ConfigureAwait(false)) + { + switch (update) + { + case ThreadUpdate tu: + threadId ??= tu.Value.Id; + goto default; + + case RunUpdate ru: + threadId ??= ru.Value.ThreadId; + responseId ??= ru.Value.Id; + + ChatResponseUpdate ruUpdate = new() + { + AuthorName = _assistantId, + ConversationId = threadId, + CreatedAt = ru.Value.CreatedAt, + MessageId = responseId, + ModelId = ru.Value.Model, + RawRepresentation = ru, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + + if (ru.Value.Usage is { } usage) + { + ruUpdate.Contents.Add(new UsageContent(new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount, + })); + } + + if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) + { + var fcc = OpenAIClientExtensions.ParseCallContent( + rau.FunctionArguments, + JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), + functionName); + fcc.RawRepresentation = ru; + ruUpdate.Contents.Add(fcc); + } + + yield return ruUpdate; + break; + + case RunStepDetailsUpdate details: + if (!string.IsNullOrEmpty(details.CodeInterpreterInput)) + { + CodeInterpreterToolCallContent hcitcc = new() + { + CallId = details.ToolCallId, + Inputs = [new DataContent(Encoding.UTF8.GetBytes(details.CodeInterpreterInput), "text/x-python")], + RawRepresentation = details, + }; + + yield return new ChatResponseUpdate(ChatRole.Assistant, [hcitcc]) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + }; + } + + if (details.CodeInterpreterOutputs is { Count: > 0 }) + { + CodeInterpreterToolResultContent hcitrc = new() + { + CallId = details.ToolCallId, + RawRepresentation = details, + }; + + foreach (var output in details.CodeInterpreterOutputs) + { + if (output.ImageFileId is not null) + { + (hcitrc.Outputs ??= []).Add(new HostedFileContent(output.ImageFileId) { MediaType = "image/*" }); + } + + if (output.Logs is string logs) + { + (hcitrc.Outputs ??= []).Add(new TextContent(logs)); + } + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, [hcitrc]) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + }; + } + break; + + case MessageContentUpdate mcu: + ChatResponseUpdate textUpdate = new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = mcu, + ResponseId = responseId, + }; + + // Add any annotations from the text update. The OpenAI Assistants API does not support passing these back + // into the model (MessageContent.FromXx does not support providing annotations), so they end up being one way and are dropped + // on subsequent requests. + if (mcu.TextAnnotation is { } tau) + { + string? fileId = null; + string? toolName = null; + if (!string.IsNullOrWhiteSpace(tau.InputFileId)) + { + fileId = tau.InputFileId; + toolName = "file_search"; + } + else if (!string.IsNullOrWhiteSpace(tau.OutputFileId)) + { + fileId = tau.OutputFileId; + toolName = "code_interpreter"; + } + + if (fileId is not null) + { + if (textUpdate.Contents.Count == 0) + { + // In case a chunk doesn't have text content, create one with empty text to hold the annotation. + textUpdate.Contents.Add(new TextContent(string.Empty)); + } + + (((TextContent)textUpdate.Contents[0]).Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = tau, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = tau.StartIndex, EndIndex = tau.EndIndex }], + FileId = fileId, + ToolName = toolName, + }); + } + } + + yield return textUpdate; + break; + + default: + yield return new() + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + break; + } + } + } + + /// + void IDisposable.Dispose() + { + // nop + } + + /// Converts an Extensions function to an OpenAI assistants function tool. + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunctionDeclaration aiFunction, ChatOptions? options = null) + { + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return new FunctionToolDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + StrictParameterSchemaEnabled = strict, + }; + } + + /// + /// Creates the to use for the request and extracts any function result contents + /// that need to be submitted as tool results. + /// + private async ValueTask<(RunCreationOptions RunOptions, ToolResources? Resources, List? ToolResults)> CreateRunOptionsAsync( + IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) + { + // Create the options instance to populate, either a fresh or using one the caller provides. + RunCreationOptions runOptions = + options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ?? + new(); + + ToolResources? resources = null; + + // Populate the run options from the ChatOptions, if provided. + if (options is not null) + { + runOptions.MaxOutputTokenCount ??= options.MaxOutputTokens; + runOptions.ModelOverride ??= options.ModelId; + runOptions.NucleusSamplingFactor ??= options.TopP; + runOptions.Temperature ??= options.Temperature; + runOptions.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; + + if (options.Tools is { Count: > 0 } tools) + { + HashSet toolsOverride = new(ToolDefinitionNameEqualityComparer.Instance); + + // If the caller has provided any tool overrides, we'll assume they don't want to use the assistant's tools. + // But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to + // just add them. To handle that, we'll get all of the assistant's tools and add them to the override list + // along with our tools. + if (runOptions.ToolsOverride.Count == 0) + { + if (_assistantTools is null) + { + var assistant = await _client.GetAssistantAsync(_assistantId, cancellationToken).ConfigureAwait(false); + _assistantTools = assistant.Value.Tools; + } + + toolsOverride.UnionWith(_assistantTools); + } + + // The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools. + foreach (AITool tool in tools) + { + switch (tool) + { + case AIFunctionDeclaration aiFunction: + _ = toolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); + break; + + case HostedCodeInterpreterTool codeInterpreterTool: + var interpreterToolDef = ToolDefinition.CreateCodeInterpreter(); + _ = toolsOverride.Add(interpreterToolDef); + + if (codeInterpreterTool.Inputs?.Count is > 0) + { + ThreadInitializationMessage? threadInitializationMessage = null; + foreach (var input in codeInterpreterTool.Inputs) + { + if (input is HostedFileContent hostedFile) + { + threadInitializationMessage ??= new(MessageRole.User, [MessageContent.FromText("attachments")]); + threadInitializationMessage.Attachments.Add(new(hostedFile.FileId, [interpreterToolDef])); + } + } + + if (threadInitializationMessage is not null) + { + runOptions.AdditionalMessages.Add(threadInitializationMessage); + } + } + + break; + + case HostedFileSearchTool fileSearchTool: + _ = toolsOverride.Add(ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount)); + if (fileSearchTool.Inputs is { Count: > 0 } fileSearchInputs) + { + foreach (var input in fileSearchInputs) + { + if (input is HostedVectorStoreContent file) + { + (resources ??= new()).FileSearch ??= new(); + resources.FileSearch.VectorStoreIds.Add(file.VectorStoreId); + } + } + } + + break; + } + } + + foreach (var tool in toolsOverride) + { + runOptions.ToolsOverride.Add(tool); + } + } + + // Store the tool mode, if relevant. + if (runOptions.ToolConstraint is null) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + runOptions.ToolConstraint = ToolConstraint.None; + break; + + case AutoChatToolMode: + runOptions.ToolConstraint = ToolConstraint.Auto; + break; + + case RequiredChatToolMode required when required.RequiredFunctionName is { } functionName: + runOptions.ToolConstraint = new ToolConstraint(ToolDefinition.CreateFunction(functionName)); + break; + + case RequiredChatToolMode required: + runOptions.ToolConstraint = ToolConstraint.Required; + break; + } + } + + // Store the response format, if relevant. + if (runOptions.ResponseFormat is null) + { + switch (options.ResponseFormat) + { + case ChatResponseFormatText: + runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat(); + break; + + case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema: + runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)); + break; + + case ChatResponseFormatJson jsonFormat: + runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonObjectFormat(); + break; + } + } + } + + // Configure system instructions. + StringBuilder? instructions = null; + void AppendSystemInstructions(string? toAppend) + { + if (!string.IsNullOrEmpty(toAppend)) + { + if (instructions is null) + { + instructions = new(toAppend); + } + else + { + _ = instructions.AppendLine().AppendLine(toAppend); + } + } + } + + AppendSystemInstructions(runOptions.AdditionalInstructions); + AppendSystemInstructions(options?.Instructions); + + // Process ChatMessages. + List? functionResults = null; + foreach (var chatMessage in messages) + { + List messageContents = []; + + // Assistants doesn't support system/developer messages directly. It does support transient per-request instructions, + // so we can use the system/developer messages to build up a set of instructions that will be passed to the assistant + // as part of this request. However, in doing so, on a subsequent request that information will be lost, as there's no + // way to store per-thread instructions in the OpenAI Assistants API. We don't want to convert these to user messages, + // however, as that would then expose the system/developer messages in a way that might make the model more likely + // to include that information in its responses. System messages should ideally be instead done as instructions to + // the assistant when the assistant is created. + if (chatMessage.Role == ChatRole.System || + chatMessage.Role == OpenAIClientExtensions.ChatRoleDeveloper) + { + foreach (var textContent in chatMessage.Contents.OfType()) + { + AppendSystemInstructions(textContent.Text); + } + + continue; + } + + foreach (AIContent content in chatMessage.Contents) + { + switch (content) + { + case AIContent when content.RawRepresentation is MessageContent rawRep: + messageContents.Add(rawRep); + break; + + case TextContent text: + messageContents.Add(MessageContent.FromText(text.Text)); + break; + + case UriContent image when image.HasTopLevelMediaType("image"): + messageContents.Add(MessageContent.FromImageUri(image.Uri)); + break; + + case FunctionResultContent result when chatMessage.Role == ChatRole.Tool: + (functionResults ??= []).Add(result); + break; + } + } + + if (messageContents.Count > 0) + { + runOptions.AdditionalMessages.Add(new ThreadInitializationMessage( + chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User, + messageContents)); + } + } + + runOptions.AdditionalInstructions = instructions?.ToString(); + + return (runOptions, resources, functionResults); + } + + /// Convert instances to instances. + /// The tool results to process. + /// The generated list of tool outputs, if any could be created. + /// The run ID associated with the corresponding function call requests. + private static string? ConvertFunctionResultsToToolOutput(List? toolResults, out List? toolOutputs) + { + string? runId = null; + toolOutputs = null; + if (toolResults?.Count > 0) + { + foreach (var frc in toolResults) + { + // When creating the FunctionCallContext, we created it with a CallId == [runId, callId]. + // We need to extract the run ID and ensure that the ToolOutput we send back to Azure + // is only the call ID. + string[]? runAndCallIDs; + try + { + runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray); + } + catch + { + continue; + } + + if (runAndCallIDs is null || + runAndCallIDs.Length != 2 || + string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID + string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID + (runId is not null && runId != runAndCallIDs[0])) + { + continue; + } + + runId = runAndCallIDs[0]; + (toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty)); + } + } + + return runId; + } + + /// + /// Provides the same behavior as , except + /// for it compares names so that two function tool definitions with the + /// same name compare equally. + /// + private sealed class ToolDefinitionNameEqualityComparer : IEqualityComparer + { + public static ToolDefinitionNameEqualityComparer Instance { get; } = new(); + + public bool Equals(ToolDefinition? x, ToolDefinition? y) => + x is FunctionToolDefinition xFtd && y is FunctionToolDefinition yFtd ? xFtd.FunctionName.Equals(yFtd.FunctionName, StringComparison.Ordinal) : + EqualityComparer.Default.Equals(x, y); + + public int GetHashCode(ToolDefinition obj) => + obj is FunctionToolDefinition ftd ? ftd.FunctionName.GetHashCode(StringComparison.Ordinal) : + EqualityComparer.Default.GetHashCode(obj); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 001f4d1a593..70ef6674bd4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -2,12 +2,15 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -15,8 +18,6 @@ using OpenAI.Chat; #pragma warning disable CA1308 // Normalize strings to uppercase -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -25,17 +26,26 @@ namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . internal sealed partial class OpenAIChatClient : IChatClient { - /// Gets the JSON schema transformer cache conforming to OpenAI restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. - internal static AIJsonSchemaTransformCache SchemaTransformCache { get; } = new(new() - { - RequireAllProperties = true, - DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true, - MoveDefaultKeywordToDescription = true, - }); - - /// Gets the default OpenAI endpoint. - private static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); + // These delegate instances are used to call the internal overloads of CompleteChatAsync and CompleteChatStreamingAsync that accept + // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, ChatCompletionOptions, RequestOptions, Task>>? + _completeChatAsync = + (Func, ChatCompletionOptions, RequestOptions, Task>>?) + typeof(ChatClient) + .GetMethod( + nameof(ChatClient.CompleteChatAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ChatCompletionOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, ChatCompletionOptions, RequestOptions, Task>>)); + private static readonly Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>? + _completeChatStreamingAsync = + (Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>?) + typeof(ChatClient) + .GetMethod( + nameof(ChatClient.CompleteChatStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ChatCompletionOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>)); /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -48,20 +58,9 @@ internal sealed partial class OpenAIChatClient : IChatClient /// is . public OpenAIChatClient(ChatClient chatClient) { - _ = Throw.IfNull(chatClient); - - _chatClient = chatClient; + _chatClient = Throw.IfNull(chatClient); - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - Uri providerUrl = typeof(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(chatClient) as Uri ?? DefaultOpenAIEndpoint; - string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(chatClient) as string; - - _metadata = new("openai", providerUrl, model); + _metadata = new("openai", chatClient.Endpoint, _chatClient.Model); } /// @@ -83,13 +82,16 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. - var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); + var task = _completeChatAsync is not null ? + _completeChatAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken); + var response = await task.ConfigureAwait(false); - return FromOpenAIChatCompletion(response.Value, options, openAIOptions); + return FromOpenAIChatCompletion(response.Value, openAIOptions); } /// @@ -98,13 +100,15 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. - var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); + var chatCompletionUpdates = _completeChatStreamingAsync is not null ? + _completeChatStreamingAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); - return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); + return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); } /// @@ -113,25 +117,49 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } - private static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); + /// Converts an Extensions function to an OpenAI chat tool. + internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) + { + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ChatTool.CreateFunctionTool( + aiFunction.Name, + aiFunction.Description, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict); + } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, JsonSerializerOptions options) + internal static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions) { // Maps all of the M.E.AI types to the corresponding OpenAI types. // Unrecognized or non-processable content is ignored. + if (chatOptions?.Instructions is { } instructions && !string.IsNullOrWhiteSpace(instructions)) + { + yield return new SystemChatMessage(instructions); + } + foreach (ChatMessage input in inputs) { + if (input.RawRepresentation is OpenAI.Chat.ChatMessage raw) + { + yield return raw; + continue; + } + if (input.Role == ChatRole.System || input.Role == ChatRole.User || - input.Role == ChatRoleDeveloper) + input.Role == OpenAIClientExtensions.ChatRoleDeveloper) { var parts = ToOpenAIChatContent(input.Contents); + string? name = SanitizeAuthorName(input.AuthorName); yield return - input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : - input.Role == ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : - new UserChatMessage(parts) { ParticipantName = input.AuthorName }; + input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = name } : + input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = name } : + new UserChatMessage(parts) { ParticipantName = name }; } else if (input.Role == ChatRole.Tool) { @@ -144,7 +172,7 @@ void IDisposable.Dispose() { try { - result = JsonSerializer.Serialize(resultContent.Result, options.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -172,7 +200,7 @@ void IDisposable.Dispose() case FunctionCallContent fc: (toolCalls ??= []).Add( ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes( - fc.Arguments, options.GetTypeInfo(typeof(IDictionary)))))); + fc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); break; default: @@ -204,7 +232,7 @@ void IDisposable.Dispose() new(ChatMessageContentPart.CreateTextPart(string.Empty)); } - message.ParticipantName = input.AuthorName; + message.ParticipantName = SanitizeAuthorName(input.AuthorName); message.Refusal = refusal; yield return message; @@ -213,15 +241,22 @@ void IDisposable.Dispose() } /// Converts a list of to a list of . - private static List ToOpenAIChatContent(IList contents) + internal static List ToOpenAIChatContent(IEnumerable contents) { List parts = []; foreach (var content in contents) { - if (ToChatMessageContentPart(content) is { } part) + if (content.RawRepresentation is ChatMessageContentPart raw) + { + parts.Add(raw); + } + else { - parts.Add(part); + if (ToChatMessageContentPart(content) is { } part) + { + parts.Add(part); + } } } @@ -237,6 +272,9 @@ private static List ToOpenAIChatContent(IList { switch (content) { + case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: + return rawContentPart; + case TextContent textContent: return ChatMessageContentPart.CreateTextPart(textContent.Text); @@ -260,7 +298,10 @@ private static List ToOpenAIChatContent(IList break; case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, $"{Guid.NewGuid():N}.pdf"); + return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"); + + case HostedFileContent fileContent: + return ChatMessageContentPart.CreateFilePart(fileContent.FileId); } return null; @@ -281,9 +322,10 @@ private static List ToOpenAIChatContent(IList return null; } - private static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( + internal static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( IAsyncEnumerable updates, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + ChatCompletionOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) { Dictionary? functionCallInfos = null; ChatRole? streamedRole = null; @@ -318,13 +360,15 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over content update items. if (update.ContentUpdate is { Count: > 0 }) { - foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + ConvertContentParts(update.ContentUpdate, responseUpdate.Contents); + } + + if (update.OutputAudioUpdate is { } audioUpdate) + { + responseUpdate.Contents.Add(new DataContent(audioUpdate.AudioBytesUpdate.ToMemory(), GetOutputAudioMimeType(options)) { - if (ToAIContent(contentPart) is AIContent aiContent) - { - responseUpdate.Contents.Add(aiContent); - } - } + RawRepresentation = audioUpdate, + }); } // Transfer over refusal updates. @@ -356,8 +400,10 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over usage updates. if (update.Usage is ChatTokenUsage tokenUsage) { - var usageDetails = FromOpenAIUsage(tokenUsage); - responseUpdate.Contents.Add(new UsageContent(usageDetails)); + responseUpdate.Contents.Add(new UsageContent(FromOpenAIUsage(tokenUsage)) + { + RawRepresentation = tokenUsage, + }); } // Now yield the item. @@ -382,7 +428,7 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha FunctionCallInfo fci = entry.Value; if (!string.IsNullOrWhiteSpace(fci.Name)) { - var callContent = ParseCallContentFromJsonString( + var callContent = OpenAIClientExtensions.ParseCallContent( fci.Arguments?.ToString() ?? string.Empty, fci.CallId!, fci.Name!); @@ -401,13 +447,25 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } - private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) + private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => + options?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + { + "opus" => "audio/opus", + "aac" => "audio/aac", + "flac" => "audio/flac", + "wav" => "audio/wav", + "pcm" => "audio/pcm", + "mp3" or _ => "audio/mpeg", + }; + + internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatCompletionOptions? chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); // Create the return message. ChatMessage returnMessage = new() { + CreatedAt = openAICompletion.CreatedAt, MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID RawRepresentation = openAICompletion, Role = FromOpenAIChatRole(openAICompletion.Role), @@ -425,33 +483,21 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple // Output audio is handled separately from message content parts. if (openAICompletion.OutputAudio is ChatOutputAudio audio) { - string mimeType = chatCompletionOptions?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + returnMessage.Contents.Add(new DataContent(audio.AudioBytes.ToMemory(), GetOutputAudioMimeType(chatCompletionOptions)) { - "opus" => "audio/opus", - "aac" => "audio/aac", - "flac" => "audio/flac", - "wav" => "audio/wav", - "pcm" => "audio/pcm", - "mp3" or _ => "audio/mpeg", - }; - - var dc = new DataContent(audio.AudioBytes.ToMemory(), mimeType); - - returnMessage.Contents.Add(dc); + RawRepresentation = audio, + }); } // Also manufacture function calling content items from any tool calls in the response. - if (options?.Tools is { Count: > 0 }) + foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) { - foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; + var callContent = OpenAIClientExtensions.ParseCallContent(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(callContent); - } + returnMessage.Contents.Add(callContent); } } @@ -461,6 +507,30 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple returnMessage.Contents.Add(new ErrorContent(refusal) { ErrorCode = nameof(openAICompletion.Refusal) }); } + // And add annotations. OpenAI chat completion specifies annotations at the message level (and as such they can't be + // roundtripped back); we store them either on the first text content, assuming there is one, or on a dedicated content + // instance if not. + if (openAICompletion.Annotations is { Count: > 0 }) + { + TextContent? annotationContent = returnMessage.Contents.OfType().FirstOrDefault(); + if (annotationContent is null) + { + annotationContent = new(null); + returnMessage.Contents.Add(annotationContent); + } + + foreach (var annotation in openAICompletion.Annotations) + { + (annotationContent.Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = annotation, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = annotation.StartIndex, EndIndex = annotation.EndIndex }], + Title = annotation.WebResourceTitle, + Url = annotation.WebResourceUri, + }); + } + } + // Wrap the content in a ChatResponse to return. var response = new ChatResponse(returnMessage) { @@ -484,12 +554,12 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { if (options is null) { - return new ChatCompletionOptions(); + return new(); } if (options.RawRepresentationFactory?.Invoke(this) is not ChatCompletionOptions result) { - result = new ChatCompletionOptions(); + result = new(); } result.FrequencyPenalty ??= options.FrequencyPenalty; @@ -497,10 +567,8 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.TopP ??= options.TopP; result.PresencePenalty ??= options.PresencePenalty; result.Temperature ??= options.Temperature; - result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. result.Seed ??= options.Seed; -#pragma warning restore OPENAI001 + OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options.ModelId); if (options.StopSequences is { Count: > 0 } stopSequences) { @@ -514,12 +582,17 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { foreach (AITool tool in tools) { - if (tool is AIFunction af) + if (tool is AIFunctionDeclaration af) { - result.Tools.Add(ToOpenAIChatTool(af)); + result.Tools.Add(ToOpenAIChatTool(af, options)); } } + if (result.Tools.Count > 0) + { + result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; + } + if (result.ToolChoice is null && result.Tools.Count > 0) { switch (options.ToolMode) @@ -542,43 +615,27 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } } - if (result.ResponseFormat is null) - { - if (options.ResponseFormat is ChatResponseFormatText) - { - result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.ResponseFormat = SchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? - OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes( - JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : - OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); - } - } + result.ResponseFormat ??= ToOpenAIChatResponseFormat(options.ResponseFormat, options); return result; } - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; + internal static OpenAI.Chat.ChatResponseFormat? ToOpenAIChatResponseFormat(ChatResponseFormat? format, ChatOptions? options) => + format switch + { + ChatResponseFormatText => OpenAI.Chat.ChatResponseFormat.CreateTextFormat(), - // Perform transformations making the schema legal per OpenAI restrictions - JsonElement jsonSchema = SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction); + ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties)), - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.ChatToolJson)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } + ChatResponseFormatJson => OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(), + + _ => null + }; private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) { @@ -619,10 +676,24 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => ChatMessageRole.User => ChatRole.User, ChatMessageRole.Assistant => ChatRole.Assistant, ChatMessageRole.Tool => ChatRole.Tool, - ChatMessageRole.Developer => ChatRoleDeveloper, + ChatMessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper, _ => new ChatRole(role.ToString()), }; + /// Creates s from . + /// The content parts to convert into a content. + /// The result collection into which to write the resulting content. + internal static void ConvertContentParts(ChatMessageContent content, IList results) + { + foreach (ChatMessageContentPart contentPart in content) + { + if (ToAIContent(contentPart) is { } aiContent) + { + results.Add(aiContent); + } + } + } + /// Creates an from a . /// The content part to convert into a content. /// The constructed , or if the content part could not be converted. @@ -630,21 +701,31 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => { AIContent? aiContent = null; - if (contentPart.Kind == ChatMessageContentPartKind.Text) - { - aiContent = new TextContent(contentPart.Text); - } - else if (contentPart.Kind == ChatMessageContentPartKind.Image) + switch (contentPart.Kind) { - aiContent = - contentPart.ImageUri is not null ? new UriContent(contentPart.ImageUri, "image/*") : - contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : - null; + case ChatMessageContentPartKind.Text: + aiContent = new TextContent(contentPart.Text); + break; - if (aiContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) - { - (aiContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; - } + case ChatMessageContentPartKind.Image: + aiContent = + contentPart.ImageUri is not null ? new UriContent(contentPart.ImageUri, OpenAIClientExtensions.ImageUriToMediaType(contentPart.ImageUri)) : + contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : + null; + + if (aiContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) + { + (aiContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; + } + + break; + + case ChatMessageContentPartKind.File: + aiContent = + contentPart.FileId is not null ? new HostedFileContent(contentPart.FileId) { Name = contentPart.Filename } : + contentPart.FileBytes is not null ? new DataContent(contentPart.FileBytes.ToMemory(), contentPart.FileBytesMediaType) { Name = contentPart.Filename } : + null; + break; } if (aiContent is not null) @@ -672,28 +753,25 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => _ => new ChatFinishReason(s), }; - private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!); - - private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!); - - /// Used to create the JSON payload for an OpenAI chat tool description. - private sealed class ChatToolJson + /// Sanitizes the author name to be appropriate for including as an OpenAI participant name. + private static string? SanitizeAuthorName(string? name) { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; + if (name is not null) + { + const int MaxLength = 64; - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; + name = InvalidAuthorNameRegex().Replace(name, string.Empty); + if (name.Length == 0) + { + name = null; + } + else if (name.Length > MaxLength) + { + name = name.Substring(0, MaxLength); + } + } - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } + return name; } /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. @@ -704,13 +782,12 @@ private sealed class FunctionCallInfo public StringBuilder? Arguments; } - /// Source-generated JSON type information. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] - [JsonSerializable(typeof(ChatToolJson))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(string[]))] - private sealed partial class ChatClientJsonContext : JsonSerializerContext; + private const string InvalidAuthorNamePattern = @"[^a-zA-Z0-9_]+"; +#if NET + [GeneratedRegex(InvalidAuthorNamePattern)] + private static partial Regex InvalidAuthorNameRegex(); +#else + private static Regex InvalidAuthorNameRegex() => _invalidAuthorNameRegex; + private static readonly Regex _invalidAuthorNameRegex = new(InvalidAuthorNamePattern, RegexOptions.Compiled); +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 81d2fe55a03..285b2c1e7ae 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -1,41 +1,260 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using OpenAI; +using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; +using OpenAI.Images; using OpenAI.Responses; +#pragma warning disable SA1515 // Single-line comment should be preceded by blank line + namespace Microsoft.Extensions.AI; /// Provides extension methods for working with s. public static class OpenAIClientExtensions { + /// Key into AdditionalProperties used to store a strict option. + private const string StrictKey = "strictJsonSchema"; + + /// Gets the default OpenAI endpoint. + internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); + + /// Gets a for "developer". + internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); + + /// + /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per + /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. + /// + internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() + { + DisallowAdditionalProperties = true, + ConvertBooleanSchemas = true, + MoveDefaultKeywordToDescription = true, + RequireAllProperties = true, + TransformSchemaNode = (ctx, node) => + { + // Move content from common but unsupported properties to description. In particular, we focus on properties that + // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. + + if (node is JsonObject schemaObj) + { + StringBuilder? additionalDescription = null; + + ReadOnlySpan unsupportedProperties = + [ + // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: + "contentEncoding", "contentMediaType", "not", + + // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: + "minLength", "maxLength", "pattern", "format", + "minimum", "maximum", "multipleOf", + "patternProperties", + "minItems", "maxItems", + + // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords + // as being unsupported with Azure OpenAI: + "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", + "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", + ]; + + foreach (string propName in unsupportedProperties) + { + if (schemaObj[propName] is { } propNode) + { + _ = schemaObj.Remove(propName); + AppendLine(ref additionalDescription, propName, propNode); + } + } + + if (additionalDescription is not null) + { + schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? + $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : + additionalDescription.ToString(); + } + + return node; + + static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) + { + sb ??= new(); + + if (sb.Length > 0) + { + _ = sb.AppendLine(); + } + + _ = sb.Append(propName).Append(": ").Append(propNode); + } + } + + return node; + }, + }); + /// Gets an for use with this . /// The client. /// An that can be used to converse via the . + /// is . public static IChatClient AsIChatClient(this ChatClient chatClient) => new OpenAIChatClient(chatClient); /// Gets an for use with this . /// The client. /// An that can be used to converse via the . + /// is . public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => - new OpenAIResponseChatClient(responseClient); + new OpenAIResponsesChatClient(responseClient); + + /// Gets an for use with this . + /// The instance to be accessed as an . + /// The unique identifier of the assistant with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + /// is . + /// is . + /// is empty or composed entirely of whitespace. + public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => + new OpenAIAssistantsChatClient(assistantClient, assistantId, threadId); + + /// Gets an for use with this . + /// The instance to be accessed as an . + /// The with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + /// is . + /// is . + public static IChatClient AsIChatClient(this AssistantClient assistantClient, Assistant assistant, string? threadId = null) => + new OpenAIAssistantsChatClient(assistantClient, assistant, threadId); /// Gets an for use with this . /// The client. /// An that can be used to transcribe audio via the . + /// is . [Experimental("MEAI001")] public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioClient) => new OpenAISpeechToTextClient(audioClient); + /// Gets an for use with this . + /// The client. + /// An that can be used to generate images via the . + /// is . + [Experimental("MEAI001")] + public static IImageGenerator AsIImageGenerator(this ImageClient imageClient) => + new OpenAIImageGenerator(imageClient); + /// Gets an for use with this . /// The client. /// The number of dimensions to generate in each embedding. /// An that can be used to generate embeddings via the . + /// is . public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); + + /// Gets whether the properties specify that strict schema handling is desired. + internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => + additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && + strictObj is bool strictValue ? + strictValue : null; + + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. + internal static BinaryData ToOpenAIFunctionParameters(AIFunctionDeclaration aiFunction, bool? strict) + { + // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. + JsonElement jsonSchema = strict is true ? + StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : + aiFunction.JsonSchema; + + // Roundtrip the schema through the ToolJson model type to remove extra properties + // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData. + var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); + + return functionParameters; + } + + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// The input arguments to be parsed. + /// The function call ID. + /// The function name. + /// A new instance of containing the parse result. + /// is . + /// is . + internal static FunctionCallContent ParseCallContent(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// The input arguments to be parsed. + /// The function call ID. + /// The function name. + /// A new instance of containing the parse result. + /// is . + /// is . + internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name, + static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + + /// Gets a media type for an image based on the file extension in the provided URI. + internal static string ImageUriToMediaType(Uri uri) + { + string absoluteUri = uri.AbsoluteUri; + return + absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : + absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" : + absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" : + absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" : + "image/*"; + } + + /// Sets $.model in to if not already set. + internal static void PatchModelIfNotSet(ref JsonPatch patch, string? modelId) + { + if (modelId is not null) + { + _ = patch.TryGetValue("$.model"u8, out string? existingModel); + if (existingModel is null) + { + patch.Set("$.model"u8, modelId); + } + } + } + + /// Used to create the JSON payload for an OpenAI tool description. + internal sealed class ToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public HashSet Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + + [JsonPropertyName("additionalProperties")] + public bool AdditionalProperties { get; set; } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index cf54b7906f0..d0e1276462f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -2,16 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using OpenAI; using OpenAI.Embeddings; -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields namespace Microsoft.Extensions.AI; @@ -19,8 +19,17 @@ namespace Microsoft.Extensions.AI; /// An for an OpenAI . internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator> { - /// Default OpenAI endpoint. - private const string DefaultOpenAIEndpoint = "https://api.openai.com/v1"; + // This delegate instance is used to call the internal overload of GenerateEmbeddingsAsync that accepts + // a RequestOptions. This should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>? + _generateEmbeddingsAsync = + (Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>?) + typeof(EmbeddingClient) + .GetMethod( + nameof(EmbeddingClient.GenerateEmbeddingsAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(OpenAI.Embeddings.EmbeddingGenerationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>)); /// Metadata about the embedding generator. private readonly EmbeddingGeneratorMetadata _metadata; @@ -38,27 +47,15 @@ internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator is not positive. public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? defaultModelDimensions = null) { - _ = Throw.IfNull(embeddingClient); + _embeddingClient = Throw.IfNull(embeddingClient); + _dimensions = defaultModelDimensions; + if (defaultModelDimensions < 1) { Throw.ArgumentOutOfRangeException(nameof(defaultModelDimensions), "Value must be greater than 0."); } - _embeddingClient = embeddingClient; - _dimensions = defaultModelDimensions; - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - string providerUrl = (typeof(EmbeddingClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(embeddingClient) as Uri)?.ToString() ?? - DefaultOpenAIEndpoint; - - FieldInfo? modelField = typeof(EmbeddingClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - string? modelId = modelField?.GetValue(embeddingClient) as string; - - _metadata = CreateMetadata("openai", providerUrl, modelId, defaultModelDimensions); + _metadata = new("openai", embeddingClient.Endpoint, _embeddingClient.Model, defaultModelDimensions); } /// @@ -66,7 +63,10 @@ public async Task>> GenerateAsync(IEnumerab { OpenAI.Embeddings.EmbeddingGenerationOptions? openAIOptions = ToOpenAIOptions(options); - var embeddings = (await _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + var t = _generateEmbeddingsAsync is not null ? + _generateEmbeddingsAsync(_embeddingClient, values, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken); + var embeddings = (await t.ConfigureAwait(false)).Value; return new(embeddings.Select(e => new Embedding(e.ToFloats()) @@ -102,26 +102,17 @@ void IDisposable.Dispose() null; } - /// Creates the for this instance. - private static EmbeddingGeneratorMetadata CreateMetadata(string providerName, string providerUrl, string? defaultModelId, int? defaultModelDimensions) => - new(providerName, Uri.TryCreate(providerUrl, UriKind.Absolute, out Uri? providerUri) ? providerUri : null, defaultModelId, defaultModelDimensions); - /// Converts an extensions options instance to an OpenAI options instance. - private OpenAI.Embeddings.EmbeddingGenerationOptions? ToOpenAIOptions(EmbeddingGenerationOptions? options) + private OpenAI.Embeddings.EmbeddingGenerationOptions ToOpenAIOptions(EmbeddingGenerationOptions? options) { - OpenAI.Embeddings.EmbeddingGenerationOptions openAIOptions = new() - { - Dimensions = options?.Dimensions ?? _dimensions, - }; - - if (options?.AdditionalProperties is { Count: > 0 } additionalProperties) + if (options?.RawRepresentationFactory?.Invoke(this) is not OpenAI.Embeddings.EmbeddingGenerationOptions result) { - if (additionalProperties.TryGetValue(nameof(openAIOptions.EndUserId), out string? endUserId)) - { - openAIOptions.EndUserId = endUserId; - } + result = new(); } - return openAIOptions; + result.Dimensions ??= options?.Dimensions ?? _dimensions; + OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options?.ModelId); + + return result; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs new file mode 100644 index 00000000000..a51454d532c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Images; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// Represents an for an OpenAI or . +internal sealed class OpenAIImageGenerator : IImageGenerator +{ + private static readonly Dictionary _mimeTypeToExtension = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/webp"] = ".webp", + ["image/gif"] = ".gif", + ["image/bmp"] = ".bmp", + ["image/tiff"] = ".tiff", + }; + + /// Metadata about the client. + private readonly ImageGeneratorMetadata _metadata; + + /// The underlying . + private readonly ImageClient _imageClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// is . + public OpenAIImageGenerator(ImageClient imageClient) + { + _imageClient = Throw.IfNull(imageClient); + + _metadata = new("openai", imageClient.Endpoint, _imageClient.Model); + } + + /// + public async Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + string? prompt = request.Prompt; + _ = Throw.IfNull(prompt); + + // If the request has original images, treat this as an edit operation + if (request.OriginalImages is not null && request.OriginalImages.Any()) + { + ImageEditOptions editOptions = ToOpenAIImageEditOptions(options); + string? fileName = null; + Stream? imageStream = null; + + // Currently only a single image is supported for editing. + var originalImage = request.OriginalImages.FirstOrDefault(); + + if (originalImage is DataContent dataContent) + { + imageStream = MemoryMarshal.TryGetArray(dataContent.Data, out var array) ? + new MemoryStream(array.Array!, array.Offset, array.Count) : + new MemoryStream(dataContent.Data.ToArray()); + fileName = dataContent.Name; + + if (fileName is null) + { + // If no file name is provided, use the default based on the content type. + if (dataContent.MediaType is not null && _mimeTypeToExtension.TryGetValue(dataContent.MediaType, out var extension)) + { + fileName = $"image{extension}"; + } + else + { + fileName = "image.png"; // Default to PNG if no content type is available. + } + } + } + + GeneratedImageCollection editResult = await _imageClient.GenerateImageEditsAsync( + imageStream, fileName, prompt, options?.Count ?? 1, editOptions, cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(editResult); + } + + OpenAI.Images.ImageGenerationOptions openAIOptions = ToOpenAIImageGenerationOptions(options); + + GeneratedImageCollection result = await _imageClient.GenerateImagesAsync(prompt, options?.Count ?? 1, openAIOptions, cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(result); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : + serviceKey is not null ? null : + serviceType == typeof(ImageGeneratorMetadata) ? _metadata : + serviceType == typeof(ImageClient) ? _imageClient : + serviceType.IsInstanceOfType(this) ? this : + null; + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IImageGenerator interface. + } + + /// + /// Converts a to an OpenAI . + /// + /// User's requested size. + /// Closest supported size. + private static GeneratedImageSize? ToOpenAIImageSize(Size? requestedSize) => + requestedSize is null ? null : new GeneratedImageSize(requestedSize.Value.Width, requestedSize.Value.Height); + + /// Converts a to a . + private static ImageGenerationResponse ToImageGenerationResponse(GeneratedImageCollection generatedImages) + { + string contentType = "image/png"; // Default content type for images + + // OpenAI doesn't expose the content type, so we need to read from the internal JSON representation. + // https://github.com/openai/openai-dotnet/issues/561 + var additionalRawData = typeof(GeneratedImageCollection) + .GetProperty("SerializedAdditionalRawData", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(generatedImages) as IDictionary; + + if (additionalRawData?.TryGetValue("output_format", out var outputFormat) ?? false) + { + var stringJsonTypeInfo = (JsonTypeInfo)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string)); + var outputFormatString = JsonSerializer.Deserialize(outputFormat, stringJsonTypeInfo); + contentType = $"image/{outputFormatString}"; + } + + List contents = []; + + foreach (GeneratedImage image in generatedImages) + { + if (image.ImageBytes is not null) + { + contents.Add(new DataContent(image.ImageBytes.ToMemory(), contentType)); + } + else if (image.ImageUri is not null) + { + contents.Add(new UriContent(image.ImageUri, contentType)); + } + else + { + throw new InvalidOperationException("Generated image does not contain a valid URI or byte array."); + } + } + + UsageDetails? ud = null; + if (generatedImages.Usage is { } usage) + { + ud = new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount, + }; + + if (usage.InputTokenDetails is { } inputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.ImageTokenCount)}", inputDetails.ImageTokenCount); + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.TextTokenCount)}", inputDetails.TextTokenCount); + } + } + + return new ImageGenerationResponse(contents) + { + RawRepresentation = generatedImages, + Usage = ud, + }; + } + + /// Converts a to a . + private OpenAI.Images.ImageGenerationOptions ToOpenAIImageGenerationOptions(ImageGenerationOptions? options) + { + OpenAI.Images.ImageGenerationOptions result = options?.RawRepresentationFactory?.Invoke(this) as OpenAI.Images.ImageGenerationOptions ?? new(); + + if (result.OutputFileFormat is null) + { + if (options?.MediaType?.Equals("image/png", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Png; + } + else if (options?.MediaType?.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Jpeg; + } + else if (options?.MediaType?.Equals("image/webp", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Webp; + } + } + + result.ResponseFormat ??= options?.ResponseFormat switch + { + ImageGenerationResponseFormat.Uri => GeneratedImageFormat.Uri, + ImageGenerationResponseFormat.Data => GeneratedImageFormat.Bytes, + + // ImageGenerationResponseFormat.Hosted not supported by ImageGenerator, however other OpenAI API support file IDs. + _ => (GeneratedImageFormat?)null + }; + + result.Size ??= ToOpenAIImageSize(options?.ImageSize); + + return result; + } + + /// Converts a to a . + private ImageEditOptions ToOpenAIImageEditOptions(ImageGenerationOptions? options) + { + ImageEditOptions result = options?.RawRepresentationFactory?.Invoke(this) as ImageEditOptions ?? new(); + + result.ResponseFormat ??= options?.ResponseFormat switch + { + ImageGenerationResponseFormat.Uri => GeneratedImageFormat.Uri, + ImageGenerationResponseFormat.Data => GeneratedImageFormat.Bytes, + + // ImageGenerationResponseFormat.Hosted not supported by ImageGenerator, however other OpenAI API support file IDs. + _ => (GeneratedImageFormat?)null + }; + + result.Size ??= ToOpenAIImageSize(options?.ImageSize); + + return result; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs new file mode 100644 index 00000000000..33d17e2963e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Source-generated JSON type information for use by all OpenAI implementations. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +[JsonSerializable(typeof(OpenAIClientExtensions.ToolJson))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(IReadOnlyDictionary))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(List))] +internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs new file mode 100644 index 00000000000..dbbabea026f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using OpenAI.Realtime; + +namespace Microsoft.Extensions.AI; + +/// Provides helpers for interacting with OpenAI Realtime. +internal sealed class OpenAIRealtimeConversationClient +{ + public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) + { + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return new ConversationFunctionTool(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + }; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs deleted file mode 100644 index a91ea9abf8f..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ /dev/null @@ -1,644 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; -using OpenAI.Responses; -using static Microsoft.Extensions.AI.OpenAIChatClient; - -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S3604 // Member initializer values should not be redundant - -namespace Microsoft.Extensions.AI; - -/// Represents an for an . -internal sealed partial class OpenAIResponseChatClient : IChatClient -{ - /// Gets the default OpenAI endpoint. - private static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); - - /// A for "developer". - private static readonly ChatRole _chatRoleDeveloper = new("developer"); - - /// Metadata about the client. - private readonly ChatClientMetadata _metadata; - - /// The underlying . - private readonly OpenAIResponseClient _responseClient; - - /// Initializes a new instance of the class for the specified . - /// The underlying client. - /// is . - public OpenAIResponseChatClient(OpenAIResponseClient responseClient) - { - _ = Throw.IfNull(responseClient); - - _responseClient = responseClient; - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - Uri providerUrl = typeof(OpenAIResponseClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as Uri ?? DefaultOpenAIEndpoint; - string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as string; - - _metadata = new("openai", providerUrl, model); - } - - /// - object? IChatClient.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(OpenAIResponseClient) ? _responseClient : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages); - var openAIOptions = ToOpenAIResponseCreationOptions(options); - - // Make the call to the OpenAIResponseClient. - var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; - - // Convert and return the results. - ChatResponse response = new() - { - ResponseId = openAIResponse.Id, - ConversationId = openAIResponse.Id, - CreatedAt = openAIResponse.CreatedAt, - FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), - Messages = [new(ChatRole.Assistant, [])], - ModelId = openAIResponse.Model, - Usage = ToUsageDetails(openAIResponse), - }; - - if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) - { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; - } - - if (openAIResponse.Error is not null) - { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; - } - - if (openAIResponse.OutputItems is not null) - { - ChatMessage message = response.Messages[0]; - Debug.Assert(message.Contents is List, "Expected a List for message contents."); - - foreach (ResponseItem outputItem in openAIResponse.OutputItems) - { - switch (outputItem) - { - case MessageResponseItem messageItem: - message.MessageId = messageItem.Id; - message.RawRepresentation = messageItem; - message.Role = ToChatRole(messageItem.Role); - (message.AdditionalProperties ??= []).Add(nameof(messageItem.Id), messageItem.Id); - ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); - break; - - case FunctionCallResponseItem functionCall: - response.FinishReason ??= ChatFinishReason.ToolCalls; - message.Contents.Add( - FunctionCallContent.CreateFromParsedArguments( - functionCall.FunctionArguments.ToMemory(), - functionCall.CallId, - functionCall.FunctionName, - static json => JsonSerializer.Deserialize(json.Span, ResponseClientJsonContext.Default.IDictionaryStringObject)!)); - break; - } - } - - if (openAIResponse.Error is { } error) - { - message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code }); - } - } - - return response; - } - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages); - var openAIOptions = ToOpenAIResponseCreationOptions(options); - - // Make the call to the OpenAIResponseClient and process the streaming results. - DateTimeOffset? createdAt = null; - string? responseId = null; - string? modelId = null; - string? lastMessageId = null; - ChatRole? lastRole = null; - Dictionary outputIndexToMessages = []; - Dictionary? functionCallInfos = null; - await foreach (var streamingUpdate in _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)) - { - switch (streamingUpdate) - { - case StreamingResponseCreatedUpdate createdUpdate: - createdAt = createdUpdate.Response.CreatedAt; - responseId = createdUpdate.Response.Id; - modelId = createdUpdate.Response.Model; - break; - - case StreamingResponseCompletedUpdate completedUpdate: - yield return new() - { - Contents = ToUsageDetails(completedUpdate.Response) is { } usage ? [new UsageContent(usage)] : [], - CreatedAt = createdAt, - ResponseId = responseId, - ConversationId = responseId, - FinishReason = - ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? - (functionCallInfos is not null ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop), - MessageId = lastMessageId, - ModelId = modelId, - Role = lastRole, - }; - break; - - case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: - switch (outputItemAddedUpdate.Item) - { - case MessageResponseItem mri: - outputIndexToMessages[outputItemAddedUpdate.OutputIndex] = mri; - break; - - case FunctionCallResponseItem fcri: - (functionCallInfos ??= [])[outputItemAddedUpdate.OutputIndex] = new(fcri); - break; - } - - break; - - case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate: - _ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex); - break; - - case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: - _ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem); - lastMessageId = messageItem?.Id; - lastRole = ToChatRole(messageItem?.Role); - yield return new ChatResponseUpdate(lastRole, outputTextDeltaUpdate.Delta) - { - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - ResponseId = responseId, - ConversationId = responseId, - }; - break; - - case StreamingResponseFunctionCallArgumentsDeltaUpdate functionCallArgumentsDeltaUpdate: - { - if (functionCallInfos?.TryGetValue(functionCallArgumentsDeltaUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true) - { - _ = (callInfo.Arguments ??= new()).Append(functionCallArgumentsDeltaUpdate.Delta); - } - - break; - } - - case StreamingResponseFunctionCallArgumentsDoneUpdate functionCallOutputDoneUpdate: - { - if (functionCallInfos?.TryGetValue(functionCallOutputDoneUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true) - { - _ = functionCallInfos.Remove(functionCallOutputDoneUpdate.OutputIndex); - - var fci = FunctionCallContent.CreateFromParsedArguments( - callInfo.Arguments?.ToString() ?? string.Empty, - callInfo.ResponseItem.CallId, - callInfo.ResponseItem.FunctionName, - static json => JsonSerializer.Deserialize(json, ResponseClientJsonContext.Default.IDictionaryStringObject)!); - - lastMessageId = callInfo.ResponseItem.Id; - lastRole = ChatRole.Assistant; - yield return new ChatResponseUpdate(lastRole, [fci]) - { - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - ResponseId = responseId, - ConversationId = responseId, - }; - } - - break; - } - - case StreamingResponseErrorUpdate errorUpdate: - yield return new ChatResponseUpdate - { - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - ResponseId = responseId, - Role = lastRole, - ConversationId = responseId, - Contents = - [ - new ErrorContent(errorUpdate.Message) - { - ErrorCode = errorUpdate.Code, - Details = errorUpdate.Param, - } - ], - }; - break; - - case StreamingResponseRefusalDoneUpdate refusalDone: - yield return new ChatResponseUpdate - { - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - ResponseId = responseId, - Role = lastRole, - ConversationId = responseId, - Contents = [new ErrorContent(refusalDone.Refusal) { ErrorCode = nameof(ResponseContentPart.Refusal) }], - }; - break; - } - } - } - - /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IChatClient interface. - } - - /// Creates a from a . - private static ChatRole ToChatRole(MessageRole? role) => - role switch - { - MessageRole.System => ChatRole.System, - MessageRole.Developer => _chatRoleDeveloper, - MessageRole.User => ChatRole.User, - _ => ChatRole.Assistant, - }; - - /// Creates a from a . - private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => - statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : - statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : - null; - - /// Converts a to a . - private static ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options) - { - ResponseCreationOptions result = new(); - - if (options is not null) - { - // Handle strongly-typed properties. - result.MaxOutputTokenCount = options.MaxOutputTokens; - result.PreviousResponseId = options.ConversationId; - result.TopP = options.TopP; - result.Temperature = options.Temperature; - result.ParallelToolCallsEnabled = options.AllowMultipleToolCalls; - - // Handle loosely-typed properties from AdditionalProperties. - if (options.AdditionalProperties is { Count: > 0 } additionalProperties) - { - if (additionalProperties.TryGetValue(nameof(result.EndUserId), out string? endUserId)) - { - result.EndUserId = endUserId; - } - - if (additionalProperties.TryGetValue(nameof(result.Instructions), out string? instructions)) - { - result.Instructions = instructions; - } - - if (additionalProperties.TryGetValue(nameof(result.Metadata), out IDictionary? metadata)) - { - foreach (KeyValuePair kvp in metadata) - { - result.Metadata[kvp.Key] = kvp.Value; - } - } - - if (additionalProperties.TryGetValue(nameof(result.ReasoningOptions), out ResponseReasoningOptions? reasoningOptions)) - { - result.ReasoningOptions = reasoningOptions; - } - - if (additionalProperties.TryGetValue(nameof(result.StoredOutputEnabled), out bool storeOutputEnabled)) - { - result.StoredOutputEnabled = storeOutputEnabled; - } - - if (additionalProperties.TryGetValue(nameof(result.TruncationMode), out ResponseTruncationMode truncationMode)) - { - result.TruncationMode = truncationMode; - } - } - - // Populate tools if there are any. - if (options.Tools is { Count: > 0 } tools) - { - foreach (AITool tool in tools) - { - switch (tool) - { - case AIFunction af: - var oaitool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(af), ResponseClientJsonContext.Default.ResponseToolJson)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson)); - result.Tools.Add(ResponseTool.CreateFunctionTool(af.Name, af.Description, functionParameters)); - break; - - case HostedWebSearchTool: - WebSearchToolLocation? location = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) - { - location = objLocation as WebSearchToolLocation; - } - - WebSearchToolContextSize? size = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && - objSize is WebSearchToolContextSize) - { - size = (WebSearchToolContextSize)objSize; - } - - result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); - break; - } - } - - switch (options.ToolMode) - { - case NoneChatToolMode: - result.ToolChoice = ResponseToolChoice.CreateNoneChoice(); - break; - - case AutoChatToolMode: - case null: - result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); - break; - - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is not null ? - ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) : - ResponseToolChoice.CreateRequiredChoice(); - break; - } - } - - // Handle response format. - if (options.ResponseFormat is ChatResponseFormatText) - { - result.TextOptions = new() - { - TextFormat = ResponseTextFormat.CreateTextFormat() - }; - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.TextOptions = new() - { - TextFormat = SchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? - ResponseTextFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ResponseClientJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : - ResponseTextFormat.CreateJsonObjectFormat(), - }; - } - } - - return result; - } - - /// Convert a sequence of s to s. - private static IEnumerable ToOpenAIResponseItems( - IEnumerable inputs) - { - foreach (ChatMessage input in inputs) - { - if (input.Role == ChatRole.System || - input.Role == _chatRoleDeveloper) - { - string text = input.Text; - if (!string.IsNullOrWhiteSpace(text)) - { - yield return input.Role == ChatRole.System ? - ResponseItem.CreateSystemMessageItem(text) : - ResponseItem.CreateDeveloperMessageItem(text); - } - - continue; - } - - if (input.Role == ChatRole.User) - { - yield return ResponseItem.CreateUserMessageItem(ToOpenAIResponsesContent(input.Contents)); - continue; - } - - if (input.Role == ChatRole.Tool) - { - foreach (AIContent item in input.Contents) - { - switch (item) - { - case FunctionResultContent resultContent: - string? result = resultContent.Result as string; - if (result is null && resultContent.Result is not null) - { - try - { - result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); - } - catch (NotSupportedException) - { - // If the type can't be serialized, skip it. - } - } - - yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty); - break; - } - } - - continue; - } - - if (input.Role == ChatRole.Assistant) - { - foreach (AIContent item in input.Contents) - { - switch (item) - { - case TextContent textContent: - yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); - break; - - case FunctionCallContent callContent: - yield return ResponseItem.CreateFunctionCallItem( - callContent.CallId, - callContent.Name, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( - callContent.Arguments, - AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); - break; - } - } - - continue; - } - } - } - - /// Extract usage details from an . - private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) - { - UsageDetails? ud = null; - if (openAIResponse?.Usage is { } usage) - { - ud = new() - { - InputTokenCount = usage.InputTokenCount, - OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount, - }; - - if (usage.OutputTokenDetails is { } outputDetails) - { - ud.AdditionalCounts ??= []; - - const string OutputDetails = nameof(usage.OutputTokenDetails); - ud.AdditionalCounts.Add($"{OutputDetails}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); - } - } - - return ud; - } - - /// Convert a sequence of s to a list of . - private static List ToAIContents(IEnumerable contents) - { - List results = []; - - foreach (ResponseContentPart part in contents) - { - switch (part.Kind) - { - case ResponseContentPartKind.OutputText: - results.Add(new TextContent(part.Text)); - break; - - case ResponseContentPartKind.Refusal: - results.Add(new ErrorContent(part.Refusal) { ErrorCode = nameof(ResponseContentPartKind.Refusal) }); - break; - } - } - - return results; - } - - /// Convert a list of s to a list of . - private static List ToOpenAIResponsesContent(IList contents) - { - List parts = []; - foreach (var content in contents) - { - switch (content) - { - case TextContent textContent: - parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); - break; - - case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); - break; - - case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); - break; - - case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - parts.Add(ResponseContentPart.CreateInputFilePart(null, $"{Guid.NewGuid():N}.pdf", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(dataContent.Uri, ResponseClientJsonContext.Default.String)))); - break; - - case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): - parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); - break; - } - } - - if (parts.Count == 0) - { - parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); - } - - return parts; - } - - /// Used to create the JSON payload for an OpenAI chat tool description. - private sealed class ResponseToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } - } - - /// POCO representing function calling info. - /// Used to concatenation information for a single function call from across multiple streaming updates. - private sealed class FunctionCallInfo(FunctionCallResponseItem item) - { - public readonly FunctionCallResponseItem ResponseItem = item; - public StringBuilder? Arguments; - } - - /// Source-generated JSON type information. - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - UseStringEnumConverter = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true)] - [JsonSerializable(typeof(ResponseToolJson))] - [JsonSerializable(typeof(JsonElement))] - [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(string[]))] - private sealed partial class ResponseClientJsonContext : JsonSerializerContext; -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs new file mode 100644 index 00000000000..6913e999936 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -0,0 +1,1429 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +#pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable S3254 // Default parameter values should not be passed as arguments +#pragma warning disable SA1204 // Static elements should appear before instance elements + +namespace Microsoft.Extensions.AI; + +/// Represents an for an . +internal sealed class OpenAIResponsesChatClient : IChatClient +{ + // Fix this to not use reflection once https://github.com/openai/openai-dotnet/issues/643 is addressed. + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + private static readonly Type? _internalResponseReasoningSummaryTextDeltaEventType = Type.GetType("OpenAI.Responses.InternalResponseReasoningSummaryTextDeltaEvent, OpenAI"); + private static readonly PropertyInfo? _summaryTextDeltaProperty = _internalResponseReasoningSummaryTextDeltaEventType?.GetProperty("Delta"); + + // These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept + // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, ResponseCreationOptions, RequestOptions, Task>>? + _createResponseAsync = + (Func, ResponseCreationOptions, RequestOptions, Task>>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.CreateResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, Task>>)); + + private static readonly Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>? + _createResponseStreamingAsync = + (Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>)); + + private static readonly Func>>? + _getResponseAsync = + (Func>>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.GetResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(string), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func>>)); + + private static readonly Func>? + _getResponseStreamingAsync = + (Func>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.GetResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(string), typeof(RequestOptions), typeof(int?)], null) + ?.CreateDelegate(typeof(Func>)); + + /// Metadata about the client. + private readonly ChatClientMetadata _metadata; + + /// The underlying . + private readonly OpenAIResponseClient _responseClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// is . + public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) + { + _ = Throw.IfNull(responseClient); + + _responseClient = responseClient; + + _metadata = new("openai", responseClient.Endpoint, responseClient.Model); + } + + /// + object? IChatClient.GetService(Type serviceType, object? serviceKey) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(ChatClientMetadata) ? _metadata : + serviceType == typeof(OpenAIResponseClient) ? _responseClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Convert the inputs into what OpenAIResponseClient expects. + var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + + // Provided continuation token signals that an existing background response should be fetched. + if (GetContinuationToken(messages, options) is { } token) + { + var getTask = _getResponseAsync is not null ? + _getResponseAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: false)) : + _responseClient.GetResponseAsync(token.ResponseId, cancellationToken); + var response = (await getTask.ConfigureAwait(false)).Value; + + return FromOpenAIResponse(response, openAIOptions, openAIConversationId); + } + + var openAIResponseItems = ToOpenAIResponseItems(messages, options); + + // Make the call to the OpenAIResponseClient. + var createTask = _createResponseAsync is not null ? + _createResponseAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken); + var openAIResponse = (await createTask.ConfigureAwait(false)).Value; + + // Convert the response to a ChatResponse. + return FromOpenAIResponse(openAIResponse, openAIOptions, openAIConversationId); + } + + internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions, string? conversationId) + { + // Convert and return the results. + ChatResponse response = new() + { + ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? openAIResponse.Id), + CreatedAt = openAIResponse.CreatedAt, + ContinuationToken = CreateContinuationToken(openAIResponse), + FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), + ModelId = openAIResponse.Model, + RawRepresentation = openAIResponse, + ResponseId = openAIResponse.Id, + Usage = ToUsageDetails(openAIResponse), + }; + + if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) + { + (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; + } + + if (openAIResponse.Error is not null) + { + (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; + } + + if (openAIResponse.OutputItems is not null) + { + response.Messages = [.. ToChatMessages(openAIResponse.OutputItems, openAIOptions)]; + + if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) + { + lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); + } + + foreach (var message in response.Messages) + { + message.CreatedAt ??= openAIResponse.CreatedAt; + } + } + + return response; + } + + internal static IEnumerable ToChatMessages(IEnumerable items, ResponseCreationOptions? options = null) + { + ChatMessage? message = null; + + foreach (ResponseItem outputItem in items) + { + message ??= new(ChatRole.Assistant, (string?)null); + + switch (outputItem) + { + case MessageResponseItem messageItem: + if (message.MessageId is not null && message.MessageId != messageItem.Id) + { + yield return message; + message = new ChatMessage(); + } + + message.MessageId = messageItem.Id; + message.RawRepresentation = messageItem; + message.Role = ToChatRole(messageItem.Role); + ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); + break; + + case ReasoningResponseItem reasoningItem: + message.Contents.Add(new TextReasoningContent(reasoningItem.GetSummaryText()) + { + ProtectedData = reasoningItem.EncryptedContent, + RawRepresentation = outputItem, + }); + break; + + case FunctionCallResponseItem functionCall: + var fcc = OpenAIClientExtensions.ParseCallContent(functionCall.FunctionArguments, functionCall.CallId, functionCall.FunctionName); + fcc.RawRepresentation = outputItem; + message.Contents.Add(fcc); + break; + + case FunctionCallOutputResponseItem functionCallOutputItem: + message.Contents.Add(new FunctionResultContent(functionCallOutputItem.CallId, functionCallOutputItem.FunctionOutput) { RawRepresentation = functionCallOutputItem }); + break; + + case McpToolCallItem mtci: + AddMcpToolCallContent(mtci, message.Contents); + break; + + case McpToolCallApprovalRequestItem mtcari: + message.Contents.Add(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + RawRepresentation = mtcari, + }) + { + RawRepresentation = mtcari, + }); + break; + + case McpToolCallApprovalResponseItem mtcari: + message.Contents.Add(new McpServerToolApprovalResponseContent(mtcari.ApprovalRequestId, mtcari.Approved) { RawRepresentation = mtcari }); + break; + + case CodeInterpreterCallResponseItem cicri: + AddCodeInterpreterContents(cicri, message.Contents); + break; + + case ImageGenerationCallResponseItem imageGenItem: + AddImageGenerationContents(imageGenItem, options, message.Contents); + break; + + default: + message.Contents.Add(new() { RawRepresentation = outputItem }); + break; + } + } + + if (message is not null) + { + yield return message; + } + } + + /// + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + + // Provided continuation token signals that an existing background response should be fetched. + if (GetContinuationToken(messages, options) is { } token) + { + IAsyncEnumerable getUpdates = _getResponseStreamingAsync is not null ? + _getResponseStreamingAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: true), token.SequenceNumber) : + _responseClient.GetResponseStreamingAsync(token.ResponseId, token.SequenceNumber, cancellationToken); + + return FromOpenAIStreamingResponseUpdatesAsync(getUpdates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken); + } + + var openAIResponseItems = ToOpenAIResponseItems(messages, options); + + var createUpdates = _createResponseStreamingAsync is not null ? + _createResponseStreamingAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); + + return FromOpenAIStreamingResponseUpdatesAsync(createUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken); + } + + internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync( + IAsyncEnumerable streamingResponseUpdates, + ResponseCreationOptions? options, + string? conversationId, + string? resumeResponseId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + DateTimeOffset? createdAt = null; + string? responseId = resumeResponseId; + string? modelId = null; + string? lastMessageId = null; + ChatRole? lastRole = null; + bool anyFunctions = false; + ResponseStatus? latestResponseStatus = null; + + UpdateConversationId(resumeResponseId); + + await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + // Create an update populated with the current state of the response. + ChatResponseUpdate CreateUpdate(AIContent? content = null) => + new(lastRole, content is not null ? [content] : null) + { + ConversationId = conversationId, + CreatedAt = createdAt, + MessageId = lastMessageId, + ModelId = modelId, + RawRepresentation = streamingUpdate, + ResponseId = responseId, + ContinuationToken = CreateContinuationToken( + responseId!, + latestResponseStatus, + options?.BackgroundModeEnabled, + streamingUpdate.SequenceNumber) + }; + + switch (streamingUpdate) + { + case StreamingResponseCreatedUpdate createdUpdate: + createdAt = createdUpdate.Response.CreatedAt; + responseId = createdUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = createdUpdate.Response.Model; + latestResponseStatus = createdUpdate.Response.Status; + goto default; + + case StreamingResponseQueuedUpdate queuedUpdate: + createdAt = queuedUpdate.Response.CreatedAt; + responseId = queuedUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = queuedUpdate.Response.Model; + latestResponseStatus = queuedUpdate.Response.Status; + goto default; + + case StreamingResponseInProgressUpdate inProgressUpdate: + createdAt = inProgressUpdate.Response.CreatedAt; + responseId = inProgressUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = inProgressUpdate.Response.Model; + latestResponseStatus = inProgressUpdate.Response.Status; + goto default; + + case StreamingResponseIncompleteUpdate incompleteUpdate: + createdAt = incompleteUpdate.Response.CreatedAt; + responseId = incompleteUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = incompleteUpdate.Response.Model; + latestResponseStatus = incompleteUpdate.Response.Status; + goto default; + + case StreamingResponseFailedUpdate failedUpdate: + createdAt = failedUpdate.Response.CreatedAt; + responseId = failedUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = failedUpdate.Response.Model; + latestResponseStatus = failedUpdate.Response.Status; + goto default; + + case StreamingResponseCompletedUpdate completedUpdate: + { + createdAt = completedUpdate.Response.CreatedAt; + responseId = completedUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = completedUpdate.Response.Model; + latestResponseStatus = completedUpdate.Response?.Status; + var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null); + update.FinishReason = + ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? + (anyFunctions ? ChatFinishReason.ToolCalls : + ChatFinishReason.Stop); + yield return update; + break; + } + + case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: + switch (outputItemAddedUpdate.Item) + { + case MessageResponseItem mri: + lastMessageId = outputItemAddedUpdate.Item.Id; + lastRole = ToChatRole(mri.Role); + break; + + case FunctionCallResponseItem fcri: + anyFunctions = true; + lastRole = ChatRole.Assistant; + break; + } + + goto default; + + case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: + yield return CreateUpdate(new TextContent(outputTextDeltaUpdate.Delta)); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is FunctionCallResponseItem fcri: + yield return CreateUpdate(OpenAIClientExtensions.ParseCallContent(fcri.FunctionArguments.ToString(), fcri.CallId, fcri.FunctionName)); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallItem mtci: + var mcpUpdate = CreateUpdate(); + AddMcpToolCallContent(mtci, mcpUpdate.Contents); + yield return mcpUpdate; + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolDefinitionListItem mtdli: + yield return CreateUpdate(new AIContent { RawRepresentation = mtdli }); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallApprovalRequestItem mtcari: + yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + RawRepresentation = mtcari, + }) + { + RawRepresentation = mtcari, + }); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is CodeInterpreterCallResponseItem cicri: + var codeUpdate = CreateUpdate(); + AddCodeInterpreterContents(cicri, codeUpdate.Contents); + yield return codeUpdate; + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when + outputItemDoneUpdate.Item is MessageResponseItem mri && + mri.Content is { Count: > 0 } content && + content.Any(c => c.OutputTextAnnotations is { Count: > 0 }): + AIContent annotatedContent = new(); + foreach (var c in content) + { + PopulateAnnotations(c, annotatedContent); + } + + yield return CreateUpdate(annotatedContent); + break; + + case StreamingResponseErrorUpdate errorUpdate: + yield return CreateUpdate(new ErrorContent(errorUpdate.Message) + { + ErrorCode = errorUpdate.Code, + Details = errorUpdate.Param, + }); + break; + + case StreamingResponseRefusalDoneUpdate refusalDone: + yield return CreateUpdate(new ErrorContent(refusalDone.Refusal) + { + ErrorCode = nameof(ResponseContentPart.Refusal), + }); + break; + + // Replace with public StreamingResponseReasoningSummaryTextDelta when available + case StreamingResponseUpdate when + streamingUpdate.GetType() == _internalResponseReasoningSummaryTextDeltaEventType && + _summaryTextDeltaProperty?.GetValue(streamingUpdate) is string delta: + yield return CreateUpdate(new TextReasoningContent(delta)); + break; + + case StreamingResponseImageGenerationCallInProgressUpdate imageGenInProgress: + yield return CreateUpdate(new ImageGenerationToolCallContent + { + ImageId = imageGenInProgress.ItemId, + RawRepresentation = imageGenInProgress, + + }); + goto default; + + case StreamingResponseImageGenerationCallPartialImageUpdate streamingImageGenUpdate: + yield return CreateUpdate(GetImageGenerationResult(streamingImageGenUpdate, options)); + break; + + default: + yield return CreateUpdate(); + break; + } + } + + void UpdateConversationId(string? id) + { + if (options?.StoredOutputEnabled is false) + { + conversationId = null; + } + else + { + conversationId ??= id; + } + } + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IChatClient interface. + } + + internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) + { + switch (tool) + { + case ResponseToolAITool rtat: + return rtat.Tool; + + case AIFunctionDeclaration aiFunction: + return ToResponseTool(aiFunction, options); + + case HostedWebSearchTool webSearchTool: + WebSearchToolLocation? location = null; + if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) + { + location = objLocation as WebSearchToolLocation; + } + + WebSearchToolContextSize? size = null; + if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && + objSize is WebSearchToolContextSize) + { + size = (WebSearchToolContextSize)objSize; + } + + return ResponseTool.CreateWebSearchTool(location, size); + + case HostedFileSearchTool fileSearchTool: + return ResponseTool.CreateFileSearchTool( + fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], + fileSearchTool.MaximumResultCount); + + case HostedImageGenerationTool imageGenerationTool: + return ToImageResponseTool(imageGenerationTool); + + case HostedCodeInterpreterTool codeTool: + return ResponseTool.CreateCodeInterpreterTool( + new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? + CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : + new())); + + case HostedMcpServerTool mcpTool: + McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + url, + mcpTool.AuthorizationToken, + mcpTool.ServerDescription) : + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + new McpToolConnectorId(mcpTool.ServerAddress), + mcpTool.AuthorizationToken, + mcpTool.ServerDescription); + + if (mcpTool.AllowedTools is not null) + { + responsesMcpTool.AllowedTools = new(); + AddAllMcpFilters(mcpTool.AllowedTools, responsesMcpTool.AllowedTools); + } + + switch (mcpTool.ApprovalMode) + { + case HostedMcpServerToolAlwaysRequireApprovalMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval); + break; + + case HostedMcpServerToolNeverRequireApprovalMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval); + break; + + case HostedMcpServerToolRequireSpecificApprovalMode specificMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(new CustomMcpToolCallApprovalPolicy()); + + if (specificMode.AlwaysRequireApprovalToolNames is { Count: > 0 } alwaysRequireToolNames) + { + responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval = new(); + AddAllMcpFilters(alwaysRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); + } + + if (specificMode.NeverRequireApprovalToolNames is { Count: > 0 } neverRequireToolNames) + { + responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval = new(); + AddAllMcpFilters(neverRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); + } + + break; + } + + return responsesMcpTool; + + default: + return null; + } + } + + internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) + { + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ResponseTool.CreateFunctionTool( + aiFunction.Name, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict, + aiFunction.Description); + } + + internal static ImageGenerationTool ToImageResponseTool(HostedImageGenerationTool imageGenerationTool) + { + ImageGenerationTool result = new(); + ImageGenerationOptions? imageGenerationOptions = imageGenerationTool.Options; + + // Model: Image generation model + result.Model = imageGenerationOptions?.ModelId; + + // Size: Image dimensions (e.g., 1024x1024, 1024x1536) + if (imageGenerationOptions?.ImageSize is not null) + { + result.Size = new ImageGenerationToolSize( + imageGenerationOptions.ImageSize.Value.Width, + imageGenerationOptions.ImageSize.Value.Height); + } + + // OutputFileFormat: File output format + if (imageGenerationOptions?.MediaType is not null) + { + result.OutputFileFormat = imageGenerationOptions.MediaType switch + { + "image/png" => ImageGenerationToolOutputFileFormat.Png, + "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, + "image/webp" => ImageGenerationToolOutputFileFormat.Webp, + _ => null, + }; + } + + // PartialImageCount: Whether to return partial images during generation + result.PartialImageCount ??= imageGenerationOptions?.StreamingCount; + + return result; + } + + /// Creates a from a . + private static ChatRole ToChatRole(MessageRole? role) => + role switch + { + MessageRole.System => ChatRole.System, + MessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper, + MessageRole.User => ChatRole.User, + _ => ChatRole.Assistant, + }; + + /// Creates a from a . + private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => + statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : + statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : + null; + + /// Converts a to a . + private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options, out string? openAIConversationId) + { + openAIConversationId = null; + + if (options is null) + { + return new(); + } + + bool hasRawRco = false; + if (options.RawRepresentationFactory?.Invoke(this) is ResponseCreationOptions result) + { + hasRawRco = true; + } + else + { + result = new(); + } + + result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.Temperature ??= options.Temperature; + result.TopP ??= options.TopP; + result.BackgroundModeEnabled ??= options.AllowBackgroundResponses; + OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options.ModelId); + + // If the ResponseCreationOptions.PreviousResponseId is already set (likely rare), then we don't need to do + // anything with regards to Conversation, because they're mutually exclusive and we would want to ignore + // ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the ResponseCreationOptions + // instance to see if a conversation ID has already been set on it and use that conversation ID subsequently if + // it has. If one hasn't been set, but ChatOptions.ConversationId has been set, we'll either set + // ResponseCreationOptions.Conversation if the string represents a conversation ID or else PreviousResponseId. + if (result.PreviousResponseId is null) + { + // Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and + // we can use that to disambiguate whether we're looking at a conversation ID or a response ID. + string? chatOptionsConversationId = options.ConversationId; + bool chatOptionsHasOpenAIConversationId = chatOptionsConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true; + + if (hasRawRco || chatOptionsHasOpenAIConversationId) + { + _ = result.Patch.TryGetValue("$.conversation"u8, out openAIConversationId); + if (openAIConversationId is null && chatOptionsHasOpenAIConversationId) + { + result.Patch.Set("$.conversation"u8, chatOptionsConversationId!); + openAIConversationId = chatOptionsConversationId; + } + } + + // If we still don't have a conversation ID, and ChatOptions.ConversationId is set, treat it as a response ID. + if (openAIConversationId is null && options.ConversationId is { } previousResponseId) + { + result.PreviousResponseId = previousResponseId; + } + } + + if (options.Instructions is { } instructions) + { + result.Instructions = string.IsNullOrEmpty(result.Instructions) ? + instructions : + $"{result.Instructions}{Environment.NewLine}{instructions}"; + } + + // Populate tools if there are any. + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (ToResponseTool(tool, options) is { } responseTool) + { + result.Tools.Add(responseTool); + } + } + + if (result.Tools.Count > 0) + { + result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; + } + + if (result.ToolChoice is null && result.Tools.Count > 0) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + result.ToolChoice = ResponseToolChoice.CreateNoneChoice(); + break; + + case AutoChatToolMode: + case null: + result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is not null ? + ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) : + ResponseToolChoice.CreateRequiredChoice(); + break; + } + } + } + + if (result.TextOptions?.TextFormat is null && + ToOpenAIResponseTextFormat(options.ResponseFormat, options) is { } newFormat) + { + (result.TextOptions ??= new()).TextFormat = newFormat; + } + + return result; + } + + internal static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) => + format switch + { + ChatResponseFormatText => ResponseTextFormat.CreateTextFormat(), + + ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => + ResponseTextFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties)), + + ChatResponseFormatJson => ResponseTextFormat.CreateJsonObjectFormat(), + + _ => null, + }; + + /// Convert a sequence of s to s. + internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs, ChatOptions? options) + { + _ = options; // currently unused + + Dictionary? idToContentMapping = null; + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System || + input.Role == OpenAIClientExtensions.ChatRoleDeveloper) + { + string text = input.Text; + if (!string.IsNullOrWhiteSpace(text)) + { + yield return input.Role == ChatRole.System ? + ResponseItem.CreateSystemMessageItem(text) : + ResponseItem.CreateDeveloperMessageItem(text); + } + + continue; + } + + if (input.Role == ChatRole.User) + { + // Some AIContent items may map to ResponseItems directly. Others map to ResponseContentParts that need to be grouped together. + // In order to preserve ordering, we yield ResponseItems as we find them, grouping ResponseContentParts between those yielded + // items together into their own yielded item. + + List? parts = null; + bool responseItemYielded = false; + + foreach (AIContent item in input.Contents) + { + // Items that directly map to a ResponseItem. + ResponseItem? directItem = item switch + { + { RawRepresentation: ResponseItem rawRep } => rawRep, + McpServerToolApprovalResponseContent mcpResp => ResponseItem.CreateMcpApprovalResponseItem(mcpResp.Id, mcpResp.Approved), + _ => null + }; + + if (directItem is not null) + { + // Yield any parts already accumulated. + if (parts is not null) + { + yield return ResponseItem.CreateUserMessageItem(parts); + parts = null; + } + + // Now yield the directly mapped item. + yield return directItem; + + responseItemYielded = true; + continue; + } + + // Items that map into ResponseContentParts and are grouped. + switch (item) + { + case AIContent when item.RawRepresentation is ResponseContentPart rawRep: + (parts ??= []).Add(rawRep); + break; + + case TextContent textContent: + (parts ??= []).Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): + (parts ??= []).Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + (parts ??= []).Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); + break; + + case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): + (parts ??= []).Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf")); + break; + + case HostedFileContent fileContent: + (parts ??= []).Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId)); + break; + + case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): + (parts ??= []).Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); + break; + } + } + + // If we haven't accumulated any parts nor have we yielded any items, manufacture an empty input text part + // to guarantee that every user message results in at least one ResponseItem. + if (parts is null && !responseItemYielded) + { + parts = []; + parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); + responseItemYielded = true; + } + + // Final yield of any accumulated parts. + if (parts is not null) + { + yield return ResponseItem.CreateUserMessageItem(parts); + parts = null; + } + + continue; + } + + if (input.Role == ChatRole.Tool) + { + foreach (AIContent item in input.Contents) + { + switch (item) + { + case AIContent when item.RawRepresentation is ResponseItem rawRep: + yield return rawRep; + break; + + case FunctionResultContent resultContent: + static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumerable contents) + { + List elements = []; + + foreach (var content in contents) + { + switch (content) + { + case TextContent tc: + elements.Add(new() + { + Type = "input_text", + Text = tc.Text + }); + break; + + case DataContent dc when dc.HasTopLevelMediaType("image"): + elements.Add(new() + { + Type = "input_image", + ImageUrl = dc.Uri + }); + break; + + case DataContent dc: + elements.Add(new() + { + Type = "input_file", + FileData = dc.Uri, // contrary to the docs, file_data is expected to be a data URI, not just the base64 portion + FileName = dc.Name ?? $"file_{Guid.NewGuid():N}", // contrary to the docs, file_name is required + }); + break; + + case UriContent uc when uc.HasTopLevelMediaType("image"): + elements.Add(new() + { + Type = "input_image", + ImageUrl = uc.Uri.AbsoluteUri, + }); + break; + + case UriContent uc: + elements.Add(new() + { + Type = "input_file", + FileUrl = uc.Uri.AbsoluteUri, + }); + break; + + case HostedFileContent fc: + elements.Add(new() + { + Type = fc.HasTopLevelMediaType("image") ? "input_image" : "input_file", + FileId = fc.FileId, + FileName = fc.Name, + }); + break; + + default: + // Fallback to serializing and storing the resulting JSON as text. + try + { + elements.Add(new() + { + Type = "input_text", + Text = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))), + }); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + break; + } + } + + FunctionCallOutputResponseItem outputItem = new(callId, string.Empty); + if (elements.Count > 0) + { + outputItem.Patch.Set("$.output"u8, JsonSerializer.SerializeToUtf8Bytes(elements, OpenAIJsonContext.Default.ListFunctionToolCallOutputElement).AsSpan()); + } + + return outputItem; + } + + switch (resultContent.Result) + { + case AIContent ac: + yield return SerializeAIContent(resultContent.CallId, [ac]); + break; + + case IEnumerable items: + yield return SerializeAIContent(resultContent.CallId, items); + break; + + default: + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is { } resultObj) + { + try + { + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty); + break; + } + break; + + case McpServerToolApprovalResponseContent mcpApprovalResponseContent: + yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); + break; + } + } + + continue; + } + + if (input.Role == ChatRole.Assistant) + { + foreach (AIContent item in input.Contents) + { + switch (item) + { + case AIContent when item.RawRepresentation is ResponseItem rawRep: + yield return rawRep; + break; + + case TextContent textContent: + yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); + break; + + case TextReasoningContent reasoningContent: + yield return OpenAIResponsesModelFactory.ReasoningResponseItem( + encryptedContent: reasoningContent.ProtectedData, + summaryText: reasoningContent.Text); + break; + + case FunctionCallContent callContent: + yield return ResponseItem.CreateFunctionCallItem( + callContent.CallId, + callContent.Name, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( + callContent.Arguments, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); + break; + + case McpServerToolApprovalRequestContent mcpApprovalRequestContent: + yield return ResponseItem.CreateMcpApprovalRequestItem( + mcpApprovalRequestContent.Id, + mcpApprovalRequestContent.ToolCall.ServerName, + mcpApprovalRequestContent.ToolCall.ToolName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(mcpApprovalRequestContent.ToolCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); + break; + + case McpServerToolCallContent mstcc: + (idToContentMapping ??= [])[mstcc.CallId] = mstcc; + break; + + case McpServerToolResultContent mstrc: + if (idToContentMapping?.TryGetValue(mstrc.CallId, out AIContent? callContentFromMapping) is true && + callContentFromMapping is McpServerToolCallContent associatedCall) + { + _ = idToContentMapping.Remove(mstrc.CallId); + McpToolCallItem mtci = ResponseItem.CreateMcpToolCallItem( + associatedCall.ServerName, + associatedCall.ToolName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(associatedCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); + if (mstrc.Output?.OfType().FirstOrDefault() is ErrorContent errorContent) + { + mtci.Error = BinaryData.FromString(errorContent.Message); + } + else + { + mtci.ToolOutput = string.Concat(mstrc.Output?.OfType() ?? []); + } + + yield return mtci; + } + + break; + } + } + + continue; + } + } + } + + /// Extract usage details from an . + private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) + { + UsageDetails? ud = null; + if (openAIResponse?.Usage is { } usage) + { + ud = new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount, + }; + + if (usage.InputTokenDetails is { } inputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); + } + + if (usage.OutputTokenDetails is { } outputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); + } + } + + return ud; + } + + /// Convert a sequence of s to a list of . + private static List ToAIContents(IEnumerable contents) + { + List results = []; + + foreach (ResponseContentPart part in contents) + { + switch (part.Kind) + { + case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText: + TextContent text = new(part.Text) { RawRepresentation = part }; + PopulateAnnotations(part, text); + results.Add(text); + break; + + case ResponseContentPartKind.InputFile: + if (!string.IsNullOrWhiteSpace(part.InputImageFileId)) + { + results.Add(new HostedFileContent(part.InputImageFileId) { MediaType = "image/*", RawRepresentation = part }); + } + else if (!string.IsNullOrWhiteSpace(part.InputFileId)) + { + results.Add(new HostedFileContent(part.InputFileId) { Name = part.InputFilename, RawRepresentation = part }); + } + else if (part.InputFileBytes is not null) + { + results.Add(new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream") + { + Name = part.InputFilename, + RawRepresentation = part, + }); + } + + break; + + case ResponseContentPartKind.Refusal: + results.Add(new ErrorContent(part.Refusal) + { + ErrorCode = nameof(ResponseContentPartKind.Refusal), + RawRepresentation = part, + }); + break; + + default: + results.Add(new() { RawRepresentation = part }); + break; + } + } + + return results; + } + + /// Converts any annotations from and stores them in . + private static void PopulateAnnotations(ResponseContentPart source, AIContent destination) + { + if (source.OutputTextAnnotations is { Count: > 0 }) + { + foreach (var ota in source.OutputTextAnnotations) + { + CitationAnnotation ca = new() + { + RawRepresentation = ota, + }; + + switch (ota) + { + case ContainerFileCitationMessageAnnotation cfcma: + ca.AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = cfcma.StartIndex, EndIndex = cfcma.EndIndex }]; + ca.FileId = cfcma.FileId; + ca.Title = cfcma.Filename; + break; + + case FilePathMessageAnnotation fpma: + ca.FileId = fpma.FileId; + break; + + case FileCitationMessageAnnotation fcma: + ca.FileId = fcma.FileId; + break; + + case UriCitationMessageAnnotation ucma: + ca.AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ucma.StartIndex, EndIndex = ucma.EndIndex }]; + ca.Url = ucma.Uri; + ca.Title = ucma.Title; + break; + } + + (destination.Annotations ??= []).Add(ca); + } + } + } + + /// Adds new for the specified into . + private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) + { + contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName, mtci.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(mtci.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + + // We purposefully do not set the RawRepresentation on the McpServerToolCallContent, only on the McpServerToolResultContent, to avoid + // the same McpToolCallItem being included on two different AIContent instances. When these are roundtripped, we want only one + // McpToolCallItem sent back for the pair. + }); + + contents.Add(new McpServerToolResultContent(mtci.Id) + { + RawRepresentation = mtci, + Output = [mtci.Error is not null ? + new ErrorContent(mtci.Error.ToString()) : + new TextContent(mtci.ToolOutput)], + }); + } + + /// Adds all of the tool names from to . + private static void AddAllMcpFilters(IList toolNames, McpToolFilter filter) + { + foreach (var toolName in toolNames) + { + filter.ToolNames.Add(toolName); + } + } + + /// Adds new for the specified into . + private static void AddCodeInterpreterContents(CodeInterpreterCallResponseItem cicri, IList contents) + { + contents.Add(new CodeInterpreterToolCallContent + { + CallId = cicri.Id, + Inputs = !string.IsNullOrWhiteSpace(cicri.Code) ? [new DataContent(Encoding.UTF8.GetBytes(cicri.Code), "text/x-python")] : null, + + // We purposefully do not set the RawRepresentation on the HostedCodeInterpreterToolCallContent, only on the HostedCodeInterpreterToolResultContent, to avoid + // the same CodeInterpreterCallResponseItem being included on two different AIContent instances. When these are roundtripped, we want only one + // CodeInterpreterCallResponseItem sent back for the pair. + }); + + contents.Add(new CodeInterpreterToolResultContent + { + CallId = cicri.Id, + Outputs = cicri.Outputs is { Count: > 0 } outputs ? outputs.Select(o => + o switch + { + CodeInterpreterCallImageOutput cicio => new UriContent(cicio.ImageUri, OpenAIClientExtensions.ImageUriToMediaType(cicio.ImageUri)) { RawRepresentation = cicio }, + CodeInterpreterCallLogsOutput ciclo => new TextContent(ciclo.Logs) { RawRepresentation = ciclo }, + _ => null, + }).OfType().ToList() : null, + RawRepresentation = cicri, + }); + } + + private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, ResponseCreationOptions? options, IList contents) + { + var imageGenTool = options?.Tools.OfType().FirstOrDefault(); + string outputFormat = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; + + contents.Add(new ImageGenerationToolCallContent + { + ImageId = outputItem.Id, + }); + + contents.Add(new ImageGenerationToolResultContent + { + ImageId = outputItem.Id, + RawRepresentation = outputItem, + Outputs = new List + { + new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}") + } + }); + } + + private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, ResponseCreationOptions? options) + { + var imageGenTool = options?.Tools.OfType().FirstOrDefault(); + var outputType = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; + + var bytes = update.PartialImageBytes; + + if (bytes is null || bytes.Length == 0) + { + // workaround https://github.com/openai/openai-dotnet/issues/809 + if (update.Patch.TryGetJson("$.partial_image_b64"u8, out var jsonBytes)) + { + Utf8JsonReader reader = new(jsonBytes.Span); + _ = reader.Read(); + bytes = BinaryData.FromBytes(reader.GetBytesFromBase64()); + } + } + + return new ImageGenerationToolResultContent + { + ImageId = update.ItemId, + RawRepresentation = update, + Outputs = new List + { + new DataContent(bytes, $"image/{outputType}") + { + AdditionalProperties = new() + { + [nameof(update.ItemId)] = update.ItemId, + [nameof(update.OutputIndex)] = update.OutputIndex, + [nameof(update.PartialImageIndex)] = update.PartialImageIndex + } + } + } + }; + } + + private static OpenAIResponsesContinuationToken? CreateContinuationToken(OpenAIResponse openAIResponse) + { + return CreateContinuationToken( + responseId: openAIResponse.Id, + responseStatus: openAIResponse.Status, + isBackgroundModeEnabled: openAIResponse.BackgroundModeEnabled); + } + + private static OpenAIResponsesContinuationToken? CreateContinuationToken( + string responseId, + ResponseStatus? responseStatus, + bool? isBackgroundModeEnabled, + int? updateSequenceNumber = null) + { + if (isBackgroundModeEnabled is not true) + { + return null; + } + + // Returns a continuation token for in-progress or queued responses as they are not yet complete. + // Also returns a continuation token if there is no status but there is a sequence number, + // which can occur for certain streaming updates related to response content part updates: response.content_part.*, + // response.output_text.* + if ((responseStatus is ResponseStatus.InProgress or ResponseStatus.Queued) || + (responseStatus is null && updateSequenceNumber is not null)) + { + return new OpenAIResponsesContinuationToken(responseId) + { + SequenceNumber = updateSequenceNumber, + }; + } + + // For all other statuses: completed, failed, canceled, incomplete + // return null to indicate the operation is finished allowing the caller + // to stop and access the final result, failure details, reason for incompletion, etc. + return null; + } + + private static OpenAIResponsesContinuationToken? GetContinuationToken(IEnumerable messages, ChatOptions? options = null) + { + if (options?.ContinuationToken is { } token) + { + if (messages.Any()) + { + throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); + } + + return OpenAIResponsesContinuationToken.FromToken(token); + } + + return null; + } + + /// Provides an wrapper for a . + internal sealed class ResponseToolAITool(ResponseTool tool) : AITool + { + public ResponseTool Tool => tool; + public override string Name => Tool.GetType().Name; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(Tool) ? Tool : + base.GetService(serviceType, serviceKey); + } + } + + /// DTO for an array element in OpenAI Responses' "Function tool call output". + internal sealed class FunctionToolCallOutputElement + { + [JsonPropertyName("type")] + public string? Type { get; set; } // input_text, input_image, or input_file + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("image_url")] + public string? ImageUrl { get; set; } + + [JsonPropertyName("file_id")] + public string? FileId { get; set; } + + [JsonPropertyName("file_data")] + public string? FileData { get; set; } + + [JsonPropertyName("file_url")] + public string? FileUrl { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs new file mode 100644 index 00000000000..229f8b40f69 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a continuation token for OpenAI responses. +/// +/// The token is used for resuming streamed background responses and continuing +/// non-streamed background responses until completion. +/// +internal sealed class OpenAIResponsesContinuationToken : ResponseContinuationToken +{ + /// Initializes a new instance of the class. + internal OpenAIResponsesContinuationToken(string responseId) + { + ResponseId = responseId; + } + + /// Gets the Id of the response. + internal string ResponseId { get; } + + /// Gets or sets the sequence number of a streamed update. + internal int? SequenceNumber { get; set; } + + /// + public override ReadOnlyMemory ToBytes() + { + using MemoryStream stream = new(); + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + writer.WriteString("responseId", ResponseId); + + if (SequenceNumber.HasValue) + { + writer.WriteNumber("sequenceNumber", SequenceNumber.Value); + } + + writer.WriteEndObject(); + + writer.Flush(); + + return stream.ToArray(); + } + + /// Create a new instance of from the provided . + /// + /// The token to create the from. + /// A equivalent of the provided . + internal static OpenAIResponsesContinuationToken FromToken(object token) + { + if (token is OpenAIResponsesContinuationToken openAIResponsesContinuationToken) + { + return openAIResponsesContinuationToken; + } + + if (token is not ResponseContinuationToken) + { + Throw.ArgumentException(nameof(token), "Failed to create OpenAIResponsesResumptionToken from provided token because it is not of type ResponseContinuationToken."); + } + + ReadOnlyMemory data = ((ResponseContinuationToken)token).ToBytes(); + + if (data.Length == 0) + { + Throw.ArgumentException(nameof(token), "Failed to create OpenAIResponsesResumptionToken from provided token because it does not contain any data."); + } + + Utf8JsonReader reader = new(data.Span); + + string? responseId = null; + int? sequenceNumber = null; + + _ = reader.Read(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string propertyName = reader.GetString()!; + + switch (propertyName) + { + case "responseId": + _ = reader.Read(); + responseId = reader.GetString(); + break; + case "sequenceNumber": + _ = reader.Read(); + sequenceNumber = reader.GetInt32(); + break; + default: + Throw.ArgumentException(nameof(token), $"Unrecognized property '{propertyName}'."); + break; + } + } + + if (responseId is null) + { + Throw.ArgumentException(nameof(token), "Failed to create MessagesPageToken from provided pageToken because it does not contain a responseId."); + } + + return new(responseId) + { + SequenceNumber = sequenceNumber + }; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs index 78fe00a8377..fb0901eeb0d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -13,8 +12,8 @@ using OpenAI; using OpenAI.Audio; -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; @@ -22,8 +21,9 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] internal sealed class OpenAISpeechToTextClient : ISpeechToTextClient { - /// Default OpenAI endpoint. - private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); + /// Filename to use when audio lacks a name. + /// This information internally is required but is only being used to create a header name in the multipart request. + private const string Filename = "audio.mp3"; /// Metadata about the client. private readonly SpeechToTextClientMetadata _metadata; @@ -35,20 +35,9 @@ internal sealed class OpenAISpeechToTextClient : ISpeechToTextClient /// The underlying client. public OpenAISpeechToTextClient(AudioClient audioClient) { - _ = Throw.IfNull(audioClient); + _audioClient = Throw.IfNull(audioClient); - _audioClient = audioClient; - - // https://github.com/openai/openai-dotnet/issues/215 - // The endpoint and model aren't currently exposed, so use reflection to get at them, temporarily. Once packages - // implement the abstractions directly rather than providing adapters on top of the public APIs, - // the package can provide such implementations separate from what's exposed in the public API. - Uri providerUrl = typeof(AudioClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as Uri ?? _defaultOpenAIEndpoint; - string? model = typeof(AudioClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as string; - - _metadata = new("openai", providerUrl, model); + _metadata = new("openai", audioClient.Endpoint, _audioClient.Model); } /// @@ -64,20 +53,6 @@ public OpenAISpeechToTextClient(AudioClient audioClient) null; } - /// - public async IAsyncEnumerable GetStreamingTextAsync( - Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(audioSpeechStream); - - var speechResponse = await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false); - - foreach (var update in speechResponse.ToSpeechToTextResponseUpdates()) - { - yield return update; - } - } - /// public async Task GetTextAsync( Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default) @@ -86,193 +61,126 @@ public async Task GetTextAsync( SpeechToTextResponse response = new(); - // A translation is triggered when the target text language is specified and the source language is not provided or different. - static bool IsTranslationRequest(SpeechToTextOptions? options) - => options is not null && options.TextLanguage is not null - && (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. if (IsTranslationRequest(options)) { - _ = Throw.IfNull(options); + var translation = (await _audioClient.TranslateAudioAsync(audioSpeechStream, filename, ToOpenAITranslationOptions(options), cancellationToken).ConfigureAwait(false)).Value; - var openAIOptions = ToOpenAITranslationOptions(options); - AudioTranslation translationResult; + response.Contents = [new TextContent(translation.Text)]; + response.RawRepresentation = translation; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = translation.Segments.Count; + if (segmentCount > 0) { - translationResult = (await _audioClient.TranslateAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = translation.Segments[0].StartTime; + response.EndTime = translation.Segments[segmentCount - 1].EndTime; } - - UpdateResponseFromOpenAIAudioTranslation(response, translationResult); } else { - var openAIOptions = ToOpenAITranscriptionOptions(options); + var transcription = (await _audioClient.TranscribeAudioAsync(audioSpeechStream, filename, ToOpenAITranscriptionOptions(options), cancellationToken).ConfigureAwait(false)).Value; - // Transcription request - AudioTranscription transcriptionResult; + response.Contents = [new TextContent(transcription.Text)]; + response.RawRepresentation = transcription; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = transcription.Segments.Count; + if (segmentCount > 0) { - transcriptionResult = (await _audioClient.TranscribeAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = transcription.Segments[0].StartTime; + response.EndTime = transcription.Segments[segmentCount - 1].EndTime; + } + else + { + int wordCount = transcription.Words.Count; + if (wordCount > 0) + { + response.StartTime = transcription.Words[0].StartTime; + response.EndTime = transcription.Words[wordCount - 1].EndTime; + } } - - UpdateResponseFromOpenAIAudioTranscription(response, transcriptionResult); } return response; } /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. - } - - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio transcription. - private static void UpdateResponseFromOpenAIAudioTranscription(SpeechToTextResponse response, AudioTranscription audioTranscription) + public async IAsyncEnumerable GetStreamingTextAsync( + Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - _ = Throw.IfNull(audioTranscription); - - var segmentCount = audioTranscription.Segments.Count; - var wordCount = audioTranscription.Words.Count; - - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) - { - endTime = audioTranscription.Segments[segmentCount - 1].EndTime; - startTime = audioTranscription.Segments[0].StartTime; - } - else if (wordCount > 0) - { - endTime = audioTranscription.Words[wordCount - 1].EndTime; - startTime = audioTranscription.Words[0].StartTime; - } - - // Update the response - response.RawRepresentation = audioTranscription; - response.Contents = [new TextContent(audioTranscription.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranscription.Language)] = audioTranscription.Language, - [nameof(audioTranscription.Duration)] = audioTranscription.Duration - }; - } + _ = Throw.IfNull(audioSpeechStream); - /// Converts an extensions options instance to an OpenAI options instance. - private static AudioTranscriptionOptions ToOpenAITranscriptionOptions(SpeechToTextOptions? options) - { - AudioTranscriptionOptions result = new(); + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. - if (options is not null) + if (IsTranslationRequest(options)) { - if (options.SpeechLanguage is not null) + foreach (var update in (await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false)).ToSpeechToTextResponseUpdates()) { - result.Language = options.SpeechLanguage; + yield return update; } - - if (options.AdditionalProperties is { Count: > 0 } additionalProperties) + } + else + { + await foreach (var update in _audioClient.TranscribeAudioStreamingAsync( + audioSpeechStream, + filename, + ToOpenAITranscriptionOptions(options), + cancellationToken).ConfigureAwait(false)) { - if (additionalProperties.TryGetValue(nameof(result.Temperature), out float? temperature)) + SpeechToTextResponseUpdate result = new() { - result.Temperature = temperature; - } + ModelId = options?.ModelId, + RawRepresentation = update, + }; - if (additionalProperties.TryGetValue(nameof(result.TimestampGranularities), out object? timestampGranularities)) + switch (update) { - result.TimestampGranularities = timestampGranularities is AudioTimestampGranularities granularities ? granularities : default; + case StreamingAudioTranscriptionTextDeltaUpdate deltaUpdate: + result.Kind = SpeechToTextResponseUpdateKind.TextUpdated; + result.Contents = [new TextContent(deltaUpdate.Delta)]; + break; + + case StreamingAudioTranscriptionTextDoneUpdate doneUpdate: + result.Kind = SpeechToTextResponseUpdateKind.SessionClose; + break; } - if (additionalProperties.TryGetValue(nameof(result.ResponseFormat), out AudioTranscriptionFormat? responseFormat)) - { - result.ResponseFormat = responseFormat; - } - - if (additionalProperties.TryGetValue(nameof(result.Prompt), out string? prompt)) - { - result.Prompt = prompt; - } + yield return result; } } - - return result; } - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio translation. - private static void UpdateResponseFromOpenAIAudioTranslation(SpeechToTextResponse response, AudioTranslation audioTranslation) + /// + void IDisposable.Dispose() { - _ = Throw.IfNull(audioTranslation); - - var segmentCount = audioTranslation.Segments.Count; - - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) - { - endTime = audioTranslation.Segments[segmentCount - 1].EndTime; - startTime = audioTranslation.Segments[0].StartTime; - } - - // Update the response - response.RawRepresentation = audioTranslation; - response.Contents = [new TextContent(audioTranslation.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranslation.Language)] = audioTranslation.Language, - [nameof(audioTranslation.Duration)] = audioTranslation.Duration - }; + // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. } - /// Converts an extensions options instance to an OpenAI options instance. - private static AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) + // A translation is triggered when the target text language is specified and the source language is not provided or different. + private static bool IsTranslationRequest(SpeechToTextOptions? options) => + options is not null && + options.TextLanguage is not null && + (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + + /// Converts an extensions options instance to an OpenAI transcription options instance. + private AudioTranscriptionOptions ToOpenAITranscriptionOptions(SpeechToTextOptions? options) { - AudioTranslationOptions result = new(); + AudioTranscriptionOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranscriptionOptions ?? new(); - if (options is not null) - { - if (options.AdditionalProperties is { Count: > 0 } additionalProperties) - { - if (additionalProperties.TryGetValue(nameof(result.Temperature), out float? temperature)) - { - result.Temperature = temperature; - } + result.Language ??= options?.SpeechLanguage; - if (additionalProperties.TryGetValue(nameof(result.ResponseFormat), out AudioTranslationFormat? responseFormat)) - { - result.ResponseFormat = responseFormat; - } + return result; + } - if (additionalProperties.TryGetValue(nameof(result.Prompt), out string? prompt)) - { - result.Prompt = prompt; - } - } - } + /// Converts an extensions options instance to an OpenAI translation options instance. + private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) + { + AudioTranslationOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new(); return result; } } - diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md index 4bbb2660f4e..28c3d4aac9b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/README.md @@ -291,6 +291,10 @@ app.MapPost("/embedding", async (IEmbeddingGenerator> c app.Run(); ``` +## Documentation + +Learn how to create a conversational .NET console chat app using an OpenAI or Azure OpenAI model with the [Quickstart - Build an AI chat app with .NET](https://learn.microsoft.com/dotnet/ai/quickstarts/build-chat-app?pivots=openai) documentation. + ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs new file mode 100644 index 00000000000..dbe38e42a2c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable CA1307 // Specify StringComparison + +namespace Microsoft.Extensions.AI; + +/// Provides utility methods for creating . +internal static class RequestOptionsExtensions +{ + /// Creates a configured for use with OpenAI. + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) + { + RequestOptions requestOptions = new() + { + CancellationToken = cancellationToken, + BufferResponse = !streaming + }; + + requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); + + return requestOptions; + } + + /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. + private sealed class MeaiUserAgentPolicy : PipelinePolicy + { + public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy(); + + private static readonly string _userAgentValue = CreateUserAgentValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private static void AddUserAgentHeader(PipelineMessage message) => + message.Request.Headers.Add("User-Agent", _userAgentValue); + + private static string CreateUserAgentValue() + { + const string Name = "MEAI"; + + if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index 25c15aed0d2..f3c53f0d8a1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -1,10 +1,78 @@ # Release History +## 9.10.2 + +- Updated the Open Telemetry instrumentation to conform to the latest 1.38 draft specification of the Semantic Conventions for Generative AI systems. + +## 9.10.1 + +- Added an `[Experimental]` implementation of tool reduction component for constraining the set of tools exposed. +- Fixed `SummarizingChatReducer` to preserve function calling content in the chat history. + +## 9.10.0 + +- Added `OpenTelemetrySpeechToTextClient` to provide Open Telemetry instrumentation for `ISpeechToTextClient` implementations. +- Augmented `OpenTelemetryChatClient` to output tool information for all tools rather than only `AIFunctionDeclaration`-based tools. +- Fixed `OpenTelemetryChatClient` to avoid throwing exceptions when trying to serialize unknown `AIContent`-derived types. +- Fixed issue with `FunctionInvokingChatClient` where some buffered updates in the face of possible approvals weren't being propagated. +- Simplified the name of the activity span emitted by `FunctionInvokingChatClient`. + +## 9.9.1 + +- Updated the `EnableSensitiveData` properties on `OpenTelemetryChatClient/EmbeddingGenerator` to respect a `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable. +- Updated `OpenTelemetryChatClient/EmbeddingGenerator` to emit recent additions to the OpenTelemetry Semantic Conventions for Generative AI systems. +- Added `OpenTelemetryImageGenerator` to provide OpenTelemetry instrumentation for `IImageGenerator` implementations. + +## 9.9.0 + +- Added `FunctionInvokingChatClient` support for non-invocable tools and `TerminateOnUnknownCalls` property. +- Added support to `FunctionInvokingChatClient` for user approval of function invocations. +- Updated the Open Telemetry instrumentation to conform to the latest 1.37 draft specification of the Semantic Conventions for Generative AI systems. +- Fixed `GetResponseAsync` to only look at the contents of the last message in the response. + +## 9.8.0 + +- Added `FunctionInvokingChatClient.AdditionalTools` to allow `FunctionInvokingChatClient` to have access to tools not included in `ChatOptions.Tools` but known to the target service via pre-configuration. +- Added [Experimental] `IChatReducer` and supporting types +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.1 + +- Added `FunctionInvokingChatClient.FunctionInvoker` to simplify customizing how functions are invoked. +- Increased the default `FunctionInvokingChatClient.MaximumIterationsPerRequest` value from 10 to 40. +- Updated the Open Telemetry instrumentation to conform to the latest 1.36 draft specification of the Semantic Conventions for Generative AI systems. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.0 + +- Added `DistributedCachingChatClient/EmbeddingGenerator.AdditionalCacheKeyValues` to allow adding additional values to the cache key. +- Allowed a `CachingChatClient` to control per-request caching. +- Updated the Open Telemetry instrumentation to conform to the latest 1.35 draft specification of the Semantic Conventions for Generative AI systems. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.6.0 + +- Fixed hashing in `CachingChatClient` and `CachingEmbeddingGenerator` to be stable with respect to indentation settings and property ordering. +- Updated the Open Telemetry instrumentation to conform to the latest 1.34 draft specification of the Semantic Conventions for Generative AI systems. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.5.0 + +- Changed `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to consider `AdditionalProperties` to be "sensitive". +- Changed `FunctionInvokingChatClient` to respect the `SynchronizationContext` of the caller when invoking functions. +- Changed hash function algorithm used in `CachingChatClient` and `CachingEmbeddingGenerator` to SHA-384 instead of SHA-256. +- Updated `FunctionInvokingChatClient` to include token counts on its emitted diagnostic spans. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.33 draft specification of the Semantic Conventions for Generative AI systems. +- Renamed the `useJsonSchema` paramter of `GetResponseAsync`. +- Removed debug-level logging of updates in `LoggingChatClient`. +- Avoided caching in `CachingChatClient` when `ConversationId` is set. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + ## 9.4.4-preview.1.25259.16 - Fixed `CachingChatClient` to avoid caching when `ConversationId` is set. - Renamed `useJsonSchema` parameter in `GetResponseAsync` to `useJsonSchemaResponseFormat`. -- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.33.0 draft specification of the Semantic Conventions for Generative AI systems. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.32 draft specification of the Semantic Conventions for Generative AI systems. ## 9.4.3-preview.1.25230.7 @@ -53,12 +121,12 @@ ## 9.3.0-preview.1.25114.11 -- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.30.0 draft specification of the Semantic Conventions for Generative AI systems. +- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.30 draft specification of the Semantic Conventions for Generative AI systems. ## 9.1.0-preview.1.25064.3 - Added `FunctionInvokingChatClient.CurrentContext` to give functions access to detailed function invocation information. -- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.29.0 draft specification of the Semantic Conventions for Generative AI systems. +- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.29 draft specification of the Semantic Conventions for Generative AI systems. - Updated `FunctionInvokingChatClient` to emit an `Activity`/span around all interactions related to a single chat operation. ## 9.0.1-preview.1.24570.5 @@ -90,7 +158,7 @@ - Improved the readability of JSON generated as part of logging. - Fixed handling of generated JSON schema names when using arrays or generic types. - Improved `CachingChatClient`'s coalescing of streaming updates, including reduced memory allocation and enhanced metadata propagation. -- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.28.0 draft specification of the Semantic Conventions for Generative AI systems. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.28 draft specification of the Semantic Conventions for Generative AI systems. - Improved `CompleteAsync`'s structured output support to handle primitive types, enums, and arrays. ## 9.0.0-preview.9.24507.7 diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs index db256e94916..9a3fb9d4ad6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs @@ -12,8 +12,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks - namespace Microsoft.Extensions.AI; /// Represents a delegating chat client that wraps an inner client with implementations provided by delegates. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs index 211fc39ec85..cb5482ac213 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs @@ -8,9 +8,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable S127 // "for" loop stop conditions should be invariant -#pragma warning disable SA1202 // Elements should be ordered by access - namespace Microsoft.Extensions.AI; /// @@ -51,7 +48,7 @@ public override Task GetResponseAsync( { _ = Throw.IfNull(messages); - return UseCaching(options) ? + return EnableCaching(messages, options) ? GetCachedResponseAsync(messages, options, cancellationToken) : base.GetResponseAsync(messages, options, cancellationToken); } @@ -79,7 +76,7 @@ public override IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - return UseCaching(options) ? + return EnableCaching(messages, options) ? GetCachedStreamingResponseAsync(messages, options, cancellationToken) : base.GetStreamingResponseAsync(messages, options, cancellationToken); } @@ -196,12 +193,25 @@ private async IAsyncEnumerable GetCachedStreamingResponseAsy /// is . protected abstract Task WriteCacheStreamingAsync(string key, IReadOnlyList value, CancellationToken cancellationToken); - /// Determine whether to use caching with the request. - private static bool UseCaching(ChatOptions? options) + /// Determines whether caching should be used with the specified request. + /// The sequence of chat messages included in the request. + /// The chat options included in the request. + /// + /// if caching should be used for the request, such that the + /// will try to satisfy the request from the cache, or if it can't, will try to cache the fetched response. + /// if caching should not be used for the request, such that the request will + /// be passed through to the inner without attempting to read from or write to the cache. + /// + /// + /// The default implementation returns as long as the + /// does not have a set. + /// + protected virtual bool EnableCaching(IEnumerable messages, ChatOptions? options) { // We want to skip caching if options.ConversationId is set. If it's set, that implies there's // some state that will impact the response and that's not represented in the messages. Since - // that state could change even with the same ID, we have to assume caching isn't valid. + // that state could change even with the same ID (e.g. if it's a thread ID representing the + // mutable state of a conversation), we have to assume caching isn't valid. return options?.ConversationId is null; } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 69c4cc7ee89..09ec568d749 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -3,11 +3,9 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Reflection; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -23,17 +21,6 @@ namespace Microsoft.Extensions.AI; /// Request a response with structured output. public static partial class ChatClientStructuredOutputExtensions { - private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() - { - IncludeSchemaKeyword = true, - TransformOptions = new AIJsonSchemaTransformOptions - { - DisallowAdditionalProperties = true, - RequireAllProperties = true, - MoveDefaultKeywordToDescription = true, - }, - }; - /// Sends chat messages, requesting a response matching the type . /// The . /// The chat content to send. @@ -161,20 +148,12 @@ public static async Task> GetResponseAsync( serializerOptions.MakeReadOnly(); - var schemaElement = AIJsonUtilities.CreateJsonSchema( - type: typeof(T), - serializerOptions: serializerOptions, - inferenceOptions: _inferenceOptions); + var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); - bool isWrappedInObject; - JsonElement schema; - if (SchemaRepresentsObject(schemaElement)) - { - // For object-representing schemas, we can use them as-is - isWrappedInObject = false; - schema = schemaElement; - } - else + Debug.Assert(responseFormat.Schema is not null, "ForJsonSchema should always populate Schema"); + var schema = responseFormat.Schema!.Value; + bool isWrappedInObject = false; + if (!SchemaRepresentsObject(schema)) { // For non-object-representing schemas, we wrap them in an object schema, because all // the real LLM providers today require an object schema as the root. This is currently @@ -184,10 +163,11 @@ public static async Task> GetResponseAsync( { { "$schema", "https://json-schema.org/draft/2020-12/schema" }, { "type", "object" }, - { "properties", new JsonObject { { "data", JsonElementToJsonNode(schemaElement) } } }, + { "properties", new JsonObject { { "data", JsonElementToJsonNode(schema) } } }, { "additionalProperties", false }, { "required", new JsonArray("data") }, }, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject))); + responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription); } ChatMessage? promptAugmentation = null; @@ -200,10 +180,7 @@ public static async Task> GetResponseAsync( { // When using native structured output, we don't add any additional prompt, because // the LLM backend is meant to do whatever's needed to explain the schema to the LLM. - options.ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema, - schemaName: SanitizeMemberName(typeof(T).Name), - schemaDescription: typeof(T).GetCustomAttribute()?.Description); + options.ResponseFormat = responseFormat; } else { @@ -213,7 +190,7 @@ public static async Task> GetResponseAsync( promptAugmentation = new ChatMessage(ChatRole.User, $$""" Respond with a JSON value conforming to the following schema: ``` - {{schema}} + {{responseFormat.Schema}} ``` """); @@ -222,53 +199,31 @@ public static async Task> GetResponseAsync( var result = await chatClient.GetResponseAsync(messages, options, cancellationToken); return new ChatResponse(result, serializerOptions) { IsWrappedInObject = isWrappedInObject }; - } - private static bool SchemaRepresentsObject(JsonElement schemaElement) - { - if (schemaElement.ValueKind is JsonValueKind.Object) + static bool SchemaRepresentsObject(JsonElement schemaElement) { - foreach (var property in schemaElement.EnumerateObject()) + if (schemaElement.ValueKind is JsonValueKind.Object) { - if (property.NameEquals("type"u8)) + foreach (var property in schemaElement.EnumerateObject()) { - return property.Value.ValueKind == JsonValueKind.String - && property.Value.ValueEquals("object"u8); + if (property.NameEquals("type"u8)) + { + return property.Value.ValueKind == JsonValueKind.String + && property.Value.ValueEquals("object"u8); + } } } - } - return false; - } + return false; + } - private static JsonNode? JsonElementToJsonNode(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.Null => null, - JsonValueKind.Array => JsonArray.Create(element), - JsonValueKind.Object => JsonObject.Create(element), - _ => JsonValue.Create(element) - }; + static JsonNode? JsonElementToJsonNode(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Array => JsonArray.Create(element), + JsonValueKind.Object => JsonObject.Create(element), + _ => JsonValue.Create(element) + }; } - - /// - /// Removes characters from a .NET member name that shouldn't be used in an AI function name. - /// - /// The .NET member name that should be sanitized. - /// - /// Replaces non-alphanumeric characters in the identifier with the underscore character. - /// Primarily intended to remove characters produced by compiler-generated method name mangling. - /// - private static string SanitizeMemberName(string memberName) => - InvalidNameCharsRegex().Replace(memberName, "_"); - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs index 3756b255cc8..5a881397917 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs @@ -22,9 +22,7 @@ public class ChatResponse : ChatResponse { private static readonly JsonReaderOptions _allowMultipleValuesJsonReaderOptions = new() { -#if NET9_0_OR_GREATER AllowMultipleValues = true -#endif }; private readonly JsonSerializerOptions _serializerOptions; @@ -82,13 +80,11 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) result = GetResultCore(out var failureReason); return failureReason is null; } -#pragma warning disable CA1031 // Do not catch general exception types catch { result = default; return false; } -#pragma warning restore CA1031 // Do not catch general exception types } private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) @@ -127,7 +123,7 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) return _deserializedResult; } - var json = Text; + var json = Messages.Count > 0 ? Messages[Messages.Count - 1].Text : string.Empty; if (string.IsNullOrEmpty(json)) { failureReason = FailureReason.ResultDidNotContainJson; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs index dc8fe9db56f..44ddcf84081 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs @@ -2,15 +2,15 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Shared.Diagnostics; -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1502 // Element should not be on a single line namespace Microsoft.Extensions.AI; @@ -34,11 +34,15 @@ namespace Microsoft.Extensions.AI; /// public class DistributedCachingChatClient : CachingChatClient { + /// Boxed cache version. + /// Bump the cache version to invalidate existing caches if the serialization format changes in a breaking way. + private static readonly object _cacheVersion = 2; + /// The instance that will be used as the backing store for the cache. private readonly IDistributedCache _storage; - /// The to use when serializing cache data. - private JsonSerializerOptions _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + /// Additional values used to inform the cache key employed for storing state. + private object[]? _cacheKeyAdditionalValues; /// Initializes a new instance of the class. /// The underlying . @@ -52,19 +56,27 @@ public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache s /// Gets or sets JSON serialization options to use when serializing cache data. public JsonSerializerOptions JsonSerializerOptions { - get => _jsonSerializerOptions; - set => _jsonSerializerOptions = Throw.IfNull(value); + get; + set => field = Throw.IfNull(value); + } = AIJsonUtilities.DefaultOptions; + + /// Gets or sets additional values used to inform the cache key employed for storing state. + /// Any values set in this list will augment the other values used to inform the cache key. + public IReadOnlyList? CacheKeyAdditionalValues + { + get => _cacheKeyAdditionalValues; + set => _cacheKeyAdditionalValues = value?.ToArray(); } /// protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) { _ = Throw.IfNull(key); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); if (await _storage.GetAsync(key, cancellationToken) is byte[] existingJson) { - return (ChatResponse?)JsonSerializer.Deserialize(existingJson, _jsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); + return (ChatResponse?)JsonSerializer.Deserialize(existingJson, JsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); } return null; @@ -74,11 +86,11 @@ public JsonSerializerOptions JsonSerializerOptions protected override async Task?> ReadCacheStreamingAsync(string key, CancellationToken cancellationToken) { _ = Throw.IfNull(key); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); if (await _storage.GetAsync(key, cancellationToken) is byte[] existingJson) { - return (IReadOnlyList?)JsonSerializer.Deserialize(existingJson, _jsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); + return (IReadOnlyList?)JsonSerializer.Deserialize(existingJson, JsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); } return null; @@ -89,9 +101,9 @@ protected override async Task WriteCacheAsync(string key, ChatResponse value, Ca { _ = Throw.IfNull(key); _ = Throw.IfNull(value); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); - var newJson = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, JsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); await _storage.SetAsync(key, newJson, cancellationToken); } @@ -100,9 +112,9 @@ protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList { _ = Throw.IfNull(key); _ = Throw.IfNull(value); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); - var newJson = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, JsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); await _storage.SetAsync(key, newJson, cancellationToken); } @@ -122,9 +134,26 @@ protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList /// protected override string GetCacheKey(IEnumerable messages, ChatOptions? options, params ReadOnlySpan additionalValues) { - // Bump the cache version to invalidate existing caches if the serialization format changes in a breaking way. - const int CacheVersion = 1; + const int FixedValuesCount = 3; - return AIJsonUtilities.HashDataToString([CacheVersion, messages, options, .. additionalValues], _jsonSerializerOptions); + object[] clientValues = _cacheKeyAdditionalValues ?? Array.Empty(); + int length = FixedValuesCount + additionalValues.Length + clientValues.Length; + + object?[] arr = ArrayPool.Shared.Rent(length); + try + { + arr[0] = _cacheVersion; + arr[1] = messages; + arr[2] = options; + additionalValues.CopyTo(arr.AsSpan(FixedValuesCount)); + clientValues.CopyTo(arr, FixedValuesCount + additionalValues.Length); + + return AIJsonUtilities.HashDataToString(arr.AsSpan(0, length), JsonSerializerOptions); + } + finally + { + Array.Clear(arr, 0, length); + ArrayPool.Shared.Return(arr); + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs index 0e426615cfd..554918b0a8e 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs @@ -17,18 +17,6 @@ public class FunctionInvocationContext /// private static readonly AIFunction _nopFunction = AIFunctionFactory.Create(() => { }, nameof(FunctionInvocationContext)); - /// The chat contents associated with the operation that initiated this function call request. - private IList _messages = Array.Empty(); - - /// The AI function to be invoked. - private AIFunction _function = _nopFunction; - - /// The function call content information associated with this invocation. - private FunctionCallContent? _callContent; - - /// The arguments used with the function. - private AIFunctionArguments? _arguments; - /// Initializes a new instance of the class. public FunctionInvocationContext() { @@ -37,30 +25,30 @@ public FunctionInvocationContext() /// Gets or sets the AI function to be invoked. public AIFunction Function { - get => _function; - set => _function = Throw.IfNull(value); - } + get; + set => field = Throw.IfNull(value); + } = _nopFunction; /// Gets or sets the arguments associated with this invocation. public AIFunctionArguments Arguments { - get => _arguments ??= []; - set => _arguments = Throw.IfNull(value); + get => field ??= []; + set => field = Throw.IfNull(value); } /// Gets or sets the function call content information associated with this invocation. public FunctionCallContent CallContent { - get => _callContent ??= new(string.Empty, _nopFunction.Name, EmptyReadOnlyDictionary.Instance); - set => _callContent = Throw.IfNull(value); + get => field ??= new(string.Empty, _nopFunction.Name, EmptyReadOnlyDictionary.Instance); + set => field = Throw.IfNull(value); } /// Gets or sets the chat contents associated with the operation that initiated this function call request. public IList Messages { - get => _messages; - set => _messages = Throw.IfNull(value); - } + get; + set => field = Throw.IfNull(value); + } = Array.Empty(); /// Gets or sets the chat options associated with the operation that initiated this function call request. public ChatOptions? Options { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index d624ec63abb..fb821c984df 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -15,9 +15,7 @@ using Microsoft.Shared.Diagnostics; #pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test -#pragma warning disable SA1202 // 'protected' members should come before 'private' members -#pragma warning disable S107 // Methods should not have too many parameters +#pragma warning disable S3353 // Unchanged local variables should be "const" namespace Microsoft.Extensions.AI; @@ -27,14 +25,33 @@ namespace Microsoft.Extensions.AI; /// /// /// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a that it sends back to the inner client. This loop -/// is repeated until there are no more function calls to make, or until another stop condition is met, -/// such as hitting . +/// When this client receives a in a chat response from its inner +/// , it responds by invoking the corresponding defined +/// in (or in ), producing a +/// that it sends back to the inner client. This loop is repeated until there are no more function calls to make, or until +/// another stop condition is met, such as hitting . /// /// -/// The provided implementation of is thread-safe for concurrent use so long as the +/// If a requested function is an but not an , the +/// will not attempt to invoke it, and instead allow that +/// to pass back out to the caller. It is then that caller's responsibility to create the appropriate +/// for that call and send it back as part of a subsequent request. +/// +/// +/// Further, if a requested function is an , the will not +/// attempt to invoke it directly. Instead, it will replace that with a +/// that wraps the and indicates that the function requires approval before it can be invoked. The caller is then +/// responsible for responding to that approval request by sending a corresponding in a subsequent +/// request. The will then process that approval response and invoke the function as appropriate. +/// +/// +/// Due to the nature of interactions with an underlying , if any is received +/// for a function that requires approval, all received in that same response will also require approval, +/// even if they were not instances. If this is a concern, consider requesting that multiple tool call +/// requests not be made in a single response, by setting to . +/// +/// +/// A instance is thread-safe for concurrent use so long as the /// instances employed as part of the supplied are also safe. /// The property can be used to control whether multiple function invocation /// requests as part of the same request are invocable concurrently, but even with that set to @@ -60,12 +77,6 @@ public partial class FunctionInvokingChatClient : DelegatingChatClient /// This component does not own the instance and should not dispose it. private readonly ActivitySource? _activitySource; - /// Maximum number of roundtrips allowed to the inner client. - private int _maximumIterationsPerRequest = 10; - - /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. - private int _maximumConsecutiveErrorsPerRequest = 3; - /// /// Initializes a new instance of the class. /// @@ -142,7 +153,7 @@ public static FunctionInvocationContext? CurrentContext /// /// /// The maximum number of iterations per request. - /// The default value is 10. + /// The default value is 40. /// /// /// @@ -159,7 +170,7 @@ public static FunctionInvocationContext? CurrentContext /// public int MaximumIterationsPerRequest { - get => _maximumIterationsPerRequest; + get; set { if (value < 1) @@ -167,9 +178,9 @@ public int MaximumIterationsPerRequest Throw.ArgumentOutOfRangeException(nameof(value)); } - _maximumIterationsPerRequest = value; + field = value; } - } + } = 40; /// /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. @@ -183,7 +194,7 @@ public int MaximumIterationsPerRequest /// When function invocations fail with an exception, the /// continues to make requests to the inner client, optionally supplying exception information (as /// controlled by ). This allows the to - /// recover from errors by trying other function parameters that may succeed. + /// recover from errors by trying other function parameters that might succeed. /// /// /// However, in case function invocations continue to produce exceptions, this property can be used to @@ -201,9 +212,54 @@ public int MaximumIterationsPerRequest /// public int MaximumConsecutiveErrorsPerRequest { - get => _maximumConsecutiveErrorsPerRequest; - set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); - } + get; + set => field = Throw.IfLessThan(value, 0); + } = 3; + + /// Gets or sets a collection of additional tools the client is able to invoke. + /// + /// These will not impact the requests sent by the , which will pass through the + /// unmodified. However, if the inner client requests the invocation of a tool + /// that was not in , this collection will also be consulted + /// to look for a corresponding tool to invoke. This is useful when the service might have been preconfigured to be aware + /// of certain tools that aren't also sent on each individual request. + /// + public IList? AdditionalTools { get; set; } + + /// Gets or sets a value indicating whether a request to call an unknown function should terminate the function calling loop. + /// + /// to terminate the function calling loop and return the response if a request to call a tool + /// that isn't available to the is received; to create and send a + /// function result message to the inner client stating that the tool couldn't be found. The default is . + /// + /// + /// + /// When , call requests to any tools that aren't available to the + /// will result in a response message automatically being created and returned to the inner client stating that the tool couldn't be + /// found. This behavior can help in cases where a model hallucinates a function, but it's problematic if the model has been made aware + /// of the existence of tools outside of the normal mechanisms, and requests one of those. can be used + /// to help with that. But if instead the consumer wants to know about all function call requests that the client can't handle, + /// can be set to . Upon receiving a request to call a function + /// that the doesn't know about, it will terminate the function calling loop and return + /// the response, leaving the handling of the function call requests to the consumer of the client. + /// + /// + /// s that the is aware of (for example, because they're in + /// or ) but that aren't s aren't considered + /// unknown, just not invocable. Any requests to a non-invocable tool will also result in the function calling loop terminating, + /// regardless of . + /// + /// + public bool TerminateOnUnknownCalls { get; set; } + + /// Gets or sets a delegate used to invoke instances. + /// + /// By default, the protected method is called for each to be invoked, + /// invoking the instance and returning its result. If this delegate is set to a non- value, + /// will replace its normal invocation with a call to this delegate, enabling + /// this delegate to assume all invocation handling of the function. + /// + public Func>? FunctionInvoker { get; set; } /// public override async Task GetResponseAsync( @@ -213,7 +269,7 @@ public override async Task GetResponseAsync( // A single request into this GetResponseAsync may result in multiple requests to the inner client. // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetResponseAsync)}"); + using Activity? activity = _activitySource?.StartActivity(OpenTelemetryConsts.GenAI.OrchestrateToolsName); // Copy the original messages in order to avoid enumerating the original messages multiple times. // The IEnumerable can represent an arbitrary amount of work. @@ -228,6 +284,35 @@ public override async Task GetResponseAsync( bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set int consecutiveErrorCount = 0; + (Dictionary? toolMap, bool anyToolsRequireApproval) = CreateToolsMap(AdditionalTools, options?.Tools); // all available tools, indexed by name + + if (HasAnyApprovalContent(originalMessages)) + { + // A previous turn may have translated FunctionCallContents from the inner client into approval requests sent back to the caller, + // for any AIFunctions that were actually ApprovalRequiredAIFunctions. If the incoming chat messages include responses to those + // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message + // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if + // the inner client had returned them directly. + (responseMessages, var notInvokedApprovals) = ProcessFunctionApprovalResponses( + originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId: null, functionCallContentFallbackMessageId: null); + (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken); + + if (invokedApprovedFunctionApprovalResponses is not null) + { + // Add any generated FRCs to the list we'll return to callers as part of the next response. + (responseMessages ??= []).AddRange(invokedApprovedFunctionApprovalResponses); + } + + if (shouldTerminate) + { + return new ChatResponse(responseMessages); + } + } + + // At this point, we've fully handled all approval responses that were part of the original messages, + // and we can now enter the main function calling loop. + for (int iteration = 0; ; iteration++) { functionCallContents?.Clear(); @@ -239,16 +324,31 @@ public override async Task GetResponseAsync( Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); } + // Before we do any function execution, make sure that any functions that require approval have been turned into + // approval requests so that they don't get executed here. + if (anyToolsRequireApproval) + { + Debug.Assert(toolMap is not null, "anyToolsRequireApproval can only be true if there are tools"); + response.Messages = ReplaceFunctionCallsWithApprovalRequests(response.Messages, toolMap!); + } + // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. bool requiresFunctionInvocation = - options?.Tools is { Count: > 0 } && iteration < MaximumIterationsPerRequest && CopyFunctionCalls(response.Messages, ref functionCallContents); - // In a common case where we make a request and there's no function calling work required, - // fast path out by just returning the original response. - if (iteration == 0 && !requiresFunctionInvocation) + if (!requiresFunctionInvocation && iteration == 0) { + // In a common case where we make an initial request and there's no function calling work required, + // fast path out by just returning the original response. We may already have some messages + // in responseMessages from processing function approval responses, and we need to ensure + // those are included in the final response, too. + if (responseMessages is { Count: > 0 }) + { + responseMessages.AddRange(response.Messages); + response.Messages = responseMessages; + } + return response; } @@ -266,10 +366,10 @@ public override async Task GetResponseAsync( } } - // If there are no tools to call, or for any other reason we should stop, we're done. - // Break out of the loop and allow the handling at the end to configure the response - // with aggregated data from previous requests. - if (!requiresFunctionInvocation) + // If there's nothing more to do, break out of the loop and allow the handling at the + // end to configure the response with aggregated data from previous requests. + if (!requiresFunctionInvocation || + ShouldTerminateLoopBasedOnHandleableFunctions(functionCallContents, toolMap)) { break; } @@ -279,7 +379,7 @@ public override async Task GetResponseAsync( // Add the responses from the function calls into the augmented history and also into the tracked // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -288,7 +388,7 @@ public override async Task GetResponseAsync( break; } - UpdateOptionsForNextIteration(ref options!, response.ConversationId); + UpdateOptionsForNextIteration(ref options, response.ConversationId); } Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); @@ -308,7 +408,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetStreamingResponseAsync)}"); + using Activity? activity = _activitySource?.StartActivity(OpenTelemetryConsts.GenAI.OrchestrateToolsName); UsageDetails? totalUsage = activity is { IsAllDataRequested: true } ? new() : null; // tracked usage across all turns, to be used for activity purposes // Copy the original messages in order to avoid enumerating the original messages multiple times. @@ -316,6 +416,7 @@ public override async IAsyncEnumerable GetStreamingResponseA List originalMessages = [.. messages]; messages = originalMessages; + AITool[]? approvalRequiredFunctions = null; // available tools that require approval List? augmentedHistory = null; // the actual history of messages sent on turns other than the first List? functionCallContents = null; // function call contents that need responding to in the current turn List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history @@ -323,11 +424,69 @@ public override async IAsyncEnumerable GetStreamingResponseA List updates = []; // updates from the current response int consecutiveErrorCount = 0; + (Dictionary? toolMap, bool anyToolsRequireApproval) = CreateToolsMap(AdditionalTools, options?.Tools); // all available tools, indexed by name + + // This is a synthetic ID since we're generating the tool messages instead of getting them from + // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to + // use the same message ID for all of them within a given iteration, as this is a single logical + // message with multiple content items. We could also use different message IDs per tool content, + // but there's no benefit to doing so. + string toolMessageId = Guid.NewGuid().ToString("N"); + + if (HasAnyApprovalContent(originalMessages)) + { + // We also need a synthetic ID for the function call content for approved function calls + // where we don't know what the original message id of the function call was. + string functionCallContentFallbackMessageId = Guid.NewGuid().ToString("N"); + + // A previous turn may have translated FunctionCallContents from the inner client into approval requests sent back to the caller, + // for any AIFunctions that were actually ApprovalRequiredAIFunctions. If the incoming chat messages include responses to those + // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message + // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if + // the inner client had returned them directly. + var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses( + originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId, functionCallContentFallbackMessageId); + if (preDownstreamCallHistory is not null) + { + foreach (var message in preDownstreamCallHistory) + { + yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + } + + // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. + (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, isStreaming: true, cancellationToken); + + if (invokedApprovedFunctionApprovalResponses is not null) + { + foreach (var message in invokedApprovedFunctionApprovalResponses) + { + message.MessageId = toolMessageId; + yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + if (shouldTerminate) + { + yield break; + } + } + } + + // At this point, we've fully handled all approval responses that were part of the original messages, + // and we can now enter the main function calling loop. + for (int iteration = 0; ; iteration++) { updates.Clear(); functionCallContents?.Clear(); + bool hasApprovalRequiringFcc = false; + int lastApprovalCheckedFCCIndex = 0; + int lastYieldedUpdateIndex = 0; + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) { if (update is null) @@ -352,18 +511,88 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - yield return update; + // We're streaming updates back to the caller. However, approvals requires extra handling. We should not yield any + // FunctionCallContents back to the caller if approvals might be required, because if any actually are, we need to convert + // all FunctionCallContents into approval requests, even those that don't require approval (we otherwise don't have a way + // to track the FCCs to a later turn, in particular when the conversation history is managed by the service / inner client). + // So, if there are no functions that need approval, we can yield updates with FCCs as they arrive. But if any FCC _might_ + // require approval (which just means that any AIFunction we can possibly invoke requires approval), then we need to hold off + // on yielding any FCCs until we know whether any of them actually require approval, which is either at the end of the stream + // or the first time we get an FCC that requires approval. At that point, we can yield all of the updates buffered thus far + // and anything further, replacing FCCs with approval if any required it, or yielding them as is. + if (anyToolsRequireApproval && approvalRequiredFunctions is null && functionCallContents is { Count: > 0 }) + { + approvalRequiredFunctions = + (options?.Tools ?? Enumerable.Empty()) + .Concat(AdditionalTools ?? Enumerable.Empty()) + .Where(t => t.GetService() is not null) + .ToArray(); + } + + if (approvalRequiredFunctions is not { Length: > 0 } || functionCallContents is not { Count: > 0 }) + { + // If there are no function calls to make yet, or if none of the functions require approval at all, + // we can yield the update as-is. + lastYieldedUpdateIndex++; + yield return update; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + + continue; + } + + // There are function calls to make, some of which _may_ require approval. + Debug.Assert(functionCallContents is { Count: > 0 }, "Expected to have function call contents to check for approval requiring functions."); + Debug.Assert(approvalRequiredFunctions is { Length: > 0 }, "Expected to have approval requiring functions to check against function call contents."); + + // Check if any of the function call contents in this update requires approval. + (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex) = CheckForApprovalRequiringFCC( + functionCallContents, approvalRequiredFunctions!, hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); + if (hasApprovalRequiringFcc) + { + // If we've encountered a function call content that requires approval, + // we need to ask for approval for all functions, since we cannot mix and match. + // Convert all function call contents into approval requests from the last yielded update index + // and yield all those updates. + for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) + { + var updateToYield = updates[lastYieldedUpdateIndex]; + if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, out var updatedContents)) + { + updateToYield.Contents = updatedContents; + } + + yield return updateToYield; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + continue; + } + + // We don't have any approval requiring function calls yet, but we may receive some in future + // so we cannot yield the updates yet. We'll just keep them in the updates list for later. + // We will yield the updates as soon as we receive a function call content that requires approval + // or when we reach the end of the updates stream. + } + + // We need to yield any remaining updates that were not yielded while looping through the streamed updates. + for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) + { + var updateToYield = updates[lastYieldedUpdateIndex]; + yield return updateToYield; Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } - // If there are no tools to call, or for any other reason we should stop, return the response. - if (functionCallContents is not { Count: > 0 } || - options?.Tools is not { Count: > 0 } || - iteration >= _maximumIterationsPerRequest) + // If there's nothing more to do, break out of the loop and allow the handling at the + // end to configure the response with aggregated data from previous requests. + if (iteration >= MaximumIterationsPerRequest || + hasApprovalRequiringFcc || + ShouldTerminateLoopBasedOnHandleableFunctions(functionCallContents, toolMap)) { break; } + // We need to invoke functions. + // Reconstitute a response from the response updates. var response = updates.ToChatResponse(); (responseMessages ??= []).AddRange(response.Messages); @@ -372,35 +601,15 @@ public override async IAsyncEnumerable GetStreamingResponseA FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId); // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - // This is a synthetic ID since we're generating the tool messages instead of getting them from - // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to - // use the same message ID for all of them within a given iteration, as this is a single logical - // message with multiple content items. We could also use different message IDs per tool content, - // but there's no benefit to doing so. - string toolResponseId = Guid.NewGuid().ToString("N"); - // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages // includes all activities, including generated function results. foreach (var message in modeAndMessages.MessagesAdded) { - var toolResultUpdate = new ChatResponseUpdate - { - AdditionalProperties = message.AdditionalProperties, - AuthorName = message.AuthorName, - ConversationId = response.ConversationId, - CreatedAt = DateTimeOffset.UtcNow, - Contents = message.Contents, - RawRepresentation = message.RawRepresentation, - ResponseId = toolResponseId, - MessageId = toolResponseId, // See above for why this can be the same as ResponseId - Role = message.Role, - }; - - yield return toolResultUpdate; + yield return ConvertToolResultMessageToUpdate(message, response.ConversationId, toolMessageId); Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } @@ -415,6 +624,20 @@ public override async IAsyncEnumerable GetStreamingResponseA AddUsageTags(activity, totalUsage); } + private static ChatResponseUpdate ConvertToolResultMessageToUpdate(ChatMessage message, string? conversationId, string? messageId) => + new() + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + ConversationId = conversationId, + CreatedAt = DateTimeOffset.UtcNow, + Contents = message.Contents, + RawRepresentation = message.RawRepresentation, + ResponseId = messageId, + MessageId = messageId, + Role = message.Role, + }; + /// Adds tags to for usage details in . private static void AddUsageTags(Activity? activity, UsageDetails? usage) { @@ -422,12 +645,12 @@ private static void AddUsageTags(Activity? activity, UsageDetails? usage) { if (usage.InputTokenCount is long inputTokens) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.InputTokens, (int)inputTokens); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); } if (usage.OutputTokenCount is long outputTokens) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, (int)outputTokens); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); } } } @@ -494,6 +717,39 @@ private static void FixupHistories( messages = augmentedHistory; } + /// Creates a mapping from tool names to the corresponding tools. + /// + /// The lists of tools to combine into a single dictionary. Tools from later lists are preferred + /// over tools from earlier lists if they have the same name. + /// + private static (Dictionary? ToolMap, bool AnyRequireApproval) CreateToolsMap(params ReadOnlySpan?> toolLists) + { + Dictionary? map = null; + bool anyRequireApproval = false; + + foreach (var toolList in toolLists) + { + if (toolList?.Count is int count && count > 0) + { + map ??= new(StringComparer.Ordinal); + for (int i = 0; i < count; i++) + { + AITool tool = toolList[i]; + anyRequireApproval |= tool.GetService() is not null; + map[tool.Name] = tool; + } + } + } + + return (map, anyRequireApproval); + } + + /// + /// Gets whether contains any or instances. + /// + private static bool HasAnyApprovalContent(List messages) => + messages.Any(static m => m.Contents.Any(static c => c is FunctionApprovalRequestContent or FunctionApprovalResponseContent)); + /// Copies any from to . private static bool CopyFunctionCalls( IList messages, [NotNullWhen(true)] ref List? functionCalls) @@ -526,9 +782,16 @@ private static bool CopyFunctionCalls( return any; } - private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? conversationId) + private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) { - if (options.ToolMode is RequiredChatToolMode) + if (options is null) + { + if (conversationId is not null) + { + options = new() { ConversationId = conversationId }; + } + } + else if (options.ToolMode is RequiredChatToolMode) { // We have to reset the tool mode to be non-required after the first iteration, // as otherwise we'll be in an infinite loop. @@ -543,6 +806,66 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin options = options.Clone(); options.ConversationId = conversationId; } + else if (options.ContinuationToken is not null) + { + // Clone options before resetting the continuation token below. + options = options.Clone(); + } + + // Reset the continuation token of a background response operation + // to signal the inner client to handle function call result rather + // than getting the result of the operation. + if (options?.ContinuationToken is not null) + { + options.ContinuationToken = null; + } + } + + /// Gets whether the function calling loop should exit based on the function call requests. + /// The call requests. + /// The map from tool names to tools. + private bool ShouldTerminateLoopBasedOnHandleableFunctions(List? functionCalls, Dictionary? toolMap) + { + if (functionCalls is not { Count: > 0 }) + { + // There are no functions to call, so there's no reason to keep going. + return true; + } + + if (toolMap is not { Count: > 0 }) + { + // There are functions to call but we have no tools, so we can't handle them. + // If we're configured to terminate on unknown call requests, do so now. + // Otherwise, ProcessFunctionCallsAsync will handle it by creating a NotFound response message. + return TerminateOnUnknownCalls; + } + + // At this point, we have both function call requests and some tools. + // Look up each function. + foreach (var fcc in functionCalls) + { + if (toolMap.TryGetValue(fcc.Name, out var tool)) + { + if (tool is not AIFunction) + { + // The tool was found but it's not invocable. Regardless of TerminateOnUnknownCallRequests, + // we need to break out of the loop so that callers can handle all the call requests. + return true; + } + } + else + { + // The tool couldn't be found. If we're configured to terminate on unknown call requests, + // break out of the loop now. Otherwise, ProcessFunctionCallsAsync will handle it by + // creating a NotFound response message. + if (TerminateOnUnknownCalls) + { + return true; + } + } + } + + return false; } /// @@ -550,6 +873,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin /// /// The current chat contents, inclusive of the function call contents being processed. /// The options used for the response being processed. + /// Map from tool name to tool. /// The function call contents representing the functions to be invoked. /// The iteration number of how many roundtrips have been made to the inner client. /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. @@ -557,7 +881,8 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, + List messages, ChatOptions? options, + Dictionary? toolMap, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. @@ -565,13 +890,13 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); var shouldTerminate = false; - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; + var captureCurrentIterationExceptions = consecutiveErrorCount < MaximumConsecutiveErrorsPerRequest; // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. if (functionCallContents.Count == 1) { FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken); IList addedMessages = CreateResponseMessages([result]); @@ -594,7 +919,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin results.AddRange(await Task.WhenAll( from callIndex in Enumerable.Range(0, functionCallContents.Count) select ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, callIndex, captureExceptions: true, isStreaming, cancellationToken))); shouldTerminate = results.Any(r => r.Terminate); @@ -605,7 +930,7 @@ select ProcessFunctionCallAsync( for (int callIndex = 0; callIndex < functionCallContents.Count; callIndex++) { var functionResult = await ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, callIndex, captureCurrentIterationExceptions, isStreaming, cancellationToken); results.Add(functionResult); @@ -628,7 +953,6 @@ select ProcessFunctionCallAsync( } } -#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection /// /// Updates the consecutive error count, and throws an exception if the count exceeds the maximum. /// @@ -637,24 +961,23 @@ select ProcessFunctionCallAsync( /// Thrown if the maximum consecutive error count is exceeded. private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) { - var allExceptions = added.SelectMany(m => m.Contents.OfType()) - .Select(frc => frc.Exception!) - .Where(e => e is not null); - - if (allExceptions.Any()) + if (added.Any(static m => m.Contents.Any(static c => c is FunctionResultContent { Exception: not null }))) { consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) + if (consecutiveErrorCount > MaximumConsecutiveErrorsPerRequest) { - var allExceptionsArray = allExceptions.ToArray(); + var allExceptionsArray = added + .SelectMany(m => m.Contents.OfType()) + .Select(frc => frc.Exception!) + .Where(e => e is not null) + .ToArray(); + if (allExceptionsArray.Length == 1) { ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); } - else - { - throw new AggregateException(allExceptionsArray); - } + + throw new AggregateException(allExceptionsArray); } } else @@ -662,14 +985,13 @@ private void UpdateConsecutiveErrorCountOrThrow(IList added, ref in consecutiveErrorCount = 0; } } -#pragma warning restore CA1851 /// /// Throws an exception if doesn't create any messages. /// private void ThrowIfNoFunctionResultsAdded(IList? messages) { - if (messages is null || messages.Count == 0) + if (messages is not { Count: > 0 }) { Throw.InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); } @@ -678,6 +1000,7 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// Processes the function call described in []. /// The current chat contents, inclusive of the function call contents being processed. /// The options used for the response being processed. + /// Map from tool name to tool. /// The function call contents representing all the functions being invoked. /// The iteration number of how many roundtrips have been made to the inner client. /// The 0-based index of the function being called out of . @@ -686,14 +1009,16 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, + List messages, ChatOptions? options, + Dictionary? toolMap, List callContents, int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? aiFunction = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); - if (aiFunction is null) + if (toolMap is null || + !toolMap.TryGetValue(callContent.Name, out AITool? tool) || + tool is not AIFunction aiFunction) { return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } @@ -795,30 +1120,43 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul _ = Throw.IfNull(context); using Activity? activity = _activitySource?.StartActivity( - $"execute_tool {context.Function.Name}", + $"{OpenTelemetryConsts.GenAI.ExecuteToolName} {context.Function.Name}", ActivityKind.Internal, default(ActivityContext), [ - new(OpenTelemetryConsts.GenAI.Operation.Name, "execute_tool"), + new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ExecuteToolName), + new(OpenTelemetryConsts.GenAI.Tool.Type, OpenTelemetryConsts.ToolTypeFunction), new(OpenTelemetryConsts.GenAI.Tool.Call.Id, context.CallContent.CallId), new(OpenTelemetryConsts.GenAI.Tool.Name, context.Function.Name), new(OpenTelemetryConsts.GenAI.Tool.Description, context.Function.Description), ]); - long startingTimestamp = 0; - if (_logger.IsEnabled(LogLevel.Debug)) + long startingTimestamp = Stopwatch.GetTimestamp(); + + bool enableSensitiveData = activity is { IsAllDataRequested: true } && InnerClient.GetService()?.EnableSensitiveData is true; + bool traceLoggingEnabled = _logger.IsEnabled(LogLevel.Trace); + bool loggedInvoke = false; + if (enableSensitiveData || traceLoggingEnabled) { - startingTimestamp = Stopwatch.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Trace)) + string functionArguments = TelemetryHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions); + + if (enableSensitiveData) { - LogInvokingSensitive(context.Function.Name, LoggingHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions)); + _ = activity?.SetTag(OpenTelemetryConsts.GenAI.Tool.Call.Arguments, functionArguments); } - else + + if (traceLoggingEnabled) { - LogInvoking(context.Function.Name); + LogInvokingSensitive(context.Function.Name, functionArguments); + loggedInvoke = true; } } + if (!loggedInvoke && _logger.IsEnabled(LogLevel.Debug)) + { + LogInvoking(context.Function.Name); + } + object? result = null; try { @@ -829,7 +1167,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul { if (activity is not null) { - _ = activity.SetTag("error.type", e.GetType().FullName) + _ = activity.SetTag(OpenTelemetryConsts.Error.Type, e.GetType().FullName) .SetStatus(ActivityStatusCode.Error, e.Message); } @@ -846,19 +1184,27 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul } finally { - if (_logger.IsEnabled(LogLevel.Debug)) + bool loggedResult = false; + if (enableSensitiveData || traceLoggingEnabled) { - TimeSpan elapsed = GetElapsedTime(startingTimestamp); + string functionResult = TelemetryHelpers.AsJson(result, context.Function.JsonSerializerOptions); - if (result is not null && _logger.IsEnabled(LogLevel.Trace)) + if (enableSensitiveData) { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.JsonSerializerOptions)); + _ = activity?.SetTag(OpenTelemetryConsts.GenAI.Tool.Call.Result, functionResult); } - else + + if (traceLoggingEnabled) { - LogInvocationCompleted(context.Function.Name, elapsed); + LogInvocationCompletedSensitive(context.Function.Name, GetElapsedTime(startingTimestamp), functionResult); + loggedResult = true; } } + + if (!loggedResult && _logger.IsEnabled(LogLevel.Debug)) + { + LogInvocationCompleted(context.Function.Name, GetElapsedTime(startingTimestamp)); + } } return result; @@ -872,7 +1218,386 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul { _ = Throw.IfNull(context); - return context.Function.InvokeAsync(context.Arguments, cancellationToken); + return FunctionInvoker is { } invoker ? + invoker(context, cancellationToken) : + context.Function.InvokeAsync(context.Arguments, cancellationToken); + } + + /// + /// 1. Remove all and from the . + /// 2. Recreate for any that haven't been executed yet. + /// 3. Genreate failed for any rejected . + /// 4. add all the new content items to and return them as the pre-invocation history. + /// + private static (List? preDownstreamCallHistory, List? approvals) ProcessFunctionApprovalResponses( + List originalMessages, bool hasConversationId, string? toolMessageId, string? functionCallContentFallbackMessageId) + { + // Extract any approval responses where we need to execute or reject the function calls. + // The original messages are also modified to remove all approval requests and responses. + var notInvokedResponses = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + + // Wrap the function call content in message(s). + ICollection? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages( + [.. notInvokedResponses.rejections ?? Enumerable.Empty(), .. notInvokedResponses.approvals ?? Enumerable.Empty()], + functionCallContentFallbackMessageId); + + // Generate failed function result contents for any rejected requests and wrap it in a message. + List? rejectedFunctionCallResults = GenerateRejectedFunctionResults(notInvokedResponses.rejections); + ChatMessage? rejectedPreDownstreamCallResultsMessage = rejectedFunctionCallResults is not null ? + new ChatMessage(ChatRole.Tool, rejectedFunctionCallResults) { MessageId = toolMessageId } : + null; + + // Add all the FCC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. + // Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), add them + // into the original messages list so that they are passed to the inner client and can be used to generate a result. + List? preDownstreamCallHistory = null; + if (allPreDownstreamCallMessages is not null) + { + preDownstreamCallHistory = [.. allPreDownstreamCallMessages]; + if (!hasConversationId) + { + originalMessages.AddRange(preDownstreamCallHistory); + } + } + + // Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. + // Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result. + if (rejectedPreDownstreamCallResultsMessage is not null) + { + (preDownstreamCallHistory ??= []).Add(rejectedPreDownstreamCallResultsMessage); + originalMessages.Add(rejectedPreDownstreamCallResultsMessage); + } + + return (preDownstreamCallHistory, notInvokedResponses.approvals); + } + + /// + /// This method extracts the approval requests and responses from the provided list of messages, + /// validates them, filters them to ones that require execution, and splits them into approved and rejected. + /// + /// + /// We return the messages containing the approval requests since these are the same messages that originally contained the FunctionCallContent from the downstream service. + /// We can then use the metadata from these messages when we re-create the FunctionCallContent messages/updates to return to the caller. This way, when we finally do return + /// the FuncionCallContent to users it's part of a message/update that contains the same metadata as originally returned to the downstream service. + /// + private static (List? approvals, List? rejections) ExtractAndRemoveApprovalRequestsAndResponses( + List messages) + { + Dictionary? allApprovalRequestsMessages = null; + List? allApprovalResponses = null; + HashSet? approvalRequestCallIds = null; + HashSet? functionResultCallIds = null; + + // 1st iteration, over all messages and content: + // - Build a list of all function call ids that are already executed. + // - Build a list of all function approval requests and responses. + // - Build a list of the content we want to keep (everything except approval requests and responses) and create a new list of messages for those. + // - Validate that we have an approval response for each approval request. + bool anyRemoved = false; + int i = 0; + for (; i < messages.Count; i++) + { + var message = messages[i]; + + List? keptContents = null; + + // Examine all content to populate our various collections. + for (int j = 0; j < message.Contents.Count; j++) + { + var content = message.Contents[j]; + switch (content) + { + case FunctionApprovalRequestContent farc: + // Validation: Capture each call id for each approval request to ensure later we have a matching response. + _ = (approvalRequestCallIds ??= []).Add(farc.FunctionCall.CallId); + (allApprovalRequestsMessages ??= []).Add(farc.Id, message); + break; + + case FunctionApprovalResponseContent farc: + // Validation: Remove the call id for each approval response, to check it off the list of requests we need responses for. + _ = approvalRequestCallIds?.Remove(farc.FunctionCall.CallId); + (allApprovalResponses ??= []).Add(farc); + break; + + case FunctionResultContent frc: + // Maintain a list of function calls that have already been invoked to avoid invoking them twice. + _ = (functionResultCallIds ??= []).Add(frc.CallId); + goto default; + + default: + // Content to keep. + (keptContents ??= []).Add(content); + break; + } + } + + // If any contents were filtered out, we need to either remove the message entirely (if no contents remain) or create a new message with the filtered contents. + if (keptContents?.Count != message.Contents.Count) + { + if (keptContents is { Count: > 0 }) + { + // Create a new replacement message to store the filtered contents. + var newMessage = message.Clone(); + newMessage.Contents = keptContents; + messages[i] = newMessage; + } + else + { + // Remove the message entirely since it has no contents left. Rather than doing an O(N) removal, which could possibly + // result in an O(N^2) overall operation, we mark the message as null and then do a single pass removal of all nulls after the loop. + anyRemoved = true; + messages[i] = null!; + } + } + } + + // Clean up any messages that were marked for removal during the iteration. + if (anyRemoved) + { + _ = messages.RemoveAll(static m => m is null); + } + + // Validation: If we got an approval for each request, we should have no call ids left. + if (approvalRequestCallIds is { Count: > 0 }) + { + Throw.InvalidOperationException( + $"FunctionApprovalRequestContent found with FunctionCall.CallId(s) '{string.Join(", ", approvalRequestCallIds)}' that have no matching FunctionApprovalResponseContent."); + } + + // 2nd iteration, over all approval responses: + // - Filter out any approval responses that already have a matching function result (i.e. already executed). + // - Find the matching function approval request for any response (where available). + // - Split the approval responses into two lists: approved and rejected, with their request messages (where available). + List? approvedFunctionCalls = null, rejectedFunctionCalls = null; + if (allApprovalResponses is { Count: > 0 }) + { + foreach (var approvalResponse in allApprovalResponses) + { + // Skip any approval responses that have already been processed. + if (functionResultCallIds?.Contains(approvalResponse.FunctionCall.CallId) is true) + { + continue; + } + + // Split the responses into approved and rejected. + ref List? targetList = ref approvalResponse.Approved ? ref approvedFunctionCalls : ref rejectedFunctionCalls; + + ChatMessage? requestMessage = null; + _ = allApprovalRequestsMessages?.TryGetValue(approvalResponse.FunctionCall.CallId, out requestMessage); + + (targetList ??= []).Add(new() { Response = approvalResponse, RequestMessage = requestMessage }); + } + } + + return (approvedFunctionCalls, rejectedFunctionCalls); + } + + /// + /// If we have any rejected approval responses, we need to generate failed function results for them. + /// + /// Any rejected approval responses. + /// The for the rejected function calls. + private static List? GenerateRejectedFunctionResults(List? rejections) => + rejections is { Count: > 0 } ? + rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user.")) : + null; + + /// + /// Extracts the from the provided to recreate the original function call messages. + /// The output messages tries to mimic the original messages that contained the , e.g. if the + /// had been split into separate messages, this method will recreate similarly split messages, each with their own . + /// + private static ICollection? ConvertToFunctionCallContentMessages( + List? resultWithRequestMessages, string? fallbackMessageId) + { + if (resultWithRequestMessages is not null) + { + ChatMessage? currentMessage = null; + Dictionary? messagesById = null; + + foreach (var resultWithRequestMessage in resultWithRequestMessages) + { + // Don't need to create a dictionary if we already have one or if it's the first iteration. + if (messagesById is null && currentMessage is not null + + // Everywhere we have no RequestMessage we use the fallbackMessageId, so in this case there is only one message. + && !(resultWithRequestMessage.RequestMessage is null && currentMessage.MessageId == fallbackMessageId) + + // Where we do have a RequestMessage, we can check if its message id differs from the current one. + && (resultWithRequestMessage.RequestMessage is not null && currentMessage.MessageId != resultWithRequestMessage.RequestMessage.MessageId)) + { + // The majority of the time, all FCC would be part of a single message, so no need to create a dictionary for this case. + // If we are dealing with multiple messages though, we need to keep track of them by their message ID. + messagesById = []; + messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage; + } + + _ = messagesById?.TryGetValue(resultWithRequestMessage.RequestMessage?.MessageId ?? string.Empty, out currentMessage); + + if (currentMessage is null) + { + currentMessage = ConvertToFunctionCallContentMessage(resultWithRequestMessage, fallbackMessageId); + } + else + { + currentMessage.Contents.Add(resultWithRequestMessage.Response.FunctionCall); + } + +#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499) + messagesById?[currentMessage.MessageId ?? string.Empty] = currentMessage; +#pragma warning restore IDE0058 + } + + if (messagesById?.Values is ICollection cm) + { + return cm; + } + + if (currentMessage is not null) + { + return [currentMessage]; + } + } + + return null; + } + + /// + /// Takes the from the and wraps it in a + /// using the same message id that the was originally returned with from the downstream . + /// + private static ChatMessage ConvertToFunctionCallContentMessage(ApprovalResultWithRequestMessage resultWithRequestMessage, string? fallbackMessageId) + { + ChatMessage functionCallMessage = resultWithRequestMessage.RequestMessage?.Clone() ?? new() { Role = ChatRole.Assistant }; + functionCallMessage.Contents = [resultWithRequestMessage.Response.FunctionCall]; + functionCallMessage.MessageId ??= fallbackMessageId; + return functionCallMessage; + } + + /// + /// Check if any of the provided require approval. + /// Supports checking from a provided index up to the end of the list, to allow efficient incremental checking + /// when streaming. + /// + private static (bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex) CheckForApprovalRequiringFCC( + List? functionCallContents, + AITool[] approvalRequiredFunctions, + bool hasApprovalRequiringFcc, + int lastApprovalCheckedFCCIndex) + { + // If we already found an approval requiring FCC, we can skip checking the rest. + if (hasApprovalRequiringFcc) + { + Debug.Assert(functionCallContents is not null, "functionCallContents must not be null here, since we have already encountered approval requiring functionCallContents"); + return (true, functionCallContents!.Count); + } + + if (functionCallContents is not null) + { + for (; lastApprovalCheckedFCCIndex < functionCallContents.Count; lastApprovalCheckedFCCIndex++) + { + var fcc = functionCallContents![lastApprovalCheckedFCCIndex]; + foreach (var arf in approvalRequiredFunctions) + { + if (arf.Name == fcc.Name) + { + hasApprovalRequiringFcc = true; + break; + } + } + } + } + + return (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); + } + + /// + /// Replaces all with and ouputs a new list if any of them were replaced. + /// + /// true if any was replaced, false otherwise. + private static bool TryReplaceFunctionCallsWithApprovalRequests(IList content, out List? updatedContent) + { + updatedContent = null; + + if (content is { Count: > 0 }) + { + for (int i = 0; i < content.Count; i++) + { + if (content[i] is FunctionCallContent fcc) + { + updatedContent ??= [.. content]; // Clone the list if we haven't already + updatedContent[i] = new FunctionApprovalRequestContent(fcc.CallId, fcc); + } + } + } + + return updatedContent is not null; + } + + /// + /// Replaces all from with + /// if any one of them requires approval. + /// + private static IList ReplaceFunctionCallsWithApprovalRequests( + IList messages, + Dictionary toolMap) + { + var outputMessages = messages; + + bool anyApprovalRequired = false; + List<(int, int)>? allFunctionCallContentIndices = null; + + // Build a list of the indices of all FunctionCallContent items. + // Also check if any of them require approval. + for (int i = 0; i < messages.Count; i++) + { + var content = messages[i].Contents; + for (int j = 0; j < content.Count; j++) + { + if (content[j] is FunctionCallContent functionCall) + { + (allFunctionCallContentIndices ??= []).Add((i, j)); + + if (!anyApprovalRequired) + { + foreach (var t in toolMap) + { + if (t.Value.GetService() is { } araf && araf.Name == functionCall.Name) + { + anyApprovalRequired = true; + break; + } + } + } + } + } + } + + // If any function calls were found, and any of them required approval, we should replace all of them with approval requests. + // This is because we do not have a way to deal with cases where some function calls require approval and others do not, so we just replace all of them. + if (anyApprovalRequired) + { + Debug.Assert(allFunctionCallContentIndices is not null, "We have already encountered function call contents that require approval."); + + // Clone the list so, we don't mutate the input. + outputMessages = [.. messages]; + int lastMessageIndex = -1; + + foreach (var (messageIndex, contentIndex) in allFunctionCallContentIndices!) + { + // Clone the message if we didn't already clone it in a previous iteration. + var message = lastMessageIndex != messageIndex ? outputMessages[messageIndex].Clone() : outputMessages[messageIndex]; + message.Contents = [.. message.Contents]; + + var functionCall = (FunctionCallContent)message.Contents[contentIndex]; + message.Contents[contentIndex] = new FunctionApprovalRequestContent(functionCall.CallId, functionCall); + outputMessages[messageIndex] = message; + + lastMessageIndex = messageIndex; + } + } + + return outputMessages; } private static TimeSpan GetElapsedTime(long startingTimestamp) => @@ -882,6 +1607,33 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); #endif + /// + /// Execute the provided and return the resulting + /// wrapped in objects. + /// + private async Task<(IList? FunctionResultContentMessages, bool ShouldTerminate, int ConsecutiveErrorCount)> InvokeApprovedFunctionApprovalResponsesAsync( + List? notInvokedApprovals, + Dictionary? toolMap, + List originalMessages, + ChatOptions? options, + int consecutiveErrorCount, + bool isStreaming, + CancellationToken cancellationToken) + { + // Check if there are any function calls to do for any approved functions and execute them. + if (notInvokedApprovals is { Count: > 0 }) + { + // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. + var modeAndMessages = await ProcessFunctionCallsAsync( + originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; + + return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); + } + + return (null, false, consecutiveErrorCount); + } + [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] private partial void LogInvoking(string methodName); @@ -948,4 +1700,10 @@ public enum FunctionInvocationStatus /// The function call failed with an exception. Exception, } + + private struct ApprovalResultWithRequestMessage + { + public FunctionApprovalResponseContent Response { get; set; } + public ChatMessage? RequestMessage { get; set; } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs new file mode 100644 index 00000000000..436adeb2295 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs @@ -0,0 +1,518 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that enables image generation capabilities by converting instances to function tools. +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// This client automatically detects instances in the collection +/// and replaces them with equivalent function tools that the chat client can invoke to perform image generation and editing operations. +/// +/// +[Experimental("MEAI001")] +public sealed class ImageGeneratingChatClient : DelegatingChatClient +{ + /// + /// Specifies how image and other data content is handled when passing data to an inner client. + /// + /// + /// Use this enumeration to control whether images in the data content are passed as-is, replaced + /// with unique identifiers, or only generated images are replaced. This setting affects how downstream clients + /// receive and process image data. + /// Reducing what's passed downstream can help manage the context window. + /// + public enum DataContentHandling + { + /// Pass all DataContent to inner client. + None, + + /// Replace all images with unique identifiers when passing to inner client. + AllImages, + + /// Replace only images that were produced by past image generation requests with unique identifiers when passing to inner client. + GeneratedImages + } + + private const string ImageKey = "meai_image"; + + private readonly IImageGenerator _imageGenerator; + private readonly DataContentHandling _dataContentHandling; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for image generation operations. + /// Specifies how to handle instances when passing messages to the inner client. + /// The default is . + /// or is . + public ImageGeneratingChatClient(IChatClient innerClient, IImageGenerator imageGenerator, DataContentHandling dataContentHandling = DataContentHandling.AllImages) + : base(innerClient) + { + _imageGenerator = Throw.IfNull(imageGenerator); + _dataContentHandling = dataContentHandling; + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var requestState = new RequestState(_imageGenerator, _dataContentHandling); + + // Process the chat options to replace HostedImageGenerationTool with functions + var processedOptions = requestState.ProcessChatOptions(options); + var processedMessages = requestState.ProcessChatMessages(messages); + + // Get response from base implementation + var response = await base.GetResponseAsync(processedMessages, processedOptions, cancellationToken); + + // Replace FunctionResultContent instances with generated image content + foreach (var message in response.Messages) + { + message.Contents = requestState.ReplaceImageGenerationFunctionResults(message.Contents); + } + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var requestState = new RequestState(_imageGenerator, _dataContentHandling); + + // Process the chat options to replace HostedImageGenerationTool with functions + var processedOptions = requestState.ProcessChatOptions(options); + var processedMessages = requestState.ProcessChatMessages(messages); + + await foreach (var update in base.GetStreamingResponseAsync(processedMessages, processedOptions, cancellationToken)) + { + // Replace any FunctionResultContent instances with generated image content + var newContents = requestState.ReplaceImageGenerationFunctionResults(update.Contents); + + if (!ReferenceEquals(newContents, update.Contents)) + { + // Create a new update instance with modified contents + var modifiedUpdate = update.Clone(); + modifiedUpdate.Contents = newContents; + yield return modifiedUpdate; + } + else + { + yield return update; + } + } + } + + /// Provides a mechanism for releasing unmanaged resources. + /// to dispose managed resources; otherwise, . + protected override void Dispose(bool disposing) + { + if (disposing) + { + _imageGenerator.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Contains all the per-request state and methods for handling image generation requests. + /// This class is created fresh for each request to ensure thread safety. + /// This class is not exposed publicly and does not own any of it's resources. + /// + private sealed class RequestState + { + private readonly IImageGenerator _imageGenerator; + private readonly DataContentHandling _dataContentHandling; + private readonly HashSet _toolNames = new(StringComparer.Ordinal); + private readonly Dictionary> _imageContentByCallId = []; + private readonly Dictionary _imageContentById = new(StringComparer.OrdinalIgnoreCase); + private ImageGenerationOptions? _imageGenerationOptions; + + public RequestState(IImageGenerator imageGenerator, DataContentHandling dataContentHandling) + { + _imageGenerator = imageGenerator; + _dataContentHandling = dataContentHandling; + } + + /// + /// Processes the chat messages to replace images in data content with unique identifiers as needed. + /// All images will be stored for later retrieval during image editing operations. + /// See for details on image replacement behavior. + /// + /// Messages to process. + /// Processed messages, or the original messages if no changes were made. + public IEnumerable ProcessChatMessages(IEnumerable messages) + { + List? newMessages = null; + int messageIndex = 0; + foreach (var message in messages) + { + List? newContents = null; + for (int contentIndex = 0; contentIndex < message.Contents.Count; contentIndex++) + { + var content = message.Contents[contentIndex]; + + void ReplaceImage(string imageId, DataContent dataContent) + { + // Replace image with a placeholder text content, to give an indication to the model of its placement in the context + newContents ??= CopyList(message.Contents, contentIndex); + newContents.Add(new TextContent($"[{ImageKey}:{imageId}] available for edit.") + { + Annotations = dataContent.Annotations, + AdditionalProperties = dataContent.AdditionalProperties + }); + } + + if (content is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) + { + // Store the image to make available for edit + var imageId = StoreImage(dataContent); + + if (_dataContentHandling == DataContentHandling.AllImages) + { + ReplaceImage(imageId, dataContent); + continue; // Skip adding the original content + } + } + else if (content is ImageGenerationToolResultContent toolResultContent) + { + foreach (var output in toolResultContent.Outputs ?? []) + { + if (output is DataContent generatedDataContent && generatedDataContent.HasTopLevelMediaType("image")) + { + // Store the image to make available for edit + var imageId = StoreImage(generatedDataContent, isGenerated: true); + + if (_dataContentHandling == DataContentHandling.AllImages || + _dataContentHandling == DataContentHandling.GeneratedImages) + { + ReplaceImage(imageId, generatedDataContent); + } + } + } + + if (_dataContentHandling == DataContentHandling.AllImages || + _dataContentHandling == DataContentHandling.GeneratedImages) + { + // skip adding the generated content + continue; + } + } + + // Add the original content if no replacement was made + newContents?.Add(content); + } + + if (newContents != null) + { + newMessages ??= [.. messages.Take(messageIndex)]; + var newMessage = message.Clone(); + newMessage.Contents = newContents; + newMessages.Add(newMessage); + } + else + { + newMessages?.Add(message); + + } + + messageIndex++; + } + + return newMessages ?? messages; + } + + public ChatOptions? ProcessChatOptions(ChatOptions? options) + { + if (options?.Tools is null || options.Tools.Count == 0) + { + return options; + } + + List? newTools = null; + var tools = options.Tools; + for (int i = 0; i < tools.Count; i++) + { + var tool = tools[i]; + + // remove all instances of HostedImageGenerationTool and store the options from the last one + if (tool is HostedImageGenerationTool imageGenerationTool) + { + _imageGenerationOptions = imageGenerationTool.Options; + + // for the first image generation tool, clone the options and insert our function tools + // remove any subsequent image generation tools + newTools ??= InitializeTools(tools, i); + } + else + { + newTools?.Add(tool); + } + } + + if (newTools is not null) + { + var newOptions = options.Clone(); + newOptions.Tools = newTools; + return newOptions; + } + + return options; + + List InitializeTools(IList existingTools, int toOffsetExclusive) + { +#if NET + ReadOnlySpan tools = +#else + AITool[] tools = +#endif + [ + AIFunctionFactory.Create(GenerateImageAsync), + AIFunctionFactory.Create(EditImageAsync), + AIFunctionFactory.Create(GetImagesForEdit) + ]; + + foreach (var tool in tools) + { + _toolNames.Add(tool.Name); + } + + var result = CopyList(existingTools, toOffsetExclusive, tools.Length); + result.AddRange(tools); + return result; + } + } + + /// + /// Replaces FunctionResultContent instances for image generation functions with actual generated image content. + /// We will have two messages + /// 1. Role: Assistant, FunctionCall + /// 2. Role: Tool, FunctionResult + /// We need to replace content from both but we shouldn't remove the messages. + /// If we do not then ChatClient's may not accept our altered history. + /// + /// When interating with a HostedImageGenerationTool we will have typically only see a single Message with + /// Role: Assistant that contains the DataContent (or a provider specific content, that's exposed as DataContent). + /// + /// The list of AI content to process. + public IList ReplaceImageGenerationFunctionResults(IList contents) + { + List? newContents = null; + + // Replace FunctionResultContent instances with generated image content + for (int i = contents.Count - 1; i >= 0; i--) + { + var content = contents[i]; + + // We must lookup by name because in the streaming case we have not yet been called to record the CallId. + if (content is FunctionCallContent functionCall && + _toolNames.Contains(functionCall.Name)) + { + // create a new list and omit the FunctionCallContent + newContents ??= CopyList(contents, i); + + if (functionCall.Name != nameof(GetImagesForEdit)) + { + newContents.Add(new ImageGenerationToolCallContent + { + ImageId = functionCall.CallId, + }); + } + } + else if (content is FunctionResultContent functionResult && + _imageContentByCallId.TryGetValue(functionResult.CallId, out var imageContents)) + { + newContents ??= CopyList(contents, i); + + if (imageContents.Any()) + { + // Insert ImageGenerationToolResultContent in its place, do not preserve the FunctionResultContent + newContents.Add(new ImageGenerationToolResultContent + { + ImageId = functionResult.CallId, + Outputs = imageContents + }); + } + + // Remove the mapping as it's no longer needed + _ = _imageContentByCallId.Remove(functionResult.CallId); + } + else + { + // keep the existing content if we have a new list + newContents?.Add(content); + } + } + + return newContents ?? contents; + } + + [Description("Generates images based on a text description.")] + public async Task GenerateImageAsync( + [Description("A detailed description of the image to generate")] string prompt, + CancellationToken cancellationToken = default) + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return "No call ID available for image generation."; + } + + var request = new ImageGenerationRequest(prompt); + var options = _imageGenerationOptions ?? new ImageGenerationOptions(); + options.Count ??= 1; + + var response = await _imageGenerator.GenerateAsync(request, options, cancellationToken); + + if (response.Contents.Count == 0) + { + return "No image was generated."; + } + + List imageIds = []; + List imageContents = _imageContentByCallId[callId] = []; + foreach (var content in response.Contents) + { + if (content is DataContent imageContent && imageContent.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(imageContent); + imageIds.Add(StoreImage(imageContent, true)); + } + } + + return "Generated image successfully."; + } + + [Description("Lists the identifiers of all images available for edit.")] + public IEnumerable GetImagesForEdit() + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return ["No call ID available for image editing."]; + } + + _imageContentByCallId[callId] = []; + + return _imageContentById.Keys.AsEnumerable(); + } + + [Description("Edits an existing image based on a text description.")] + public async Task EditImageAsync( + [Description("A detailed description of the image to generate")] string prompt, + [Description($"The image to edit from one of the available image identifiers returned by {nameof(GetImagesForEdit)}")] string imageId, + CancellationToken cancellationToken = default) + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return "No call ID available for image editing."; + } + + if (string.IsNullOrEmpty(imageId)) + { + return "No imageId provided"; + } + + try + { + var originalImage = RetrieveImageContent(imageId); + if (originalImage == null) + { + return $"No image found with: {imageId}"; + } + + var request = new ImageGenerationRequest(prompt, [originalImage]); + var response = await _imageGenerator.GenerateAsync(request, _imageGenerationOptions, cancellationToken); + + if (response.Contents.Count == 0) + { + return "No edited image was generated."; + } + + List imageIds = []; + List imageContents = _imageContentByCallId[callId] = []; + foreach (var content in response.Contents) + { + if (content is DataContent imageContent && imageContent.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(imageContent); + imageIds.Add(StoreImage(imageContent, true)); + } + } + + return "Edited image successfully."; + } + catch (FormatException) + { + return "Invalid image data format. Please provide a valid base64-encoded image."; + } + } + + private static List CopyList(IList original, int toOffsetExclusive, int additionalCapacity = 0) + { + var newList = new List(original.Count + additionalCapacity); + + // Copy all items up to and excluding the current index + for (int j = 0; j < toOffsetExclusive; j++) + { + newList.Add(original[j]); + } + + return newList; + } + + private DataContent? RetrieveImageContent(string imageId) + { + if (_imageContentById.TryGetValue(imageId, out var imageContent)) + { + return imageContent as DataContent; + } + + return null; + } + + private string StoreImage(DataContent imageContent, bool isGenerated = false) + { + // Generate a unique ID for the image if it doesn't have one + string? imageId = null; + if (imageContent.AdditionalProperties?.TryGetValue(ImageKey, out imageId) is false || imageId is null) + { + imageId = imageContent.Name ?? Guid.NewGuid().ToString(); + } + + if (isGenerated) + { + imageContent.AdditionalProperties ??= []; + imageContent.AdditionalProperties[ImageKey] = imageId; + } + + // Store the image content for later retrieval + _imageContentById[imageId] = imageContent; + + return imageId; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..241c851fd4e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class ImageGeneratingChatClientBuilderExtensions +{ + /// Adds image generation capabilities to the chat client pipeline. + /// The . + /// + /// An optional used for image generation operations. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + /// + /// + /// This method enables the chat client to handle instances by converting them + /// into function tools that can be invoked by the underlying chat model to perform image generation and editing operations. + /// + /// + public static ChatClientBuilder UseImageGeneration( + this ChatClientBuilder builder, + IImageGenerator? imageGenerator = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + imageGenerator ??= services.GetRequiredService(); + + var chatClient = new ImageGeneratingChatClient(innerClient, imageGenerator); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs index aec72eddcdc..c39fa2c0eb6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -169,7 +169,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - private string AsJson(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions); + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] private partial void LogInvoked(string methodName); diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index c22dc292c8a..1f630a5a62c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -4,20 +4,20 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable CA1307 // Specify StringComparison for clarity +#pragma warning disable CA1308 // Normalize strings to uppercase #pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter #pragma warning disable SA1113 // Comma should be on the same line as previous parameter @@ -25,22 +25,19 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.33, defined at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// public sealed partial class OpenTelemetryChatClient : DelegatingChatClient { - private const LogLevel EventLogLevel = LogLevel.Information; - private readonly ActivitySource _activitySource; private readonly Meter _meter; - private readonly ILogger _logger; private readonly Histogram _tokenUsageHistogram; private readonly Histogram _operationDurationHistogram; private readonly string? _defaultModelId; - private readonly string? _system; + private readonly string? _providerName; private readonly string? _serverAddress; private readonly int _serverPort; @@ -48,20 +45,20 @@ public sealed partial class OpenTelemetryChatClient : DelegatingChatClient /// Initializes a new instance of the class. /// The underlying . - /// The to use for emitting events. + /// The to use for emitting any logging data from the client. /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for backwards compatibility and future use public OpenTelemetryChatClient(IChatClient innerClient, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 : base(innerClient) { Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); - _logger = logger ?? NullLogger.Instance; - if (innerClient!.GetService() is ChatClientMetadata metadata) { _defaultModelId = metadata.DefaultModelId; - _system = metadata.ProviderName; - _serverAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; _serverPort = metadata.ProviderUri?.Port ?? 0; } @@ -72,19 +69,15 @@ public OpenTelemetryChatClient(IChatClient innerClient, ILogger? logger = null, _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; @@ -115,13 +108,16 @@ protected override void Dispose(bool disposing) /// /// if potentially sensitive information should be included in telemetry; /// if telemetry shouldn't include raw inputs and outputs. - /// The default value is . + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). /// /// /// By default, telemetry includes metadata, such as token counts, but not raw inputs /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. /// - public bool EnableSensitiveData { get; set; } + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; /// public override object? GetService(Type serviceType, object? serviceKey = null) => @@ -139,7 +135,7 @@ public override async Task GetResponseAsync( Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; string? requestModelId = options?.ModelId ?? _defaultModelId; - LogChatMessages(messages); + AddInputMessagesTags(messages, options, activity); ChatResponse? response = null; Exception? error = null; @@ -170,7 +166,7 @@ public override async IAsyncEnumerable GetStreamingResponseA Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; string? requestModelId = options?.ModelId ?? _defaultModelId; - LogChatMessages(messages); + AddInputMessagesTags(messages, options, activity); IAsyncEnumerable updates; try @@ -219,6 +215,152 @@ public override async IAsyncEnumerable GetStreamingResponseA } } + internal static string SerializeChatMessages( + IEnumerable messages, ChatFinishReason? chatFinishReason = null, JsonSerializerOptions? customContentSerializerOptions = null) + { + List output = []; + + string? finishReason = + chatFinishReason?.Value is null ? null : + chatFinishReason == ChatFinishReason.Length ? "length" : + chatFinishReason == ChatFinishReason.ContentFilter ? "content_filter" : + chatFinishReason == ChatFinishReason.ToolCalls ? "tool_call" : + "stop"; + + foreach (ChatMessage message in messages) + { + OtelMessage m = new() + { + FinishReason = finishReason, + Role = + message.Role == ChatRole.Assistant ? "assistant" : + message.Role == ChatRole.Tool ? "tool" : + message.Role == ChatRole.System || message.Role == new ChatRole("developer") ? "system" : + "user", + Name = message.AuthorName, + }; + + foreach (AIContent content in message.Contents) + { + switch (content) + { + // These are all specified in the convention: + + case TextContent tc when !string.IsNullOrWhiteSpace(tc.Text): + m.Parts.Add(new OtelGenericPart { Content = tc.Text }); + break; + + case TextReasoningContent trc when !string.IsNullOrWhiteSpace(trc.Text): + m.Parts.Add(new OtelGenericPart { Type = "reasoning", Content = trc.Text }); + break; + + case FunctionCallContent fcc: + m.Parts.Add(new OtelToolCallRequestPart + { + Id = fcc.CallId, + Name = fcc.Name, + Arguments = fcc.Arguments, + }); + break; + + case FunctionResultContent frc: + m.Parts.Add(new OtelToolCallResponsePart + { + Id = frc.CallId, + Response = frc.Result, + }); + break; + + case DataContent dc: + m.Parts.Add(new OtelBlobPart + { + Content = dc.Base64Data.ToString(), + MimeType = dc.MediaType, + Modality = DeriveModalityFromMediaType(dc.MediaType), + }); + break; + + case UriContent uc: + m.Parts.Add(new OtelUriPart + { + Uri = uc.Uri.AbsoluteUri, + MimeType = uc.MediaType, + Modality = DeriveModalityFromMediaType(uc.MediaType), + }); + break; + + case HostedFileContent fc: + m.Parts.Add(new OtelFilePart + { + FileId = fc.FileId, + MimeType = fc.MediaType, + Modality = DeriveModalityFromMediaType(fc.MediaType), + }); + break; + + // These are non-standard and are using the "generic" non-text part that provides an extensibility mechanism: + + case HostedVectorStoreContent vsc: + m.Parts.Add(new OtelGenericPart { Type = "vector_store", Content = vsc.VectorStoreId }); + break; + + case ErrorContent ec: + m.Parts.Add(new OtelGenericPart { Type = "error", Content = ec.Message }); + break; + + default: + JsonElement element = _emptyObject; + try + { + JsonTypeInfo? unknownContentTypeInfo = + customContentSerializerOptions?.TryGetTypeInfo(content.GetType(), out JsonTypeInfo? ctsi) is true ? ctsi : + _defaultOptions.TryGetTypeInfo(content.GetType(), out JsonTypeInfo? dtsi) ? dtsi : + null; + + if (unknownContentTypeInfo is not null) + { + element = JsonSerializer.SerializeToElement(content, unknownContentTypeInfo); + } + } + catch + { + // Ignore the contents of any parts that can't be serialized. + } + + m.Parts.Add(new OtelGenericPart + { + Type = content.GetType().FullName!, + Content = element, + }); + break; + } + } + + output.Add(m); + } + + return JsonSerializer.Serialize(output, _defaultOptions.GetTypeInfo(typeof(IList))); + } + + private static string? DeriveModalityFromMediaType(string? mediaType) + { + if (mediaType is not null) + { + int pos = mediaType.IndexOf('/'); + if (pos >= 0) + { + ReadOnlySpan topLevel = mediaType.AsSpan(0, pos); + return + topLevel.Equals("image", StringComparison.OrdinalIgnoreCase) ? "image" : + topLevel.Equals("audio", StringComparison.OrdinalIgnoreCase) ? "audio" : + topLevel.Equals("video", StringComparison.OrdinalIgnoreCase) ? "video" : + null; + } + } + + return null; + } + /// Creates an activity for a chat request, or returns if not enabled. private Activity? CreateAndConfigureActivity(ChatOptions? options) { @@ -228,15 +370,15 @@ public override async IAsyncEnumerable GetStreamingResponseA string? modelId = options?.ModelId ?? _defaultModelId; activity = _activitySource.StartActivity( - string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Chat : $"{OpenTelemetryConsts.GenAI.Chat} {modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.ChatName : $"{OpenTelemetryConsts.GenAI.ChatName} {modelId}", ActivityKind.Client); - if (activity is not null) + if (activity is { IsAllDataRequested: true }) { _ = activity - .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Chat) + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ChatName) .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) - .AddTag(OpenTelemetryConsts.GenAI.SystemName, _system); + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); if (_serverAddress is not null) { @@ -247,6 +389,11 @@ public override async IAsyncEnumerable GetStreamingResponseA if (options is not null) { + if (options.ConversationId is string conversationId) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, conversationId); + } + if (options.FrequencyPenalty is float frequencyPenalty) { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.FrequencyPenalty, frequencyPenalty); @@ -267,7 +414,7 @@ public override async IAsyncEnumerable GetStreamingResponseA _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.Seed, seed); } - if (options.StopSequences is IList stopSequences) + if (options.StopSequences is IList { Count: > 0 } stopSequences) { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.StopSequences, $"[{string.Join(", ", stopSequences.Select(s => $"\"{s}\""))}]"); } @@ -292,27 +439,39 @@ public override async IAsyncEnumerable GetStreamingResponseA switch (options.ResponseFormat) { case ChatResponseFormatText: - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, "text"); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeText); break; case ChatResponseFormatJson: - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, "json"); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeJson); break; } } - if (_system is not null) + if (EnableSensitiveData) { - // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data - if (EnableSensitiveData && options.AdditionalProperties is { } props) + if (options.Tools is { Count: > 0 }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Tool.Definitions, + JsonSerializer.Serialize(options.Tools.Select(t => t switch + { + _ when t.GetService() is { } af => new OtelFunction + { + Name = af.Name, + Description = af.Description, + Parameters = af.JsonSchema, + }, + _ => new OtelFunction { Type = t.Name }, + }), OtelContext.Default.IEnumerableOtelFunction)); + } + + // Log all additional request options as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (options.AdditionalProperties is { } props) { - // Log all additional request options as per-provider tags. This is non-normative, but it covers cases where - // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.request.service_tier), - // and more generally cases where there's additional useful information to be logged. foreach (KeyValuePair prop in props) { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -349,17 +508,17 @@ private void TraceResponse( if (usage.InputTokenCount is long inputTokens) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); AddMetricTags(ref tags, requestModelId, response); - _tokenUsageHistogram.Record((int)inputTokens); + _tokenUsageHistogram.Record((int)inputTokens, tags); } if (usage.OutputTokenCount is long outputTokens) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "output"); + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); AddMetricTags(ref tags, requestModelId, response); - _tokenUsageHistogram.Record((int)outputTokens); + _tokenUsageHistogram.Record((int)outputTokens, tags); } } @@ -372,7 +531,7 @@ private void TraceResponse( if (response is not null) { - LogChatResponse(response); + AddOutputMessagesTags(response, activity); if (activity is not null) { @@ -395,28 +554,21 @@ private void TraceResponse( if (response.Usage?.InputTokenCount is long inputTokens) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.InputTokens, (int)inputTokens); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); } if (response.Usage?.OutputTokenCount is long outputTokens) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.OutputTokens, (int)outputTokens); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); } - if (_system is not null) + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && response.AdditionalProperties is { } props) { - // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data - if (EnableSensitiveData && response.AdditionalProperties is { } props) + foreach (KeyValuePair prop in props) { - // Log all additional response properties as per-provider tags. This is non-normative, but it covers cases where - // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.response.system_fingerprint), - // and more generally cases where there's additional useful information to be logged. - foreach (KeyValuePair prop in props) - { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); - } + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -424,14 +576,14 @@ private void TraceResponse( void AddMetricTags(ref TagList tags, string? requestModelId, ChatResponse? response) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Chat); + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ChatName); if (requestModelId is not null) { tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); } - tags.Add(OpenTelemetryConsts.GenAI.SystemName, _system); + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); if (_serverAddress is string endpointAddress) { @@ -446,155 +598,122 @@ void AddMetricTags(ref TagList tags, string? requestModelId, ChatResponse? respo } } - private void LogChatMessages(IEnumerable messages) + private void AddInputMessagesTags(IEnumerable messages, ChatOptions? options, Activity? activity) { - if (!_logger.IsEnabled(EventLogLevel)) + if (EnableSensitiveData && activity is { IsAllDataRequested: true }) { - return; - } - - foreach (ChatMessage message in messages) - { - if (message.Role == ChatRole.Assistant) - { - Log(new(1, OpenTelemetryConsts.GenAI.Assistant.Message), - JsonSerializer.Serialize(CreateAssistantEvent(message.Contents), OtelContext.Default.AssistantEvent)); - } - else if (message.Role == ChatRole.Tool) - { - foreach (FunctionResultContent frc in message.Contents.OfType()) - { - Log(new(1, OpenTelemetryConsts.GenAI.Tool.Message), - JsonSerializer.Serialize(new() - { - Id = frc.CallId, - Content = EnableSensitiveData && frc.Result is object result ? - JsonSerializer.SerializeToNode(result, _jsonSerializerOptions.GetTypeInfo(result.GetType())) : - null, - }, OtelContext.Default.ToolEvent)); - } - } - else + if (!string.IsNullOrWhiteSpace(options?.Instructions)) { - Log(new(1, message.Role == ChatRole.System ? OpenTelemetryConsts.GenAI.System.Message : OpenTelemetryConsts.GenAI.User.Message), - JsonSerializer.Serialize(new() - { - Role = message.Role != ChatRole.System && message.Role != ChatRole.User && !string.IsNullOrWhiteSpace(message.Role.Value) ? message.Role.Value : null, - Content = GetMessageContent(message.Contents), - }, OtelContext.Default.SystemOrUserEvent)); + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.SystemInstructions, + JsonSerializer.Serialize(new object[1] { new OtelGenericPart { Content = options!.Instructions } }, _defaultOptions.GetTypeInfo(typeof(IList)))); } + + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Input.Messages, + SerializeChatMessages(messages, customContentSerializerOptions: _jsonSerializerOptions)); } } - private void LogChatResponse(ChatResponse response) + private void AddOutputMessagesTags(ChatResponse response, Activity? activity) { - if (!_logger.IsEnabled(EventLogLevel)) + if (EnableSensitiveData && activity is { IsAllDataRequested: true }) { - return; + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Output.Messages, + SerializeChatMessages(response.Messages, response.FinishReason, customContentSerializerOptions: _jsonSerializerOptions)); } - - EventId id = new(1, OpenTelemetryConsts.GenAI.Choice); - Log(id, JsonSerializer.Serialize(new() - { - FinishReason = response.FinishReason?.Value ?? "error", - Index = 0, - Message = CreateAssistantEvent(response.Messages is { Count: 1 } ? response.Messages[0].Contents : response.Messages.SelectMany(m => m.Contents)), - }, OtelContext.Default.ChoiceEvent)); } - private void Log(EventId id, [StringSyntax(StringSyntaxAttribute.Json)] string eventBodyJson) + private sealed class OtelMessage { - // This is not the idiomatic way to log, but it's necessary for now in order to structure - // the data in a way that the OpenTelemetry collector can work with it. The event body - // can be very large and should not be logged as an attribute. - - KeyValuePair[] tags = - [ - new(OpenTelemetryConsts.Event.Name, id.Name), - new(OpenTelemetryConsts.GenAI.SystemName, _system), - ]; - - _logger.Log(EventLogLevel, id, tags, null, (_, __) => eventBodyJson); + public string? Role { get; set; } + public string? Name { get; set; } + public List Parts { get; set; } = []; + public string? FinishReason { get; set; } } - private AssistantEvent CreateAssistantEvent(IEnumerable contents) + private sealed class OtelGenericPart { - var toolCalls = contents.OfType().Select(fc => new ToolCall - { - Id = fc.CallId, - Function = new() - { - Name = fc.Name, - Arguments = EnableSensitiveData ? - JsonSerializer.SerializeToNode(fc.Arguments, _jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) : - null, - }, - }).ToArray(); - - return new() - { - Content = GetMessageContent(contents), - ToolCalls = toolCalls.Length > 0 ? toolCalls : null, - }; + public string Type { get; set; } = "text"; + public object? Content { get; set; } // should be a string when Type == "text" } - private string? GetMessageContent(IEnumerable contents) + private sealed class OtelBlobPart { - if (EnableSensitiveData) - { - string content = string.Concat(contents.OfType()); - if (content.Length > 0) - { - return content; - } - } - - return null; + public string Type { get; set; } = "blob"; + public string? Content { get; set; } // base64-encoded binary data + public string? MimeType { get; set; } + public string? Modality { get; set; } } - private sealed class SystemOrUserEvent + private sealed class OtelUriPart { - public string? Role { get; set; } - public string? Content { get; set; } + public string Type { get; set; } = "uri"; + public string? Uri { get; set; } + public string? MimeType { get; set; } + public string? Modality { get; set; } } - private sealed class AssistantEvent + private sealed class OtelFilePart { - public string? Content { get; set; } - public ToolCall[]? ToolCalls { get; set; } + public string Type { get; set; } = "file"; + public string? FileId { get; set; } + public string? MimeType { get; set; } + public string? Modality { get; set; } } - private sealed class ToolEvent + private sealed class OtelToolCallRequestPart { + public string Type { get; set; } = "tool_call"; public string? Id { get; set; } - public JsonNode? Content { get; set; } + public string? Name { get; set; } + public IDictionary? Arguments { get; set; } } - private sealed class ChoiceEvent + private sealed class OtelToolCallResponsePart { - public string? FinishReason { get; set; } - public int Index { get; set; } - public AssistantEvent? Message { get; set; } + public string Type { get; set; } = "tool_call_response"; + public string? Id { get; set; } + public object? Response { get; set; } } - private sealed class ToolCall + private sealed class OtelFunction { - public string? Id { get; set; } - public string? Type { get; set; } = "function"; - public ToolCallFunction? Function { get; set; } + public string Type { get; set; } = "function"; + public string? Name { get; set; } + public string? Description { get; set; } + public JsonElement? Parameters { get; set; } } - private sealed class ToolCallFunction + private static readonly JsonSerializerOptions _defaultOptions = CreateDefaultOptions(); + private static readonly JsonElement _emptyObject = JsonSerializer.SerializeToElement(new object(), _defaultOptions.GetTypeInfo(typeof(object))); + + private static JsonSerializerOptions CreateDefaultOptions() { - public string? Name { get; set; } - public JsonNode? Arguments { get; set; } + JsonSerializerOptions options = new(OtelContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.MakeReadOnly(); + + return options; } - [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] - [JsonSerializable(typeof(SystemOrUserEvent))] - [JsonSerializable(typeof(AssistantEvent))] - [JsonSerializable(typeof(ToolEvent))] - [JsonSerializable(typeof(ChoiceEvent))] - [JsonSerializable(typeof(object))] + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(OtelMessage))] + [JsonSerializable(typeof(OtelGenericPart))] + [JsonSerializable(typeof(OtelBlobPart))] + [JsonSerializable(typeof(OtelUriPart))] + [JsonSerializable(typeof(OtelFilePart))] + [JsonSerializable(typeof(OtelToolCallRequestPart))] + [JsonSerializable(typeof(OtelToolCallResponsePart))] + [JsonSerializable(typeof(IEnumerable))] private sealed partial class OtelContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs new file mode 100644 index 00000000000..aadf5f3fed6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs @@ -0,0 +1,307 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating image generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +[Experimental("MEAI001")] +public sealed class OpenTelemetryImageGenerator : DelegatingImageGenerator +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _defaultModelId; + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with IChatClient and future use + public OpenTelemetryImageGenerator(IImageGenerator innerGenerator, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 + : base(innerGenerator) + { + Debug.Assert(innerGenerator is not null, "Should have been validated by the base ctor"); + + if (innerGenerator!.GetService() is ImageGeneratorMetadata metadata) + { + _defaultModelId = metadata.DefaultModelId; + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } + ); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + /// + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + + /// + public async override Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + using Activity? activity = CreateAndConfigureActivity(request, options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + ImageGenerationResponse? response = null; + Exception? error = null; + try + { + response = await base.GenerateAsync(request, options, cancellationToken).ConfigureAwait(false); + return response; + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, requestModelId, response, error, stopwatch); + } + } + + /// Creates an activity for an image generation request, or returns if not enabled. + private Activity? CreateAndConfigureActivity(ImageGenerationRequest request, ImageGenerationOptions? options) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + string? modelId = options?.ModelId ?? _defaultModelId; + + activity = _activitySource.StartActivity( + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.GenerateContentName : $"{OpenTelemetryConsts.GenAI.GenerateContentName} {modelId}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName) + .AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeImage) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (options is not null) + { + if (options.Count is int count) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.ChoiceCount, count); + } + + // Otel hasn't yet standardized tags for image generation parameters; these are based on other systems. + if (options.ImageSize is Size size) + { + _ = activity + .AddTag("gen_ai.request.image.width", size.Width) + .AddTag("gen_ai.request.image.height", size.Height); + } + } + + if (EnableSensitiveData) + { + List content = []; + + if (request.Prompt is not null) + { + content.Add(new TextContent(request.Prompt)); + } + + if (request.OriginalImages is not null) + { + content.AddRange(request.OriginalImages); + } + + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Input.Messages, + OpenTelemetryChatClient.SerializeChatMessages([new(ChatRole.User, content)])); + + if (options?.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + } + + return activity; + } + + /// Adds image generation response information to the activity. + private void TraceResponse( + Activity? activity, + string? requestModelId, + ImageGenerationResponse? response, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + + AddMetricTags(ref tags, requestModelId); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + } + + if (response is not null) + { + if (EnableSensitiveData && + response.Contents is { Count: > 0 } contents && + activity is { IsAllDataRequested: true }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Output.Messages, + OpenTelemetryChatClient.SerializeChatMessages([new(ChatRole.Assistant, contents)])); + } + + if (response.Usage is { } usage) + { + if (_tokenUsageHistogram.Enabled) + { + if (usage.InputTokenCount is long inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); + AddMetricTags(ref tags, requestModelId); + _tokenUsageHistogram.Record((int)inputTokens, tags); + } + + if (usage.OutputTokenCount is long outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); + AddMetricTags(ref tags, requestModelId); + _tokenUsageHistogram.Record((int)outputTokens, tags); + } + } + + if (activity is { IsAllDataRequested: true }) + { + if (usage.InputTokenCount is long inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); + } + + if (usage.OutputTokenCount is long outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); + } + } + } + } + + void AddMetricTags(ref TagList tags, string? requestModelId) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..63919505590 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGeneratorBuilderExtensions.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class OpenTelemetryImageGeneratorBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the image generator pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static ImageGeneratorBuilder UseOpenTelemetry( + this ImageGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerGenerator, services) => + { + loggerFactory ??= services.GetService(); + + var g = new OpenTelemetryImageGenerator(innerGenerator, loggerFactory?.CreateLogger(typeof(OpenTelemetryImageGenerator)), sourceName); + configure?.Invoke(g); + + return g; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs new file mode 100644 index 00000000000..afe56eddbd8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A chat client that reduces the size of a message list. +/// +[Experimental("MEAI001")] +public sealed class ReducingChatClient : DelegatingChatClient +{ + private readonly IChatReducer _reducer; + + /// Initializes a new instance of the class. + /// The underlying , or the next instance in a chain of clients. + /// The reducer to be used by this instance. + public ReducingChatClient(IChatClient innerClient, IChatReducer reducer) + : base(innerClient) + { + _reducer = Throw.IfNull(reducer); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); + + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..2f13d3e3cea --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for attaching a to a chat pipeline. +/// +[Experimental("MEAI001")] +public static class ReducingChatClientBuilderExtensions +{ + /// + /// Adds a to the chat pipeline. + /// + /// The being used to build the chat pipeline. + /// An optional to apply to the chat client. If not supplied, an instance will be resolved from the service provider. + /// An optional callback that can be used to configure the instance. + /// The configured instance. + public static ChatClientBuilder UseChatReducer( + this ChatClientBuilder builder, + IChatReducer? reducer = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + reducer ??= services.GetRequiredService(); + + var chatClient = new ReducingChatClient(innerClient, reducer); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer_Forwarder.cs similarity index 51% rename from src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs rename to src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer_Forwarder.cs index a00d0e0e290..8a61f0c83be 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer_Forwarder.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.Extensions.AI; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; -internal sealed class OllamaToolCall -{ - public OllamaFunctionToolCall? Function { get; set; } -} +[assembly: TypeForwardedTo(typeof(IChatReducer))] diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs new file mode 100644 index 00000000000..5ba48617355 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides a chat reducer that limits the number of non-system messages in a conversation to a specified maximum +/// count, preserving the most recent messages and the first system message if present. +/// +/// +/// This reducer is useful for scenarios where it is necessary to constrain the size of a chat history, +/// such as when preparing input for models with context length limits. The reducer always includes the first +/// encountered system message, if any, and then retains up to the specified number of the most recent non-system +/// messages. Messages containing function call or function result content are excluded from the reduced +/// output. +/// +[Experimental("MEAI001")] +public sealed class MessageCountingChatReducer : IChatReducer +{ + private readonly int _targetCount; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of non-system messages to retain in the reduced output. + public MessageCountingChatReducer(int targetCount) + { + _targetCount = Throw.IfLessThanOrEqual(targetCount, min: 0); + } + + /// + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + _ = Throw.IfNull(messages); + return Task.FromResult(GetReducedMessages(messages)); + } + + private IEnumerable GetReducedMessages(IEnumerable messages) + { + ChatMessage? systemMessage = null; + Queue reducedMessages = new(capacity: _targetCount); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + systemMessage ??= message; + } + else if (!message.Contents.Any(m => m is FunctionCallContent or FunctionResultContent)) + { + if (reducedMessages.Count >= _targetCount) + { + _ = reducedMessages.Dequeue(); + } + + reducedMessages.Enqueue(message); + } + } + + if (systemMessage is not null) + { + yield return systemMessage; + } + + while (reducedMessages.Count > 0) + { + yield return reducedMessages.Dequeue(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs new file mode 100644 index 00000000000..28f05ed9e5f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs @@ -0,0 +1,233 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides functionality to reduce a collection of chat messages into a summarized form. +/// +/// +/// This reducer is useful for scenarios where it is necessary to constrain the size of a chat history, +/// such as when preparing input for models with context length limits. The reducer automatically summarizes +/// older messages when the conversation exceeds a specified length, preserving context while reducing message +/// count. The reducer maintains system messages and excludes messages containing function call or function +/// result content from summarization. +/// +[Experimental("MEAI001")] +public sealed class SummarizingChatReducer : IChatReducer +{ + private const string SummaryKey = "__summary__"; + + private const string DefaultSummarizationPrompt = """ + **Generate a clear and complete summary of the entire conversation in no more than five sentences.** + + The summary must always: + - Reflect contributions from both the user and the assistant + - Preserve context to support ongoing dialogue + - Incorporate any previously provided summary + - Emphasize the most relevant and meaningful points + + The summary must never: + - Offer critique, correction, interpretation, or speculation + - Highlight errors, misunderstandings, or judgments of accuracy + - Comment on events or ideas not present in the conversation + - Omit any details included in an earlier summary + """; + + private readonly IChatClient _chatClient; + private readonly int _targetCount; + private readonly int _thresholdCount; + + /// + /// Gets or sets the prompt text used for summarization. + /// + public string SummarizationPrompt + { + get; + set => field = Throw.IfNull(value); + } = DefaultSummarizationPrompt; + + /// + /// Initializes a new instance of the class with the specified chat client, + /// target count, and optional threshold count. + /// + /// The chat client used to interact with the chat system. Cannot be . + /// The target number of messages to retain after summarization. Must be greater than 0. + /// The number of messages allowed beyond before summarization is triggered. Must be greater than or equal to 0 if specified. + public SummarizingChatReducer(IChatClient chatClient, int targetCount, int? threshold) + { + _chatClient = Throw.IfNull(chatClient); + _targetCount = Throw.IfLessThanOrEqual(targetCount, min: 0); + _thresholdCount = Throw.IfLessThan(threshold ?? 0, min: 0, nameof(threshold)); + } + + /// + public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + _ = Throw.IfNull(messages); + + var summarizedConversation = SummarizedConversation.FromChatMessages(messages); + var indexOfFirstMessageToKeep = summarizedConversation.FindIndexOfFirstMessageToKeep(_targetCount, _thresholdCount); + if (indexOfFirstMessageToKeep > 0) + { + summarizedConversation = await summarizedConversation.ResummarizeAsync( + _chatClient, + indexOfFirstMessageToKeep, + SummarizationPrompt, + cancellationToken); + } + + return summarizedConversation.ToChatMessages(); + } + + /// Represents a conversation with an optional summary. + private readonly struct SummarizedConversation(string? summary, ChatMessage? systemMessage, IList unsummarizedMessages) + { + /// Creates a from a list of chat messages. + public static SummarizedConversation FromChatMessages(IEnumerable messages) + { + string? summary = null; + ChatMessage? systemMessage = null; + var unsummarizedMessages = new List(); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + systemMessage ??= message; + } + else if (message.AdditionalProperties?.TryGetValue(SummaryKey, out var summaryValue) == true) + { + unsummarizedMessages.Clear(); + summary = summaryValue; + } + else + { + unsummarizedMessages.Add(message); + } + } + + return new(summary, systemMessage, unsummarizedMessages); + } + + /// Performs summarization by calling the chat client and updating the conversation state. + public async ValueTask ResummarizeAsync( + IChatClient chatClient, int indexOfFirstMessageToKeep, string summarizationPrompt, CancellationToken cancellationToken) + { + Debug.Assert(indexOfFirstMessageToKeep > 0, "Expected positive index for first message to keep."); + + // Generate the summary by sending unsummarized messages to the chat client + var summarizerChatMessages = ToSummarizerChatMessages(indexOfFirstMessageToKeep, summarizationPrompt); + var response = await chatClient.GetResponseAsync(summarizerChatMessages, cancellationToken: cancellationToken); + var newSummary = response.Text; + + // Attach the summary metadata to the last message being summarized + // This is what allows us to build on previously-generated summaries + var lastSummarizedMessage = unsummarizedMessages[indexOfFirstMessageToKeep - 1]; + var additionalProperties = lastSummarizedMessage.AdditionalProperties ??= []; + additionalProperties[SummaryKey] = newSummary; + + // Compute the new list of unsummarized messages + var newUnsummarizedMessages = unsummarizedMessages.Skip(indexOfFirstMessageToKeep).ToList(); + return new SummarizedConversation(newSummary, systemMessage, newUnsummarizedMessages); + } + + /// Determines the index of the first message to keep (not summarize) based on target and threshold counts. + public int FindIndexOfFirstMessageToKeep(int targetCount, int thresholdCount) + { + var earliestAllowedIndex = unsummarizedMessages.Count - thresholdCount - targetCount; + if (earliestAllowedIndex <= 0) + { + // Not enough messages to warrant summarization + return 0; + } + + // Start at the ideal cut point (keeping exactly targetCount messages) + var indexOfFirstMessageToKeep = unsummarizedMessages.Count - targetCount; + + // Move backward to skip over function call/result content at the boundary + // We want to keep complete function call sequences together with their responses + while (indexOfFirstMessageToKeep > 0) + { + if (!unsummarizedMessages[indexOfFirstMessageToKeep - 1].Contents.Any(IsToolRelatedContent)) + { + break; + } + + indexOfFirstMessageToKeep--; + } + + // Search backward within the threshold window to find a User message + // If found, cut right before it to avoid orphaning user questions from responses + for (var i = indexOfFirstMessageToKeep; i >= earliestAllowedIndex; i--) + { + if (unsummarizedMessages[i].Role == ChatRole.User) + { + return i; + } + } + + // No User message found within threshold - use the adjusted cut point + return indexOfFirstMessageToKeep; + } + + /// Converts the summarized conversation back into a collection of chat messages. + public IEnumerable ToChatMessages() + { + if (systemMessage is not null) + { + yield return systemMessage; + } + + if (summary is not null) + { + yield return new ChatMessage(ChatRole.Assistant, summary); + } + + foreach (var message in unsummarizedMessages) + { + yield return message; + } + } + + /// Returns whether the given relates to tool calling capabilities. + /// + /// This method returns for content types whose meaning depends on other related + /// instances in the conversation, such as function calls that require corresponding results, or other tool interactions that span + /// multiple messages. Such content should be kept together during summarization. + /// + private static bool IsToolRelatedContent(AIContent content) => content + is FunctionCallContent + or FunctionResultContent + or UserInputRequestContent + or UserInputResponseContent; + + /// Builds the list of messages to send to the chat client for summarization. + private IEnumerable ToSummarizerChatMessages(int indexOfFirstMessageToKeep, string summarizationPrompt) + { + if (summary is not null) + { + yield return new ChatMessage(ChatRole.Assistant, summary); + } + + for (var i = 0; i < indexOfFirstMessageToKeep; i++) + { + var message = unsummarizedMessages[i]; + if (!message.Contents.Any(IsToolRelatedContent)) + { + yield return message; + } + } + + yield return new ChatMessage(ChatRole.System, summarizationPrompt); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs index 2c880d7a22c..926378ad517 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/CachingEmbeddingGenerator.cs @@ -54,6 +54,11 @@ public override async Task> GenerateAsync( Throw.InvalidOperationException($"Expected exactly one embedding to be generated, but received {generated.Count}."); } + if (generated[0] is null) + { + Throw.InvalidOperationException("Generator produced null embedding."); + } + await WriteCacheAsync(cacheKey, generated[0], cancellationToken); return generated; } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs index 73867e4b2f7..d2657bfdd1f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -4,8 +4,6 @@ using System; using Microsoft.Shared.Diagnostics; -#pragma warning disable SA1629 // Documentation text should end with a period - namespace Microsoft.Extensions.AI; /// Provides extensions for configuring instances. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs index cd26879d040..7da9671554b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/DistributedCachingEmbeddingGenerator.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using System.Threading; @@ -24,7 +27,17 @@ namespace Microsoft.Extensions.AI; public class DistributedCachingEmbeddingGenerator : CachingEmbeddingGenerator where TEmbedding : Embedding { + /// Boxed cache version. + /// Bump the cache version to invalidate existing caches if the serialization format changes in a breaking way. + private static readonly object _cacheVersion = 2; + + /// The instance that will be used as the backing store for the cache. private readonly IDistributedCache _storage; + + /// Additional values used to inform the cache key employed for storing state. + private object[]? _cacheKeyAdditionalValues; + + /// Additional cache key values used to inform the key employed for storing state. private JsonSerializerOptions _jsonSerializerOptions; /// Initializes a new instance of the class. @@ -51,6 +64,14 @@ public JsonSerializerOptions JsonSerializerOptions } } + /// Gets or sets additional values used to inform the cache key employed for storing state. + /// Any values set in this list will augment the other values used to inform the cache key. + public IReadOnlyList? CacheKeyAdditionalValues + { + get => _cacheKeyAdditionalValues; + set => _cacheKeyAdditionalValues = value?.ToArray(); + } + /// protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) { @@ -87,6 +108,26 @@ protected override async Task WriteCacheAsync(string key, TEmbedding value, Canc /// The generated cache key is not guaranteed to be stable across releases of the library. /// /// - protected override string GetCacheKey(params ReadOnlySpan values) => - AIJsonUtilities.HashDataToString(values, _jsonSerializerOptions); + protected override string GetCacheKey(params ReadOnlySpan values) + { + const int FixedValuesCount = 1; + + object[] clientValues = _cacheKeyAdditionalValues ?? Array.Empty(); + int length = FixedValuesCount + clientValues.Length + values.Length; + + object?[] arr = ArrayPool.Shared.Rent(length); + try + { + arr[0] = _cacheVersion; + values.CopyTo(arr.AsSpan(FixedValuesCount)); + clientValues.CopyTo(arr, FixedValuesCount + values.Length); + + return AIJsonUtilities.HashDataToString(arr.AsSpan(0, length), _jsonSerializerOptions); + } + finally + { + Array.Clear(arr, 0, length); + ArrayPool.Shared.Return(arr); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 99a3ed684af..f13f7273d89 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -19,7 +18,7 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.33, defined at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. /// /// The type of input used to produce embeddings. @@ -33,10 +32,9 @@ public sealed class OpenTelemetryEmbeddingGenerator : Delega private readonly Histogram _tokenUsageHistogram; private readonly Histogram _operationDurationHistogram; - private readonly string? _system; + private readonly string? _providerName; private readonly string? _defaultModelId; private readonly int? _defaultModelDimensions; - private readonly string? _modelProvider; private readonly string? _endpointAddress; private readonly int _endpointPort; @@ -44,7 +42,7 @@ public sealed class OpenTelemetryEmbeddingGenerator : Delega /// Initializes a new instance of the class. /// /// The underlying , which is the next stage of the pipeline. - /// The to use for emitting events. + /// The to use for emitting any logging data from the generator. /// An optional source name that will be used on the telemetry data. #pragma warning disable IDE0060 // Remove unused parameter; it exists for future use and consistency with OpenTelemetryChatClient public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator innerGenerator, ILogger? logger = null, string? sourceName = null) @@ -55,11 +53,10 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i if (innerGenerator!.GetService() is EmbeddingGeneratorMetadata metadata) { - _system = metadata.ProviderName; _defaultModelId = metadata.DefaultModelId; _defaultModelDimensions = metadata.DefaultModelDimensions; - _modelProvider = metadata.ProviderName; - _endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _providerName = metadata.ProviderName; + _endpointAddress = metadata.ProviderUri?.Host; _endpointPort = metadata.ProviderUri?.Port ?? 0; } @@ -70,19 +67,15 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); } @@ -92,13 +85,16 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i /// /// if potentially sensitive information should be included in telemetry; /// if telemetry shouldn't include raw inputs and outputs. - /// The default value is . + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). /// /// /// By default, telemetry includes metadata, such as token counts, but not raw inputs /// and outputs or additional options data. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. /// - public bool EnableSensitiveData { get; set; } + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; /// public override object? GetService(Type serviceType, object? serviceKey = null) => @@ -154,13 +150,13 @@ protected override void Dispose(bool disposing) string? modelId = options?.ModelId ?? _defaultModelId; activity = _activitySource.StartActivity( - string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embeddings : $"{OpenTelemetryConsts.GenAI.Embeddings} {modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.EmbeddingsName : $"{OpenTelemetryConsts.GenAI.EmbeddingsName} {modelId}", ActivityKind.Client, default(ActivityContext), [ - new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings), + new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.EmbeddingsName), new(OpenTelemetryConsts.GenAI.Request.Model, modelId), - new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider), + new(OpenTelemetryConsts.GenAI.Provider.Name, _providerName), ]); if (activity is not null) @@ -174,22 +170,16 @@ protected override void Dispose(bool disposing) if ((options?.Dimensions ?? _defaultModelDimensions) is int dimensionsValue) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensionsValue); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Embeddings.Dimension.Count, dimensionsValue); } - // Log all additional request options as per-provider tags. This is non-normative, but it covers cases where - // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.request.service_tier), - // and more generally cases where there's additional useful information to be logged. + // Log all additional request options as raw values on the span. // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. - if (EnableSensitiveData && - _system is not null && - options?.AdditionalProperties is { } props) + if (EnableSensitiveData && options?.AdditionalProperties is { } props) { foreach (KeyValuePair prop in props) { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -232,10 +222,10 @@ private void TraceResponse( if (_tokenUsageHistogram.Enabled && inputTokens.HasValue) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); AddMetricTags(ref tags, requestModelId, responseModelId); - _tokenUsageHistogram.Record(inputTokens.Value); + _tokenUsageHistogram.Record(inputTokens.Value, tags); } if (activity is not null) @@ -249,7 +239,7 @@ private void TraceResponse( if (inputTokens.HasValue) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.InputTokens, inputTokens); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, inputTokens); } if (responseModelId is not null) @@ -257,18 +247,13 @@ private void TraceResponse( _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, responseModelId); } - // Log all additional response properties as per-provider tags. This is non-normative, but it covers cases where - // there's a per-provider specification in a best-effort manner (e.g. gen_ai.openai.response.system_fingerprint), - // and more generally cases where there's additional useful information to be logged. - if (EnableSensitiveData && - _system is not null && - embeddings?.AdditionalProperties is { } props) + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && embeddings?.AdditionalProperties is { } props) { foreach (KeyValuePair prop in props) { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -276,14 +261,14 @@ _system is not null && private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings); + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.EmbeddingsName); if (requestModelId is not null) { tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); } - tags.Add(OpenTelemetryConsts.GenAI.SystemName, _modelProvider); + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); if (_endpointAddress is string endpointAddress) { diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs new file mode 100644 index 00000000000..b9e698a33f6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating image generator that configures a instance used by the remainder of the pipeline. +[Experimental("MEAI001")] +public sealed class ConfigureOptionsImageGenerator : DelegatingImageGenerator +{ + /// The callback delegate used to configure options. + private readonly Action _configureOptions; + + /// Initializes a new instance of the class with the specified callback. + /// The inner generator. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance + /// (or a newly constructed instance if the caller-supplied instance is ). + /// + /// or is . + /// + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via of the caller-supplied + /// instance if one was supplied. + /// + public ConfigureOptionsImageGenerator(IImageGenerator innerGenerator, Action configure) + : base(innerGenerator) + { + _configureOptions = Throw.IfNull(configure); + } + + /// + public override async Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.GenerateAsync(request, Configure(options), cancellationToken); + } + + /// Creates and configures the to pass along to the inner generator. + private ImageGenerationOptions Configure(ImageGenerationOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..52c953fba77 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class ConfigureOptionsImageGeneratorBuilderExtensions +{ + /// + /// Adds a callback that configures a to be passed to the next generator in the pipeline. + /// + /// The . + /// + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). + /// + /// or is . + /// + /// This method can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via ) + /// of the caller-supplied instance if one was supplied. + /// + /// The . + public static ImageGeneratorBuilder ConfigureOptions( + this ImageGeneratorBuilder builder, Action configure) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configure); + + return builder.Use(innerGenerator => new ConfigureOptionsImageGenerator(innerGenerator, configure)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs new file mode 100644 index 00000000000..9070ed8a59c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +[Experimental("MEAI001")] +public sealed class ImageGeneratorBuilder +{ + private readonly Func _innerGeneratorFactory; + + /// The registered generator factory instances. + private List>? _generatorFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + /// is . + public ImageGeneratorBuilder(IImageGenerator innerGenerator) + { + _ = Throw.IfNull(innerGenerator); + _innerGeneratorFactory = _ => innerGenerator; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + /// is . + public ImageGeneratorBuilder(Func innerGeneratorFactory) + { + _innerGeneratorFactory = Throw.IfNull(innerGeneratorFactory); + } + + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IImageGenerator Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var imageGenerator = _innerGeneratorFactory(services); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_generatorFactories is not null) + { + for (var i = _generatorFactories.Count - 1; i >= 0; i--) + { + imageGenerator = _generatorFactories[i](imageGenerator, services) ?? + throw new InvalidOperationException( + $"The {nameof(ImageGeneratorBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IImageGenerator)} instances."); + } + } + + return imageGenerator; + } + + /// Adds a factory for an intermediate image generator to the image generator pipeline. + /// The generator factory function. + /// The updated instance. + /// is . + public ImageGeneratorBuilder Use(Func generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + return Use((innerGenerator, _) => generatorFactory(innerGenerator)); + } + + /// Adds a factory for an intermediate image generator to the image generator pipeline. + /// The generator factory function. + /// The updated instance. + /// is . + public ImageGeneratorBuilder Use(Func generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + (_generatorFactories ??= []).Add(generatorFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs new file mode 100644 index 00000000000..e8242287b68 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with in the context of . +[Experimental("MEAI001")] +public static class ImageGeneratorBuilderImageGeneratorExtensions +{ + /// Creates a new using as its inner generator. + /// The generator to use as the inner generator. + /// The new instance. + /// is . + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner generator. + /// + public static ImageGeneratorBuilder AsBuilder(this IImageGenerator innerGenerator) + { + _ = Throw.IfNull(innerGenerator); + + return new ImageGeneratorBuilder(innerGenerator); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..7868adf2eb3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Provides extension methods for registering with a . +[Experimental("MEAI001")] +public static class ImageGeneratorBuilderServiceCollectionExtensions +{ + /// Registers a singleton in the . + /// The to which the generator should be added. + /// The inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// or is . + /// The generator is registered as a singleton service. + public static ImageGeneratorBuilder AddImageGenerator( + this IServiceCollection serviceCollection, + IImageGenerator innerGenerator, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddImageGenerator(serviceCollection, _ => innerGenerator, lifetime); + + /// Registers a singleton in the . + /// The to which the generator should be added. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// or is . + /// The generator is registered as a singleton service. + public static ImageGeneratorBuilder AddImageGenerator( + this IServiceCollection serviceCollection, + Func innerGeneratorFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerGeneratorFactory); + + var builder = new ImageGeneratorBuilder(innerGeneratorFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IImageGenerator), builder.Build, lifetime)); + return builder; + } + + /// Registers a keyed singleton in the . + /// The to which the generator should be added. + /// The key with which to associate the generator. + /// The inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// , , or is . + /// The generator is registered as a scoped service. + public static ImageGeneratorBuilder AddKeyedImageGenerator( + this IServiceCollection serviceCollection, + object? serviceKey, + IImageGenerator innerGenerator, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddKeyedImageGenerator(serviceCollection, serviceKey, _ => innerGenerator, lifetime); + + /// Registers a keyed singleton in the . + /// The to which the generator should be added. + /// The key with which to associate the generator. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// , , or is . + /// The generator is registered as a scoped service. + public static ImageGeneratorBuilder AddKeyedImageGenerator( + this IServiceCollection serviceCollection, + object? serviceKey, + Func innerGeneratorFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerGeneratorFactory); + + var builder = new ImageGeneratorBuilder(innerGeneratorFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IImageGenerator), serviceKey, factory: (services, serviceKey) => builder.Build(services), lifetime)); + return builder; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs new file mode 100644 index 00000000000..f74701d766e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating image generator that logs image generation operations to an . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// When the employed enables , the contents of +/// prompts and options are logged. These prompts and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Prompts and options are not logged at other logging levels. +/// +/// +[Experimental("MEAI001")] +public partial class LoggingImageGenerator : DelegatingImageGenerator +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + /// or is . + public LoggingImageGenerator(IImageGenerator innerGenerator, ILogger logger) + : base(innerGenerator) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + /// The value being set is . + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(GenerateAsync), request.Prompt ?? string.Empty, AsJson(options), AsJson(this.GetService())); + } + else + { + LogInvoked(nameof(GenerateAsync)); + } + } + + try + { + var response = await base.GenerateAsync(request, options, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace) && response.Contents.All(c => c is not DataContent)) + { + LogCompletedSensitive(nameof(GenerateAsync), AsJson(response)); + } + else + { + LogCompleted(nameof(GenerateAsync)); + } + } + + return response; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GenerateAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GenerateAsync), ex); + throw; + } + } + + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Prompt: {Prompt}. Options: {ImageGenerationOptions}. Metadata: {ImageGeneratorMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string prompt, string imageGenerationOptions, string imageGeneratorMetadata); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} completed: {ImageGenerationResponse}.")] + private partial void LogCompletedSensitive(string methodName, string imageGenerationResponse); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..ece65d942ed --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class LoggingImageGeneratorBuilderExtensions +{ + /// Adds logging to the image generator pipeline. + /// The . + /// + /// An optional used to create a logger with which logging should be performed. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + /// + /// + /// When the employed enables , the contents of + /// prompts and options are logged. These prompts and options may contain sensitive application data. + /// is disabled by default and should never be enabled in a production environment. + /// Prompts and options are not logged at other logging levels. + /// + /// + public static ImageGeneratorBuilder UseLogging( + this ImageGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerGenerator, services) => + { + loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingImageGenerator will end up + // being an expensive nop, so skip adding it and just return the inner generator. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerGenerator; + } + + var imageGenerator = new LoggingImageGenerator(innerGenerator, loggerFactory.CreateLogger(typeof(LoggingImageGenerator))); + configure?.Invoke(imageGenerator); + return imageGenerator; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 3b621827213..960ae56de65 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -1,21 +1,21 @@ - + Microsoft.Extensions.AI Utilities for working with generative AI components. AI + true - preview - false + normal 89 - 0 + 85 $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;CA1034;SA1316;S1067;S1121;S1994;S3253 + $(NoWarn);MEAI001 $(NoWarn);CA2007 - + true true @@ -45,6 +45,10 @@ + + + + @@ -53,5 +57,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json index e69de29bb2d..45b72f0aad1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json @@ -0,0 +1,860 @@ +{ + "Name": "Microsoft.Extensions.AI, Version=9.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "abstract class Microsoft.Extensions.AI.CachingChatClient : Microsoft.Extensions.AI.DelegatingChatClient", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.CachingChatClient.CachingChatClient(Microsoft.Extensions.AI.IChatClient innerClient);", + "Stage": "Stable" + }, + { + // After generating the baseline, manually edit this file to have 'params' instead of 'scoped' + // This is needed until ICSharpCode.Decompiler adds params collection support + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Member": "abstract string Microsoft.Extensions.AI.CachingChatClient.GetCacheKey(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options, params System.ReadOnlySpan additionalValues);", + "Stage": "Stable" + }, + { + "Member": "virtual bool Microsoft.Extensions.AI.CachingChatClient.EnableCaching(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.CachingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.CachingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.Task Microsoft.Extensions.AI.CachingChatClient.ReadCacheAsync(string key, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.Task?> Microsoft.Extensions.AI.CachingChatClient.ReadCacheStreamingAsync(string key, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.Task Microsoft.Extensions.AI.CachingChatClient.WriteCacheAsync(string key, Microsoft.Extensions.AI.ChatResponse value, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.Task Microsoft.Extensions.AI.CachingChatClient.WriteCacheStreamingAsync(string key, System.Collections.Generic.IReadOnlyList value, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.CachingChatClient.CoalesceStreamingUpdates { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.CachingEmbeddingGenerator : Microsoft.Extensions.AI.DelegatingEmbeddingGenerator where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.CachingEmbeddingGenerator.CachingEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task> Microsoft.Extensions.AI.CachingEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + // After generating the baseline, manually edit this file to have 'params' instead of 'scoped' + // This is needed until ICSharpCode.Decompiler adds params collection support + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Member": "abstract string Microsoft.Extensions.AI.CachingEmbeddingGenerator.GetCacheKey(params System.ReadOnlySpan values);", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.Task Microsoft.Extensions.AI.CachingEmbeddingGenerator.ReadCacheAsync(string key, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "abstract System.Threading.Tasks.Task Microsoft.Extensions.AI.CachingEmbeddingGenerator.WriteCacheAsync(string key, TEmbedding value, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ChatClientBuilder", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatClientBuilder.ChatClientBuilder(Microsoft.Extensions.AI.IChatClient innerClient);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatClientBuilder.ChatClientBuilder(System.Func innerClientFactory);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.IChatClient Microsoft.Extensions.AI.ChatClientBuilder.Build(System.IServiceProvider? services = null);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.ChatClientBuilder.Use(System.Func clientFactory);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.ChatClientBuilder.Use(System.Func clientFactory);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.ChatClientBuilder.Use(System.Func, Microsoft.Extensions.AI.ChatOptions?, System.Func, Microsoft.Extensions.AI.ChatOptions?, System.Threading.CancellationToken, System.Threading.Tasks.Task>, System.Threading.CancellationToken, System.Threading.Tasks.Task> sharedFunc);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.ChatClientBuilder.Use(System.Func, Microsoft.Extensions.AI.ChatOptions?, Microsoft.Extensions.AI.IChatClient, System.Threading.CancellationToken, System.Threading.Tasks.Task>? getResponseFunc, System.Func, Microsoft.Extensions.AI.ChatOptions?, Microsoft.Extensions.AI.IChatClient, System.Threading.CancellationToken, System.Collections.Generic.IAsyncEnumerable>? getStreamingResponseFunc);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ChatClientBuilderChatClientExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.ChatClientBuilderChatClientExtensions.AsBuilder(this Microsoft.Extensions.AI.IChatClient innerClient);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.DependencyInjection.ChatClientBuilderServiceCollectionExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.DependencyInjection.ChatClientBuilderServiceCollectionExtensions.AddChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, Microsoft.Extensions.AI.IChatClient innerClient, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.DependencyInjection.ChatClientBuilderServiceCollectionExtensions.AddChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Func innerClientFactory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.DependencyInjection.ChatClientBuilderServiceCollectionExtensions.AddKeyedChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, object? serviceKey, Microsoft.Extensions.AI.IChatClient innerClient, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.DependencyInjection.ChatClientBuilderServiceCollectionExtensions.AddKeyedChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, object? serviceKey, System.Func innerClientFactory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient chatClient, System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, bool? useJsonSchemaResponseFormat = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient chatClient, string chatMessage, Microsoft.Extensions.AI.ChatOptions? options = null, bool? useJsonSchemaResponseFormat = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient chatClient, Microsoft.Extensions.AI.ChatMessage chatMessage, Microsoft.Extensions.AI.ChatOptions? options = null, bool? useJsonSchemaResponseFormat = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient chatClient, string chatMessage, System.Text.Json.JsonSerializerOptions serializerOptions, Microsoft.Extensions.AI.ChatOptions? options = null, bool? useJsonSchemaResponseFormat = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient chatClient, Microsoft.Extensions.AI.ChatMessage chatMessage, System.Text.Json.JsonSerializerOptions serializerOptions, Microsoft.Extensions.AI.ChatOptions? options = null, bool? useJsonSchemaResponseFormat = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "static System.Threading.Tasks.Task> Microsoft.Extensions.AI.ChatClientStructuredOutputExtensions.GetResponseAsync(this Microsoft.Extensions.AI.IChatClient chatClient, System.Collections.Generic.IEnumerable messages, System.Text.Json.JsonSerializerOptions serializerOptions, Microsoft.Extensions.AI.ChatOptions? options = null, bool? useJsonSchemaResponseFormat = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.ChatResponse : Microsoft.Extensions.AI.ChatResponse", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ChatResponse.ChatResponse(Microsoft.Extensions.AI.ChatResponse response, System.Text.Json.JsonSerializerOptions serializerOptions);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.ChatResponse.TryGetResult(out T? result);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "T Microsoft.Extensions.AI.ChatResponse.Result { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ConfigureOptionsChatClient : Microsoft.Extensions.AI.DelegatingChatClient", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ConfigureOptionsChatClient.ConfigureOptionsChatClient(Microsoft.Extensions.AI.IChatClient innerClient, System.Action configure);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.ConfigureOptionsChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ConfigureOptionsChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ConfigureOptionsChatClientBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.ConfigureOptionsChatClientBuilderExtensions.ConfigureOptions(this Microsoft.Extensions.AI.ChatClientBuilder builder, System.Action configure);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ConfigureOptionsEmbeddingGenerator : Microsoft.Extensions.AI.DelegatingEmbeddingGenerator where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ConfigureOptionsEmbeddingGenerator.ConfigureOptionsEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator, System.Action configure);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task> Microsoft.Extensions.AI.ConfigureOptionsEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ConfigureOptionsEmbeddingGeneratorBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.ConfigureOptionsEmbeddingGeneratorBuilderExtensions.ConfigureOptions(this Microsoft.Extensions.AI.EmbeddingGeneratorBuilder builder, System.Action configure);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.ConfigureOptionsSpeechToTextClient : Microsoft.Extensions.AI.DelegatingSpeechToTextClient", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.ConfigureOptionsSpeechToTextClient.ConfigureOptionsSpeechToTextClient(Microsoft.Extensions.AI.ISpeechToTextClient innerClient, System.Action configure);", + "Stage": "Experimental" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ConfigureOptionsSpeechToTextClient.GetStreamingTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.ConfigureOptionsSpeechToTextClient.GetTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.ConfigureOptionsSpeechToTextClientBuilderExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.AI.ConfigureOptionsSpeechToTextClientBuilderExtensions.ConfigureOptions(this Microsoft.Extensions.AI.SpeechToTextClientBuilder builder, System.Action configure);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DistributedCachingChatClient : Microsoft.Extensions.AI.CachingChatClient", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DistributedCachingChatClient.DistributedCachingChatClient(Microsoft.Extensions.AI.IChatClient innerClient, Microsoft.Extensions.Caching.Distributed.IDistributedCache storage);", + "Stage": "Stable" + }, + { + // After generating the baseline, manually edit this file to have 'params' instead of 'scoped' + // This is needed until ICSharpCode.Decompiler adds params collection support + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Member": "override string Microsoft.Extensions.AI.DistributedCachingChatClient.GetCacheKey(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options, params System.ReadOnlySpan additionalValues);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.DistributedCachingChatClient.ReadCacheAsync(string key, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task?> Microsoft.Extensions.AI.DistributedCachingChatClient.ReadCacheStreamingAsync(string key, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.DistributedCachingChatClient.WriteCacheAsync(string key, Microsoft.Extensions.AI.ChatResponse value, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.DistributedCachingChatClient.WriteCacheStreamingAsync(string key, System.Collections.Generic.IReadOnlyList value, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList? Microsoft.Extensions.AI.DistributedCachingChatClient.CacheKeyAdditionalValues { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.DistributedCachingChatClient.JsonSerializerOptions { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.DistributedCachingChatClientBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.DistributedCachingChatClientBuilderExtensions.UseDistributedCache(this Microsoft.Extensions.AI.ChatClientBuilder builder, Microsoft.Extensions.Caching.Distributed.IDistributedCache? storage = null, System.Action? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator : Microsoft.Extensions.AI.CachingEmbeddingGenerator where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator.DistributedCachingEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator, Microsoft.Extensions.Caching.Distributed.IDistributedCache storage);", + "Stage": "Stable" + }, + { + // After generating the baseline, manually edit this file to have 'params' instead of 'scoped' + // This is needed until ICSharpCode.Decompiler adds params collection support + // See: https://github.com/icsharpcode/ILSpy/issues/829 + "Member": "override string Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator.GetCacheKey(params System.ReadOnlySpan values);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator.ReadCacheAsync(string key, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator.WriteCacheAsync(string key, TEmbedding value, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList? Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator.CacheKeyAdditionalValues { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.DistributedCachingEmbeddingGenerator.JsonSerializerOptions { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.DistributedCachingEmbeddingGeneratorBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.DistributedCachingEmbeddingGeneratorBuilderExtensions.UseDistributedCache(this Microsoft.Extensions.AI.EmbeddingGeneratorBuilder builder, Microsoft.Extensions.Caching.Distributed.IDistributedCache? storage = null, System.Action>? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.EmbeddingGeneratorBuilder where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.EmbeddingGeneratorBuilder.EmbeddingGeneratorBuilder(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.EmbeddingGeneratorBuilder.EmbeddingGeneratorBuilder(System.Func> innerGeneratorFactory);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.IEmbeddingGenerator Microsoft.Extensions.AI.EmbeddingGeneratorBuilder.Build(System.IServiceProvider? services = null);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.EmbeddingGeneratorBuilder.Use(System.Func, Microsoft.Extensions.AI.IEmbeddingGenerator> generatorFactory);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.EmbeddingGeneratorBuilder.Use(System.Func, System.IServiceProvider, Microsoft.Extensions.AI.IEmbeddingGenerator> generatorFactory);", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.EmbeddingGeneratorBuilder.Use(System.Func, Microsoft.Extensions.AI.EmbeddingGenerationOptions?, Microsoft.Extensions.AI.IEmbeddingGenerator, System.Threading.CancellationToken, System.Threading.Tasks.Task>>? generateFunc);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions.AsBuilder(this Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator);", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.DependencyInjection.EmbeddingGeneratorBuilderServiceCollectionExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.DependencyInjection.EmbeddingGeneratorBuilderServiceCollectionExtensions.AddEmbeddingGenerator(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.DependencyInjection.EmbeddingGeneratorBuilderServiceCollectionExtensions.AddEmbeddingGenerator(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Func> innerGeneratorFactory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.DependencyInjection.EmbeddingGeneratorBuilderServiceCollectionExtensions.AddKeyedEmbeddingGenerator(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, object? serviceKey, Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.DependencyInjection.EmbeddingGeneratorBuilderServiceCollectionExtensions.AddKeyedEmbeddingGenerator(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, object? serviceKey, System.Func> innerGeneratorFactory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.FunctionInvocationContext", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.FunctionInvocationContext.FunctionInvocationContext();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.AIFunctionArguments Microsoft.Extensions.AI.FunctionInvocationContext.Arguments { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.FunctionCallContent Microsoft.Extensions.AI.FunctionInvocationContext.CallContent { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.FunctionInvocationContext.Function { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.FunctionInvocationContext.FunctionCallIndex { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.FunctionInvocationContext.FunctionCount { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvocationContext.IsStreaming { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.FunctionInvocationContext.Iteration { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.FunctionInvocationContext.Messages { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.ChatOptions? Microsoft.Extensions.AI.FunctionInvocationContext.Options { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvocationContext.Terminate { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.FunctionInvokingChatClient : Microsoft.Extensions.AI.DelegatingChatClient", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvokingChatClient(Microsoft.Extensions.AI.IChatClient innerClient, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, System.IServiceProvider? functionInvocationServices = null);", + "Stage": "Stable" + }, + { + "Member": "virtual System.Collections.Generic.IList Microsoft.Extensions.AI.FunctionInvokingChatClient.CreateResponseMessages(System.ReadOnlySpan results);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.FunctionInvokingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.FunctionInvokingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "virtual System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.FunctionInvokingChatClient.InvokeFunctionAsync(Microsoft.Extensions.AI.FunctionInvocationContext context, System.Threading.CancellationToken cancellationToken);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.FunctionInvokingChatClient.AdditionalTools { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.AllowConcurrentInvocation { get; set; }", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.FunctionInvocationContext? Microsoft.Extensions.AI.FunctionInvokingChatClient.CurrentContext { get; protected set; }", + "Stage": "Stable" + }, + { + "Member": "System.IServiceProvider? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationServices { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Func>? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvoker { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.IncludeDetailedErrors { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.FunctionInvokingChatClient.MaximumConsecutiveErrorsPerRequest { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.AI.FunctionInvokingChatClient.MaximumIterationsPerRequest { get; set; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.TerminateOnUnknownCalls { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult", + "Stage": "Stable", + "Properties": [ + { + "Member": "Microsoft.Extensions.AI.FunctionCallContent Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult.CallContent { get; }", + "Stage": "Stable" + }, + { + "Member": "System.Exception? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult.Exception { get; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult.Result { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult.Status { get; }", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationResult.Terminate { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "enum Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus.FunctionInvocationStatus();", + "Stage": "Stable" + } + ], + "Fields": [ + { + "Member": "const Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus.Exception", + "Stage": "Stable", + "Value": "2" + }, + { + "Member": "const Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus.NotFound", + "Stage": "Stable", + "Value": "1" + }, + { + "Member": "const Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion", + "Stage": "Stable", + "Value": "0" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.FunctionInvokingChatClientBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.FunctionInvokingChatClientBuilderExtensions.UseFunctionInvocation(this Microsoft.Extensions.AI.ChatClientBuilder builder, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, System.Action? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.LoggingChatClient : Microsoft.Extensions.AI.DelegatingChatClient", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.LoggingChatClient.LoggingChatClient(Microsoft.Extensions.AI.IChatClient innerClient, Microsoft.Extensions.Logging.ILogger logger);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.LoggingChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.LoggingChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.LoggingChatClient.JsonSerializerOptions { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.LoggingChatClientBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.LoggingChatClientBuilderExtensions.UseLogging(this Microsoft.Extensions.AI.ChatClientBuilder builder, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, System.Action? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.LoggingEmbeddingGenerator : Microsoft.Extensions.AI.DelegatingEmbeddingGenerator where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.LoggingEmbeddingGenerator.LoggingEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator, Microsoft.Extensions.Logging.ILogger logger);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task> Microsoft.Extensions.AI.LoggingEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.LoggingEmbeddingGenerator.JsonSerializerOptions { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.LoggingEmbeddingGeneratorBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.LoggingEmbeddingGeneratorBuilderExtensions.UseLogging(this Microsoft.Extensions.AI.EmbeddingGeneratorBuilder builder, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, System.Action>? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.LoggingSpeechToTextClient : Microsoft.Extensions.AI.DelegatingSpeechToTextClient", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.LoggingSpeechToTextClient.LoggingSpeechToTextClient(Microsoft.Extensions.AI.ISpeechToTextClient innerClient, Microsoft.Extensions.Logging.ILogger logger);", + "Stage": "Experimental" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.LoggingSpeechToTextClient.GetStreamingTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.LoggingSpeechToTextClient.GetTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.LoggingSpeechToTextClient.JsonSerializerOptions { get; set; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.LoggingSpeechToTextClientBuilderExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.AI.LoggingSpeechToTextClientBuilderExtensions.UseLogging(this Microsoft.Extensions.AI.SpeechToTextClientBuilder builder, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, System.Action? configure = null);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.OpenTelemetryChatClient : Microsoft.Extensions.AI.DelegatingChatClient", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.OpenTelemetryChatClient.OpenTelemetryChatClient(Microsoft.Extensions.AI.IChatClient innerClient, Microsoft.Extensions.Logging.ILogger? logger = null, string? sourceName = null);", + "Stage": "Stable" + }, + { + "Member": "override void Microsoft.Extensions.AI.OpenTelemetryChatClient.Dispose(bool disposing);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task Microsoft.Extensions.AI.OpenTelemetryChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "override object? Microsoft.Extensions.AI.OpenTelemetryChatClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "override System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.OpenTelemetryChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.OpenTelemetryChatClient.EnableSensitiveData { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.OpenTelemetryChatClient.JsonSerializerOptions { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.OpenTelemetryChatClientBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.ChatClientBuilder Microsoft.Extensions.AI.OpenTelemetryChatClientBuilderExtensions.UseOpenTelemetry(this Microsoft.Extensions.AI.ChatClientBuilder builder, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, string? sourceName = null, System.Action? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.OpenTelemetryEmbeddingGenerator : Microsoft.Extensions.AI.DelegatingEmbeddingGenerator where TEmbedding : Microsoft.Extensions.AI.Embedding", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.OpenTelemetryEmbeddingGenerator.OpenTelemetryEmbeddingGenerator(Microsoft.Extensions.AI.IEmbeddingGenerator innerGenerator, Microsoft.Extensions.Logging.ILogger? logger = null, string? sourceName = null);", + "Stage": "Stable" + }, + { + "Member": "override void Microsoft.Extensions.AI.OpenTelemetryEmbeddingGenerator.Dispose(bool disposing);", + "Stage": "Stable" + }, + { + "Member": "override System.Threading.Tasks.Task> Microsoft.Extensions.AI.OpenTelemetryEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Stable" + }, + { + "Member": "override object? Microsoft.Extensions.AI.OpenTelemetryEmbeddingGenerator.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.OpenTelemetryEmbeddingGenerator.EnableSensitiveData { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.OpenTelemetryEmbeddingGeneratorBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.EmbeddingGeneratorBuilder Microsoft.Extensions.AI.OpenTelemetryEmbeddingGeneratorBuilderExtensions.UseOpenTelemetry(this Microsoft.Extensions.AI.EmbeddingGeneratorBuilder builder, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, string? sourceName = null, System.Action>? configure = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.SpeechToTextClientBuilder", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.SpeechToTextClientBuilder.SpeechToTextClientBuilder(Microsoft.Extensions.AI.ISpeechToTextClient innerClient);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextClientBuilder.SpeechToTextClientBuilder(System.Func innerClientFactory);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.ISpeechToTextClient Microsoft.Extensions.AI.SpeechToTextClientBuilder.Build(System.IServiceProvider? services = null);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.AI.SpeechToTextClientBuilder.Use(System.Func clientFactory);", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.AI.SpeechToTextClientBuilder.Use(System.Func clientFactory);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.DependencyInjection.SpeechToTextClientBuilderServiceCollectionExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.DependencyInjection.SpeechToTextClientBuilderServiceCollectionExtensions.AddKeyedSpeechToTextClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, object serviceKey, Microsoft.Extensions.AI.ISpeechToTextClient innerClient, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.DependencyInjection.SpeechToTextClientBuilderServiceCollectionExtensions.AddKeyedSpeechToTextClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, object serviceKey, System.Func innerClientFactory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.DependencyInjection.SpeechToTextClientBuilderServiceCollectionExtensions.AddSpeechToTextClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, Microsoft.Extensions.AI.ISpeechToTextClient innerClient, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Experimental" + }, + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.DependencyInjection.SpeechToTextClientBuilderServiceCollectionExtensions.AddSpeechToTextClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Func innerClientFactory, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton);", + "Stage": "Experimental" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.AI.SpeechToTextClientBuilderSpeechToTextClientExtensions", + "Stage": "Experimental", + "Methods": [ + { + "Member": "static Microsoft.Extensions.AI.SpeechToTextClientBuilder Microsoft.Extensions.AI.SpeechToTextClientBuilderSpeechToTextClientExtensions.AsBuilder(this Microsoft.Extensions.AI.ISpeechToTextClient innerClient);", + "Stage": "Experimental" + } + ] + } + ] +} diff --git a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs index c1a22066227..3def478d0e1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs +++ b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs @@ -3,7 +3,6 @@ namespace Microsoft.Extensions.AI; -#pragma warning disable CA1716 // Identifiers should not match keywords #pragma warning disable S4041 // Type names should not match namespaces /// Provides constants used by various telemetry services. @@ -14,10 +13,17 @@ internal static class OpenTelemetryConsts public const string SecondsUnit = "s"; public const string TokensUnit = "token"; - public static class Event - { - public const string Name = "event.name"; - } + /// Environment variable name for controlling whether sensitive content should be captured in telemetry by default. + public const string GenAICaptureMessageContentEnvVar = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"; + + public const string ToolTypeFunction = "function"; + + public const string TypeText = "text"; + public const string TypeJson = "json"; + public const string TypeImage = "image"; + + public const string TokenTypeInput = "input"; + public const string TokenTypeOutput = "output"; public static class Error { @@ -26,16 +32,13 @@ public static class Error public static class GenAI { - public const string Choice = "gen_ai.choice"; - public const string SystemName = "gen_ai.system"; - - public const string Chat = "chat"; - public const string Embeddings = "embeddings"; + public const string ChatName = "chat"; + public const string EmbeddingsName = "embeddings"; + public const string ExecuteToolName = "execute_tool"; + public const string OrchestrateToolsName = "orchestrate_tools"; // Non-standard + public const string GenerateContentName = "generate_content"; - public static class Assistant - { - public const string Message = "gen_ai.assistant.message"; - } + public const string SystemInstructions = "gen_ai.system_instructions"; public static class Client { @@ -54,6 +57,24 @@ public static class TokenUsage } } + public static class Conversation + { + public const string Id = "gen_ai.conversation.id"; + } + + public static class Embeddings + { + public static class Dimension + { + public const string Count = "gen_ai.embeddings.dimension.count"; + } + } + + public static class Input + { + public const string Messages = "gen_ai.input.messages"; + } + public static class Operation { public const string Name = "gen_ai.operation.name"; @@ -61,12 +82,18 @@ public static class Operation public static class Output { + public const string Messages = "gen_ai.output.messages"; public const string Type = "gen_ai.output.type"; } + public static class Provider + { + public const string Name = "gen_ai.provider.name"; + } + public static class Request { - public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions"; + public const string ChoiceCount = "gen_ai.request.choice.count"; public const string FrequencyPenalty = "gen_ai.request.frequency_penalty"; public const string Model = "gen_ai.request.model"; public const string MaxTokens = "gen_ai.request.max_tokens"; @@ -76,24 +103,13 @@ public static class Request public const string Temperature = "gen_ai.request.temperature"; public const string TopK = "gen_ai.request.top_k"; public const string TopP = "gen_ai.request.top_p"; - - public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.request.{parameterName}"; } public static class Response { public const string FinishReasons = "gen_ai.response.finish_reasons"; public const string Id = "gen_ai.response.id"; - public const string InputTokens = "gen_ai.response.input_tokens"; public const string Model = "gen_ai.response.model"; - public const string OutputTokens = "gen_ai.response.output_tokens"; - - public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}"; - } - - public static class System - { - public const string Message = "gen_ai.system.message"; } public static class Token @@ -106,16 +122,21 @@ public static class Tool public const string Name = "gen_ai.tool.name"; public const string Description = "gen_ai.tool.description"; public const string Message = "gen_ai.tool.message"; + public const string Type = "gen_ai.tool.type"; + public const string Definitions = "gen_ai.tool.definitions"; public static class Call { public const string Id = "gen_ai.tool.call.id"; + public const string Arguments = "gen_ai.tool.call.arguments"; + public const string Result = "gen_ai.tool.call.result"; } } - public static class User + public static class Usage { - public const string Message = "gen_ai.user.message"; + public const string InputTokens = "gen_ai.usage.input_tokens"; + public const string OutputTokens = "gen_ai.usage.output_tokens"; } } diff --git a/src/Libraries/Microsoft.Extensions.AI/README.md b/src/Libraries/Microsoft.Extensions.AI/README.md index ef092749200..746688e9599 100644 --- a/src/Libraries/Microsoft.Extensions.AI/README.md +++ b/src/Libraries/Microsoft.Extensions.AI/README.md @@ -1,6 +1,18 @@ # Microsoft.Extensions.AI -Provides utilities for working with generative AI components. +.NET developers need to integrate and interact with a growing variety of artificial intelligence (AI) services in their apps. The `Microsoft.Extensions.AI` libraries provide a unified approach for representing generative AI components, and enable seamless integration and interoperability with various AI services. + +## The packages + +The [Microsoft.Extensions.AI.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions) package provides the core exchange types, including [`IChatClient`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.ichatclient) and [`IEmbeddingGenerator`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.iembeddinggenerator-2). Any .NET library that provides an LLM client can implement the `IChatClient` interface to enable seamless integration with consuming code. + +The [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) package has an implicit dependency on the `Microsoft.Extensions.AI.Abstractions` package. This package enables you to easily integrate components such as automatic function tool invocation, telemetry, and caching into your applications using familiar dependency injection and middleware patterns. For example, it provides the [`UseOpenTelemetry(ChatClientBuilder, ILoggerFactory, String, Action)`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.opentelemetrychatclientbuilderextensions.useopentelemetry#microsoft-extensions-ai-opentelemetrychatclientbuilderextensions-useopentelemetry(microsoft-extensions-ai-chatclientbuilder-microsoft-extensions-logging-iloggerfactory-system-string-system-action((microsoft-extensions-ai-opentelemetrychatclient)))) extension method, which adds OpenTelemetry support to the chat client pipeline. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.AI.Abstractions`. + +To also have access to higher-level utilities for working with generative AI components, reference the `Microsoft.Extensions.AI` package instead (which itself references `Microsoft.Extensions.AI.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.AI` package along with one or more libraries that provide concrete implementations of the abstractions. ## Install the package @@ -18,9 +30,9 @@ Or directly in the C# project file: ``` -## Usage Examples +## Documentation -Please refer to the [README](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions/#readme-body-tab) for the [Microsoft.Extensions.AI.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions) package. +Refer to the [Microsoft.Extensions.AI libraries documentation](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) for more information and API usage examples. ## Feedback & Contributing diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs index 037d25a14d5..f9f492b2635 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs @@ -5,8 +5,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Shared.Diagnostics; -#pragma warning disable SA1629 // Documentation text should end with a period - namespace Microsoft.Extensions.AI; /// Provides extensions for configuring instances. diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs index e7bf7850a94..10e499a7e57 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs @@ -179,7 +179,7 @@ public override async IAsyncEnumerable GetStreamingT } } - private string AsJson(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions); + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] private partial void LogInvoked(string methodName); diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs index 92a67189982..54ed411bd35 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public static class LoggingSpeechToTextClientBuilderExtensions { - /// Adds logging to the audio transcription client pipeline. + /// Adds logging to the speech-to-text client pipeline. /// The . /// /// An optional used to create a logger with which logging should be performed. diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs new file mode 100644 index 00000000000..3b0688ba585 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs @@ -0,0 +1,363 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating speech-to-text client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +[Experimental("MEAI001")] +public sealed class OpenTelemetrySpeechToTextClient : DelegatingSpeechToTextClient +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _defaultModelId; + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with IChatClient and future use + public OpenTelemetrySpeechToTextClient(ISpeechToTextClient innerClient, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 + : base(innerClient) + { + Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + + if (innerClient!.GetService() is SpeechToTextClientMetadata metadata) + { + _defaultModelId = metadata.DefaultModelId; + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } + ); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + /// + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + + /// + public override async Task GetTextAsync(Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(audioSpeechStream); + + using Activity? activity = CreateAndConfigureActivity(options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + SpeechToTextResponse? response = null; + Exception? error = null; + try + { + response = await base.GetTextAsync(audioSpeechStream, options, cancellationToken); + return response; + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, requestModelId, response, error, stopwatch); + } + } + + /// + public override async IAsyncEnumerable GetStreamingTextAsync( + Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(audioSpeechStream); + + using Activity? activity = CreateAndConfigureActivity(options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + IAsyncEnumerable updates; + try + { + updates = base.GetStreamingTextAsync(audioSpeechStream, options, cancellationToken); + } + catch (Exception ex) + { + TraceResponse(activity, requestModelId, response: null, ex, stopwatch); + throw; + } + + var responseEnumerator = updates.GetAsyncEnumerator(cancellationToken); + List trackedUpdates = []; + Exception? error = null; + try + { + while (true) + { + SpeechToTextResponseUpdate update; + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + + update = responseEnumerator.Current; + } + catch (Exception ex) + { + error = ex; + throw; + } + + trackedUpdates.Add(update); + yield return update; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + } + finally + { + TraceResponse(activity, requestModelId, trackedUpdates.ToSpeechToTextResponse(), error, stopwatch); + + await responseEnumerator.DisposeAsync(); + } + } + + /// Creates an activity for a speech-to-text request, or returns if not enabled. + private Activity? CreateAndConfigureActivity(SpeechToTextOptions? options) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + string? modelId = options?.ModelId ?? _defaultModelId; + + activity = _activitySource.StartActivity( + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.GenerateContentName : $"{OpenTelemetryConsts.GenAI.GenerateContentName} {modelId}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName) + .AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeText); + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (options is not null) + { + if (EnableSensitiveData) + { + // Log all additional request options as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (options.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + } + } + + return activity; + } + + /// Adds speech-to-text response information to the activity. + private void TraceResponse( + Activity? activity, + string? requestModelId, + SpeechToTextResponse? response, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + + AddMetricTags(ref tags, requestModelId, response); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (_tokenUsageHistogram.Enabled && response?.Usage is { } usage) + { + if (usage.InputTokenCount is long inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); + AddMetricTags(ref tags, requestModelId, response); + _tokenUsageHistogram.Record((int)inputTokens, tags); + } + + if (usage.OutputTokenCount is long outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); + AddMetricTags(ref tags, requestModelId, response); + _tokenUsageHistogram.Record((int)outputTokens, tags); + } + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + } + + if (response is not null) + { + AddOutputMessagesTags(response, activity); + + if (activity is not null) + { + if (!string.IsNullOrWhiteSpace(response.ResponseId)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId); + } + + if (response.ModelId is not null) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, response.ModelId); + } + + if (response.Usage?.InputTokenCount is long inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); + } + + if (response.Usage?.OutputTokenCount is long outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); + } + + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && response.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + + void AddMetricTags(ref TagList tags, string? requestModelId, SpeechToTextResponse? response) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (response?.ModelId is string responseModel) + { + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); + } + } + } + + private void AddOutputMessagesTags(SpeechToTextResponse response, Activity? activity) + { + if (EnableSensitiveData && activity is { IsAllDataRequested: true }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Output.Messages, + OpenTelemetryChatClient.SerializeChatMessages([new(ChatRole.Assistant, response.Contents)])); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClientBuilderExtensions.cs new file mode 100644 index 00000000000..5e23a41358e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClientBuilderExtensions.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class OpenTelemetrySpeechToTextClientBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the speech-to-text client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static SpeechToTextClientBuilder UseOpenTelemetry( + this SpeechToTextClientBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerClient, services) => + { + loggerFactory ??= services.GetService(); + + var client = new OpenTelemetrySpeechToTextClient(innerClient, loggerFactory?.CreateLogger(typeof(OpenTelemetrySpeechToTextClient)), sourceName); + configure?.Invoke(client); + + return client; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs index dae4224a94d..1945a140762 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs @@ -58,7 +58,7 @@ public ISpeechToTextClient Build(IServiceProvider? services = null) return audioClient; } - /// Adds a factory for an intermediate audio transcription client to the audio transcription client pipeline. + /// Adds a factory for an intermediate speech-to-text client to the speech-to-text client pipeline. /// The client factory function. /// The updated instance. public SpeechToTextClientBuilder Use(Func clientFactory) @@ -68,7 +68,7 @@ public SpeechToTextClientBuilder Use(Func clientFactory(innerClient)); } - /// Adds a factory for an intermediate audio transcription client to the audio transcription client pipeline. + /// Adds a factory for an intermediate speech-to-text client to the speech-to-text client pipeline. /// The client factory function. /// The updated instance. public SpeechToTextClientBuilder Use(Func clientFactory) diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs index 5ef54e8db26..243cb057068 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs @@ -52,7 +52,7 @@ public static SpeechToTextClientBuilder AddSpeechToTextClient( /// The client is registered as a scoped service. public static SpeechToTextClientBuilder AddKeyedSpeechToTextClient( this IServiceCollection serviceCollection, - object serviceKey, + object? serviceKey, ISpeechToTextClient innerClient, ServiceLifetime lifetime = ServiceLifetime.Singleton) => AddKeyedSpeechToTextClient(serviceCollection, serviceKey, _ => innerClient, lifetime); @@ -66,12 +66,11 @@ public static SpeechToTextClientBuilder AddKeyedSpeechToTextClient( /// The client is registered as a scoped service. public static SpeechToTextClientBuilder AddKeyedSpeechToTextClient( this IServiceCollection serviceCollection, - object serviceKey, + object? serviceKey, Func innerClientFactory, ServiceLifetime lifetime = ServiceLifetime.Singleton) { _ = Throw.IfNull(serviceCollection); - _ = Throw.IfNull(serviceKey); _ = Throw.IfNull(innerClientFactory); var builder = new SpeechToTextClientBuilder(innerClientFactory); diff --git a/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/TelemetryHelpers.cs similarity index 55% rename from src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs rename to src/Libraries/Microsoft.Extensions.AI/TelemetryHelpers.cs index 72a7e283988..46a9b862e5b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI/TelemetryHelpers.cs @@ -1,17 +1,20 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable CA1031 // Do not catch general exception types -#pragma warning disable S108 // Nested blocks of code should not be left empty -#pragma warning disable S2486 // Generic exceptions should not be ignored - +using System; using System.Text.Json; namespace Microsoft.Extensions.AI; -/// Provides internal helpers for implementing logging. -internal static class LoggingHelpers +/// Provides internal helpers for implementing telemetry. +internal static class TelemetryHelpers { + /// Gets a value indicating whether the OpenTelemetry clients should enable their EnableSensitiveData property's by default. + /// Defaults to false. May be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT environment variable to "true". + public static bool EnableSensitiveDataDefault { get; } = + Environment.GetEnvironmentVariable(OpenTelemetryConsts.GenAICaptureMessageContentEnvVar) is string envVar && + string.Equals(envVar, "true", StringComparison.OrdinalIgnoreCase); + /// Serializes as JSON for logging purposes. public static string AsJson(T value, JsonSerializerOptions? options) { @@ -24,6 +27,7 @@ public static string AsJson(T value, JsonSerializerOptions? options) } catch { + // If we fail to serialize, just fall through to returning "{}". } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ChatClientBuilderToolReductionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ChatClientBuilderToolReductionExtensions.cs new file mode 100644 index 00000000000..5a644267328 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ChatClientBuilderToolReductionExtensions.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Extension methods for adding tool reduction middleware to a chat client pipeline. +[Experimental("MEAI001")] +public static class ChatClientBuilderToolReductionExtensions +{ + /// + /// Adds tool reduction to the chat client pipeline using the specified . + /// + /// The chat client builder. + /// The reduction strategy. + /// The original builder for chaining. + /// If or is . + /// + /// This should typically appear in the pipeline before function invocation middleware so that only the reduced tools + /// are exposed to the underlying provider. + /// + public static ChatClientBuilder UseToolReduction(this ChatClientBuilder builder, IToolReductionStrategy strategy) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(strategy); + + return builder.Use(inner => new ToolReducingChatClient(inner, strategy)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ToolReduction/EmbeddingToolReductionStrategy.cs b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/EmbeddingToolReductionStrategy.cs new file mode 100644 index 00000000000..f9e4c60995a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/EmbeddingToolReductionStrategy.cs @@ -0,0 +1,330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Numerics.Tensors; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +#pragma warning disable IDE0032 // Use auto property, suppressed until repo updates to C# 14 + +/// +/// A tool reduction strategy that ranks tools by embedding similarity to the current conversation context. +/// +/// +/// The strategy embeds each tool (name + description by default) once (cached) and embeds the current +/// conversation content each request. It then selects the top toolLimit tools by similarity. +/// +[Experimental("MEAI001")] +public sealed class EmbeddingToolReductionStrategy : IToolReductionStrategy +{ + private readonly ConditionalWeakTable> _toolEmbeddingsCache = new(); + private readonly IEmbeddingGenerator> _embeddingGenerator; + private readonly int _toolLimit; + + private Func _toolEmbeddingTextSelector = static t => + { + if (string.IsNullOrWhiteSpace(t.Name)) + { + return t.Description; + } + + if (string.IsNullOrWhiteSpace(t.Description)) + { + return t.Name; + } + + return t.Name + Environment.NewLine + t.Description; + }; + + private Func, ValueTask> _messagesEmbeddingTextSelector = static messages => + { + var sb = new StringBuilder(); + foreach (var message in messages) + { + var contents = message.Contents; + for (var i = 0; i < contents.Count; i++) + { + string text; + switch (contents[i]) + { + case TextContent content: + text = content.Text; + break; + case TextReasoningContent content: + text = content.Text; + break; + default: + continue; + } + + _ = sb.AppendLine(text); + } + } + + return new ValueTask(sb.ToString()); + }; + + private Func, ReadOnlyMemory, float> _similarity = static (a, b) => TensorPrimitives.CosineSimilarity(a.Span, b.Span); + + private Func _isRequiredTool = static _ => false; + + /// + /// Initializes a new instance of the class. + /// + /// Embedding generator used to produce embeddings. + /// Maximum number of tools to return, excluding required tools. Must be greater than zero. + public EmbeddingToolReductionStrategy( + IEmbeddingGenerator> embeddingGenerator, + int toolLimit) + { + _embeddingGenerator = Throw.IfNull(embeddingGenerator); + _toolLimit = Throw.IfLessThanOrEqual(toolLimit, min: 0); + } + + /// + /// Gets or sets the selector used to generate a single text string from a tool. + /// + /// + /// Defaults to: Name + "\n" + Description (omitting empty parts). + /// + public Func ToolEmbeddingTextSelector + { + get => _toolEmbeddingTextSelector; + set => _toolEmbeddingTextSelector = Throw.IfNull(value); + } + + /// + /// Gets or sets the selector used to generate a single text string from a collection of chat messages for + /// embedding purposes. + /// + public Func, ValueTask> MessagesEmbeddingTextSelector + { + get => _messagesEmbeddingTextSelector; + set => _messagesEmbeddingTextSelector = Throw.IfNull(value); + } + + /// + /// Gets or sets a similarity function applied to (query, tool) embedding vectors. + /// + /// + /// Defaults to cosine similarity. + /// + public Func, ReadOnlyMemory, float> Similarity + { + get => _similarity; + set => _similarity = Throw.IfNull(value); + } + + /// + /// Gets or sets a function that determines whether a tool is required (always included). + /// + /// + /// If this returns , the tool is included regardless of ranking and does not count against + /// the configured non-required tool limit. A tool explicitly named by (when + /// is non-null) is also treated as required, independent + /// of this delegate's result. + /// + public Func IsRequiredTool + { + get => _isRequiredTool; + set => _isRequiredTool = Throw.IfNull(value); + } + + /// + /// Gets or sets a value indicating whether to preserve original ordering of selected tools. + /// If (default), tools are ordered by descending similarity. + /// If , the top-N tools by similarity are re-emitted in their original order. + /// + public bool PreserveOriginalOrdering { get; set; } + + /// + public async Task> SelectToolsForRequestAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + if (options?.Tools is not { Count: > 0 } tools) + { + // Prefer the original tools list reference if possible. + // This allows ToolReducingChatClient to avoid unnecessarily copying ChatOptions. + // When no reduction is performed. + return options?.Tools ?? []; + } + + Debug.Assert(_toolLimit > 0, "Expected the tool count limit to be greater than zero."); + + if (tools.Count <= _toolLimit) + { + // Since the total number of tools doesn't exceed the configured tool limit, + // there's no need to determine which tools are optional, i.e., subject to reduction. + // We can return the original tools list early. + return tools; + } + + var toolRankingInfoArray = ArrayPool.Shared.Rent(tools.Count); + try + { + var toolRankingInfoMemory = toolRankingInfoArray.AsMemory(start: 0, length: tools.Count); + + // We allocate tool rankings in a contiguous chunk of memory, but partition them such that + // required tools come first and are immediately followed by optional tools. + // This allows us to separately rank optional tools by similarity score, but then later re-order + // the top N tools (including required tools) to preserve their original relative order. + var (requiredTools, optionalTools) = PartitionToolRankings(toolRankingInfoMemory, tools, options.ToolMode); + + if (optionalTools.Length <= _toolLimit) + { + // There aren't enough optional tools to require reduction, so we'll return the original + // tools list. + return tools; + } + + // Build query text from recent messages. + var queryText = await MessagesEmbeddingTextSelector(messages).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(queryText)) + { + // We couldn't build a meaningful query, likely because the message list was empty. + // We'll just return the original tools list. + return tools; + } + + var queryEmbedding = await _embeddingGenerator.GenerateAsync(queryText, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Compute and populate similarity scores in the tool ranking info. + await ComputeSimilarityScoresAsync(optionalTools, queryEmbedding, cancellationToken); + + var topTools = toolRankingInfoMemory.Slice(start: 0, length: requiredTools.Length + _toolLimit); +#if NET + optionalTools.Span.Sort(AIToolRankingInfo.CompareByDescendingSimilarityScore); + if (PreserveOriginalOrdering) + { + topTools.Span.Sort(AIToolRankingInfo.CompareByOriginalIndex); + } +#else + Array.Sort(toolRankingInfoArray, index: requiredTools.Length, length: optionalTools.Length, AIToolRankingInfo.CompareByDescendingSimilarityScore); + if (PreserveOriginalOrdering) + { + Array.Sort(toolRankingInfoArray, index: 0, length: topTools.Length, AIToolRankingInfo.CompareByOriginalIndex); + } +#endif + return ToToolList(topTools.Span); + + static List ToToolList(ReadOnlySpan toolInfo) + { + var result = new List(capacity: toolInfo.Length); + foreach (var info in toolInfo) + { + result.Add(info.Tool); + } + + return result; + } + } + finally + { + ArrayPool.Shared.Return(toolRankingInfoArray); + } + } + + private (Memory RequiredTools, Memory OptionalTools) PartitionToolRankings( + Memory toolRankingInfo, IList tools, ChatToolMode? toolMode) + { + // Always include a tool if its name matches the required function name. + var requiredFunctionName = (toolMode as RequiredChatToolMode)?.RequiredFunctionName; + var nextRequiredToolIndex = 0; + var nextOptionalToolIndex = tools.Count - 1; + for (var i = 0; i < toolRankingInfo.Length; i++) + { + var tool = tools[i]; + var isRequiredByToolMode = requiredFunctionName is not null && string.Equals(requiredFunctionName, tool.Name, StringComparison.Ordinal); + var toolIndex = isRequiredByToolMode || IsRequiredTool(tool) + ? nextRequiredToolIndex++ + : nextOptionalToolIndex--; + toolRankingInfo.Span[toolIndex] = new AIToolRankingInfo(tool, originalIndex: i); + } + + return ( + RequiredTools: toolRankingInfo.Slice(0, nextRequiredToolIndex), + OptionalTools: toolRankingInfo.Slice(nextRequiredToolIndex)); + } + + private async Task ComputeSimilarityScoresAsync(Memory toolInfo, Embedding queryEmbedding, CancellationToken cancellationToken) + { + var anyCacheMisses = false; + List cacheMissToolEmbeddingTexts = null!; + List cacheMissToolInfoIndexes = null!; + for (var i = 0; i < toolInfo.Length; i++) + { + ref var info = ref toolInfo.Span[i]; + if (_toolEmbeddingsCache.TryGetValue(info.Tool, out var toolEmbedding)) + { + info.SimilarityScore = Similarity(queryEmbedding.Vector, toolEmbedding.Vector); + } + else + { + if (!anyCacheMisses) + { + anyCacheMisses = true; + cacheMissToolEmbeddingTexts = []; + cacheMissToolInfoIndexes = []; + } + + var text = ToolEmbeddingTextSelector(info.Tool); + cacheMissToolEmbeddingTexts.Add(text); + cacheMissToolInfoIndexes.Add(i); + } + } + + if (!anyCacheMisses) + { + // There were no cache misses; no more work to do. + return; + } + + var uncachedEmbeddings = await _embeddingGenerator.GenerateAsync(cacheMissToolEmbeddingTexts, cancellationToken: cancellationToken).ConfigureAwait(false); + if (uncachedEmbeddings.Count != cacheMissToolEmbeddingTexts.Count) + { + throw new InvalidOperationException($"Expected {cacheMissToolEmbeddingTexts.Count} embeddings, got {uncachedEmbeddings.Count}."); + } + + for (var i = 0; i < uncachedEmbeddings.Count; i++) + { + var toolInfoIndex = cacheMissToolInfoIndexes[i]; + var toolEmbedding = uncachedEmbeddings[i]; + ref var info = ref toolInfo.Span[toolInfoIndex]; + info.SimilarityScore = Similarity(queryEmbedding.Vector, toolEmbedding.Vector); + _toolEmbeddingsCache.Add(info.Tool, toolEmbedding); + } + } + + private struct AIToolRankingInfo(AITool tool, int originalIndex) + { + public static readonly Comparer CompareByDescendingSimilarityScore + = Comparer.Create(static (a, b) => + { + var result = b.SimilarityScore.CompareTo(a.SimilarityScore); + return result != 0 + ? result + : a.OriginalIndex.CompareTo(b.OriginalIndex); // Stabilize ties. + }); + + public static readonly Comparer CompareByOriginalIndex + = Comparer.Create(static (a, b) => a.OriginalIndex.CompareTo(b.OriginalIndex)); + + public AITool Tool { get; } = tool; + public int OriginalIndex { get; } = originalIndex; + public float SimilarityScore { get; set; } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ToolReducingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ToolReducingChatClient.cs new file mode 100644 index 00000000000..6a5d6d925fc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ToolReducingChatClient.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that applies a tool reduction strategy before invoking the inner client. +/// +/// +/// Insert this into a pipeline (typically before function invocation middleware) to automatically +/// reduce the tool list carried on for each request. +/// +[Experimental("MEAI001")] +public sealed class ToolReducingChatClient : DelegatingChatClient +{ + private readonly IToolReductionStrategy _strategy; + + /// + /// Initializes a new instance of the class. + /// + /// The inner client. + /// The tool reduction strategy to apply. + /// Thrown if any argument is . + public ToolReducingChatClient(IChatClient innerClient, IToolReductionStrategy strategy) + : base(innerClient) + { + _strategy = Throw.IfNull(strategy); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + options = await ApplyReductionAsync(messages, options, cancellationToken).ConfigureAwait(false); + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + options = await ApplyReductionAsync(messages, options, cancellationToken).ConfigureAwait(false); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private async Task ApplyReductionAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + // If there are no options or no tools, skip. + if (options?.Tools is not { Count: > 0 }) + { + return options; + } + + var reduced = await _strategy.SelectToolsForRequestAsync(messages, options, cancellationToken).ConfigureAwait(false); + + // If strategy returned the same list instance (or reference equality), assume no change. + if (ReferenceEquals(reduced, options.Tools)) + { + return options; + } + + // Materialize and compare counts; if unchanged and tools have identical ordering and references, keep original. + if (reduced is not IList reducedList) + { + reducedList = reduced.ToList(); + } + + // Clone options to avoid mutating a possibly shared instance. + var cloned = options.Clone(); + cloned.Tools = reducedList; + return cloned; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs index 4dfacb812de..c3b303a5a0e 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs @@ -20,8 +20,8 @@ public static class ApplicationMetadataConfigurationBuilderExtensions /// /// The configuration builder. /// An instance of . - /// Section name to save configuration into. Default set to "ambientmetadata:application". - /// The value of >. + /// The section name to save configuration into. The default is "ambientmetadata:application". + /// The value of . /// or is . /// is either , empty, or whitespace. public static IConfigurationBuilder AddApplicationMetadata(this IConfigurationBuilder builder, IHostEnvironment hostEnvironment, string sectionName = DefaultSectionName) diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs index e2635bbcc4d..2c7054c4b11 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs @@ -32,4 +32,25 @@ public static IHostBuilder UseApplicationMetadata(this IHostBuilder builder, str .ConfigureAppConfiguration((hostBuilderContext, configurationBuilder) => configurationBuilder.AddApplicationMetadata(hostBuilderContext.HostingEnvironment, sectionName)) .ConfigureServices((hostBuilderContext, serviceCollection) => serviceCollection.AddApplicationMetadata(hostBuilderContext.Configuration.GetSection(sectionName))); } + + /// + /// Registers a configuration provider for application metadata and binds a model object onto the configuration. + /// + /// . + /// The host builder. + /// Section name to bind configuration from. Default set to "ambientmetadata:application". + /// The value of . + /// is . + /// is either , empty, or whitespace. + public static TBuilder UseApplicationMetadata(this TBuilder builder, string sectionName = DefaultSectionName) + where TBuilder : IHostApplicationBuilder + { + _ = Throw.IfNull(builder); + _ = Throw.IfNullOrWhitespace(sectionName); + + _ = builder.Configuration.AddApplicationMetadata(builder.Environment, sectionName); + _ = builder.Services.AddApplicationMetadata(builder.Configuration.GetSection(sectionName)); + + return builder; + } } diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs index bc08c2a60e9..1e84e50c4f1 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs @@ -35,7 +35,7 @@ public static IServiceCollection AddApplicationMetadata(this IServiceCollection /// /// The dependency injection container to add the instance to. /// The delegate to configure with. - /// The value of >. + /// The value of . /// or is . public static IServiceCollection AddApplicationMetadata(this IServiceCollection services, Action configure) { diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj index f631a4047bb..86f07dc205f 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj @@ -1,6 +1,7 @@ Microsoft.Extensions.AmbientMetadata + $(NetCoreTargetFrameworks);netstandard2.0;net462 Runtime information provider for application-level ambient metadata. Telemetry diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json index 35db568194a..ecdcf8916fa 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.AmbientMetadata.Application, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.AmbientMetadata.Application, Version=9.10.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "class Microsoft.Extensions.AmbientMetadata.ApplicationMetadata", @@ -46,6 +46,10 @@ { "Member": "static Microsoft.Extensions.Hosting.IHostBuilder Microsoft.Extensions.Hosting.ApplicationMetadataHostBuilderExtensions.UseApplicationMetadata(this Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = \"ambientmetadata:application\");", "Stage": "Stable" + }, + { + "Member": "static TBuilder Microsoft.Extensions.Hosting.ApplicationMetadataHostBuilderExtensions.UseApplicationMetadata(this TBuilder builder, string sectionName = \"ambientmetadata:application\");", + "Stage": "Stable" } ] }, diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md index 45cba75f480..5ef2e71c271 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md @@ -26,6 +26,7 @@ The services can be registered using any of the following methods: ```csharp public static IHostBuilder UseApplicationMetadata(this IHostBuilder builder, string sectionName = DefaultSectionName) +public static TBuilder UseApplicationMetadata(this TBuilder builder, string sectionName = DefaultSectionName) where TBuilder : IHostApplicationBuilder public static IServiceCollection AddApplicationMetadata(this IServiceCollection services, Action configure) ``` diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs b/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs index d338447ed1d..68ddcfe1c66 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs @@ -52,9 +52,7 @@ public AsyncStateToken RegisterAsyncContext() public bool TryGet(AsyncStateToken token, out object? value) { // Context is not initialized -#pragma warning disable EA0011 if (_asyncContextCurrent.Value?.Features == null) -#pragma warning restore EA0011 { value = null; return false; @@ -79,9 +77,7 @@ public bool TryGet(AsyncStateToken token, out object? value) public void Set(AsyncStateToken token, object? value) { // Context is not initialized -#pragma warning disable EA0011 if (_asyncContextCurrent.Value?.Features == null) -#pragma warning restore EA0011 { Throw.InvalidOperationException("Context is not initialized"); } diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs index aef151f3016..f10827b1954 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs @@ -8,10 +8,9 @@ namespace Microsoft.Extensions.AsyncState; /// /// Provides access to the current async context. -/// Some implementations of this interface may not be thread safe. +/// Some implementations of this interface might not be thread safe. /// /// The type of the asynchronous state. -[SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Getter and setter throw exceptions.")] public interface IAsyncContext where T : notnull { diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs index 9873375a78b..ed33f8afeb2 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs @@ -7,7 +7,7 @@ namespace Microsoft.Extensions.AsyncState; /// /// Provides access to the current async context stored outside of the HTTP pipeline. -/// Some implementations of this interface may not be thread safe. +/// Some implementations of this interface might not be thread safe. /// /// The type of the asynchronous state. /// This type is intended for internal use. Use instead. diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs index ec15b72c2c9..4bf4c748146 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs @@ -9,9 +9,8 @@ namespace Microsoft.Extensions.AsyncState; /// /// Encapsulates all information within the asynchronous flow in an variable. -/// Some implementations of this interface may not be thread safe. +/// Some implementations of this interface might not be thread safe. /// -[SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Getter and setter throw exceptions.")] public interface IAsyncState { /// @@ -55,5 +54,5 @@ public interface IAsyncState /// Registers new async context with the state. /// /// Token that gives access to the reserved context. - public AsyncStateToken RegisterAsyncContext(); + AsyncStateToken RegisterAsyncContext(); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs index 7b87755da7b..d55ac1a4ea1 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs @@ -57,4 +57,9 @@ public class HybridCacheOptions /// should not be visible in metrics systems. /// public bool ReportTagMetrics { get; set; } + + /// + /// Gets or sets the key used to resolve the distributed cache service from the . + /// + public object? DistributedCacheServiceKey { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs index d28dc4e47d5..060307026d6 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DependencyInjection; @@ -17,28 +18,111 @@ public static class HybridCacheServiceExtensions /// /// Adds support for multi-tier caching services. /// - /// A builder instance that allows further configuration of the system. + /// The to add the service to. + /// A delegate to run to configure the instance. + /// A builder instance that allows further configuration of the service. public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services, Action setupAction) { _ = Throw.IfNull(setupAction); - _ = AddHybridCache(services); + + var builder = AddHybridCache(services); _ = services.Configure(setupAction); - return new HybridCacheBuilder(services); + + return builder; } /// /// Adds support for multi-tier caching services. /// - /// A builder instance that allows further configuration of the system. + /// The to add the service to. + /// A builder instance that allows further configuration of the service. public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services) { _ = Throw.IfNull(services); + var builder = PrepareServices(services); + + services.TryAddSingleton(); + + return builder; + } + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// A delegate to run to configure the instance. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey, Action setupAction) => + AddKeyedHybridCache(services, serviceKey, serviceKey?.ToString() ?? Options.Options.DefaultName, setupAction); + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// The named options name to use for the instance. + /// A delegate to run to configure the instance. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey, string optionsName, Action setupAction) + { + _ = Throw.IfNull(setupAction); + + var builder = AddKeyedHybridCache(services, serviceKey, optionsName); + _ = services.AddOptions(optionsName).Configure(setupAction); + + return builder; + } + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey) => + AddKeyedHybridCache(services, serviceKey, serviceKey?.ToString() ?? Options.Options.DefaultName); + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// The named options name to use for the instance. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey, string optionsName) + { + _ = Throw.IfNull(optionsName); + + var builder = PrepareServices(services); + _ = services.AddOptions(optionsName); + + _ = services.AddKeyedSingleton(serviceKey, (sp, key) => + { + var optionsService = sp.GetRequiredService>(); + var options = optionsService.Get(optionsName); + + return new DefaultHybridCache(options, sp); + }); + + return builder; + } + + /// + /// Adds the services required for hybrid caching. + /// + /// The to prepare with prerequisites. + /// A builder instance that allows further configuration of the service. + private static HybridCacheBuilder PrepareServices(IServiceCollection services) + { + _ = Throw.IfNull(services); + services.TryAddSingleton(TimeProvider.System); _ = services.AddOptions().AddMemoryCache(); services.TryAddSingleton(); services.TryAddSingleton>(InbuiltTypeSerializer.Instance); services.TryAddSingleton>(InbuiltTypeSerializer.Instance); - services.TryAddSingleton(); + return new HybridCacheBuilder(services); } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs index d7a12d9678d..4293d54bc30 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -21,8 +21,6 @@ internal partial class DefaultHybridCache private static readonly DistributedCacheEntryOptions _tagInvalidationEntryOptions = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(MaxCacheDays) }; [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Manual sync check")] - [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual sync check")] - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Explicit async exception handling")] [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Deliberate recycle only on success")] internal ValueTask GetFromL2DirectAsync(string key, CancellationToken token) { @@ -134,7 +132,6 @@ static async ValueTask AwaitedAsync(ValueTask pending, byte[] oversized) } [SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Cancellation handled internally")] - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "All failure is critical")] internal async Task SafeReadTagInvalidationAsync(string tag) { Debug.Assert(HasBackendCache, "shouldn't be here without L2"); @@ -258,9 +255,6 @@ private void ThrowPayloadLengthExceeded(int size) // splitting the exception bit throw new InvalidOperationException($"Maximum cache length ({MaximumPayloadBytes} bytes) exceeded"); } -#if NET8_0_OR_GREATER - [SuppressMessage("Maintainability", "CA1508:Avoid dead conditional code", Justification = "False positive from unsafe accessor")] -#endif private DistributedCacheEntryOptions GetL2DistributedCacheOptions(HybridCacheEntryOptions? options) { DistributedCacheEntryOptions? result = null; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs index a27417c385e..bcb997702ee 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.Serialization.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; @@ -52,7 +51,6 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi } } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentional for logged failure mode")] private bool TrySerialize(T value, out BufferChunk buffer, out IHybridCacheSerializer? serializer) { // note: also returns the serializer we resolved, because most-any time we want to serialize, we'll also want diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs index 6dca4a1afe0..34a68ef30aa 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -79,7 +79,6 @@ public Task ExecuteDirectAsync(in TState state, Func _result?.TrySetCanceled(SharedToken); - [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Custom task management")] public ValueTask JoinAsync(ILogger log, CancellationToken token) { // If the underlying has already completed, and/or our local token can't cancel: we @@ -120,7 +119,6 @@ static async ValueTask WithCancellationAsync(ILogger log, StampedeState> Task { get @@ -135,8 +133,6 @@ static Task> InvalidAsync() => System.Threading.Tasks.Task.FromExce [SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "No cancellable operation")] [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Checked manual unwrap")] - [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Checked manual unwrap")] - [SuppressMessage("Major Code Smell", "S1121:Assignments should not be made from within sub-expressions", Justification = "Unusual, but legit here")] internal ValueTask UnwrapReservedAsync(ILogger log) { Task> task = Task; @@ -162,8 +158,6 @@ static async Task AwaitedAsync(ILogger log, Task> task) private static CacheItem ThrowUnexpectedCacheItem() => throw new InvalidOperationException("Unexpected cache item"); [SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "In this case the cancellation token is provided internally via SharedToken")] - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exception is passed through to faulted task result")] - [SuppressMessage("Reliability", "EA0002:Use 'System.TimeProvider' to make the code easier to test", Justification = "Does not apply")] private async Task BackgroundFetchAsync() { bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); @@ -544,7 +538,6 @@ private void SetResult(CacheItem value, TimeSpan maxRelativeTime) } } - [SuppressMessage("Major Code Smell", "S1121:Assignments should not be made from within sub-expressions", Justification = "Reasonable in this case, due to stack alloc scope.")] private static bool ValidateUnicodeCorrectness(ILogger logger, string key, TagSet tags) { int maxChars = Math.Max(key.Length, tags.MaxLength()); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs index 4672818d056..042203182a0 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs @@ -29,7 +29,6 @@ internal partial class DefaultHybridCache private readonly object _syncLock6 = new(); private readonly object _syncLock7 = new(); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Trivial low 3 bits")] internal object GetPartitionedSyncLock(in StampedeKey key) => (key.HashCode & 0b111) switch // generate 8 partitions using the low 3 bits { 0 => _syncLock0, diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs index 21105a2f4c0..ef5b7f1a01a 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -164,7 +164,6 @@ static async ValueTask SlowAsync(DefaultHybridCache @this, TagSet tags, lo [System.Diagnostics.CodeAnalysis.SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "Ack")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Completion-checked")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Manual async unwrap")] public ValueTask IsTagExpiredAsync(string tag, long timestamp) { if (!_tagInvalidationTimes.TryGetValue(tag, out Task? pending)) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index 7294e1daf84..93e1e5457cb 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -26,9 +26,6 @@ internal sealed partial class DefaultHybridCache : HybridCache { internal const int DefaultExpirationMinutes = 5; - // reserve non-printable characters from keys, to prevent potential L2 abuse - private static readonly char[] _keyReservedCharacters = Enumerable.Range(0, 32).Select(i => (char)i).ToArray(); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "Keep usage explicit")] private readonly IDistributedCache? _backendCache; [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0032:Use auto property", Justification = "Keep usage explicit")] @@ -68,13 +65,23 @@ internal enum CacheFeatures internal bool HasBackendCache => (_features & CacheFeatures.BackendCache) != 0; public DefaultHybridCache(IOptions options, IServiceProvider services) + : this(Throw.IfNull(options).Value, services) + { + } + + public DefaultHybridCache(HybridCacheOptions options, IServiceProvider services) { _services = Throw.IfNull(services); _localCache = services.GetRequiredService(); - _options = options.Value; + _options = options; _logger = services.GetService()?.CreateLogger(typeof(HybridCache)) ?? NullLogger.Instance; _clock = services.GetService() ?? TimeProvider.System; - _backendCache = services.GetService(); // note optional + + // The backend cache service is optional; if not provided, we operate as a pure L1 cache. + // If a service key is provided, the service must be present. + _backendCache = _options.DistributedCacheServiceKey is null + ? services.GetService() + : services.GetRequiredKeyedService(_options.DistributedCacheServiceKey); // ignore L2 if it is really just the same L1, wrapped // (note not just an "is" test; if someone has a custom subclass, who knows what it does?) @@ -255,6 +262,26 @@ private static ValueTask RunWithoutCacheAsync(HybridCacheEntryFlag return null; } + // reserve non-printable characters from keys, to prevent potential L2 abuse + private static bool ContainsReservedCharacters(ReadOnlySpan key) + { + const char MaxControlChar = (char)31; + +#if NET8_0_OR_GREATER + return key.IndexOfAnyInRange((char)0, MaxControlChar) >= 0; +#else + foreach (char c in key) + { + if (c <= MaxControlChar) + { + return true; + } + } + + return false; +#endif + } + private bool ValidateKey(string key) { if (string.IsNullOrWhiteSpace(key)) @@ -269,7 +296,7 @@ private bool ValidateKey(string key) return false; } - if (key.IndexOfAny(_keyReservedCharacters) >= 0) + if (ContainsReservedCharacters(key.AsSpan())) { _logger.KeyInvalidContent(); return false; diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs index f499ba485b3..537968113c2 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs @@ -126,10 +126,9 @@ static void PrepareStateForDepth(Type type, ref Dictionary? state, bool result) { var value = result ? FieldOnlyResult.FieldOnly : FieldOnlyResult.NotFieldOnly; - if (state is not null) - { - state[type] = value; - } +#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499). + state?[type] = value; +#pragma warning restore IDE0058 return value; } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs index 5c39727d980..079bd295a02 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs @@ -97,9 +97,7 @@ static int GetMaxStringLength(int charCount) => MaxVarint64Length + Encoding.GetMaxByteCount(charCount); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Encoding details; clear in context")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Not cryptographic")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Borderline")] public static int Write(byte[] destination, string key, long creationTime, TimeSpan duration, PayloadFlags flags, TagSet tags, ReadOnlySequence payload) { @@ -172,9 +170,6 @@ static void WriteString(byte[] target, ref int offset, string value) [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1122:Use string.Empty for empty strings", Justification = "Subjective, but; ugly")] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204:Static elements should appear before instance elements", Justification = "False positive?")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Encoding details; clear in context")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Borderline")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exposed for logging")] public static HybridCachePayloadParseResult TryParse(ArraySegment source, string key, TagSet knownTags, DefaultHybridCache cache, out ArraySegment payload, out TimeSpan remainingTime, out PayloadFlags flags, out ushort entropy, out TagSet pendingTags, out Exception? fault) { diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs index 12b13cf03e6..f43f3af988c 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs @@ -34,13 +34,10 @@ internal static bool IsBlittable() // minimize the generic portion (twinned w GCHandle.Alloc(obj, GCHandleType.Pinned).Free(); return true; } -#pragma warning disable CA1031 // Do not catch general exception types: interpret any failure here as "nope" catch { return false; } -#pragma warning restore CA1031 - #endif } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json index 2c1a811b223..10be31168ba 100644 Binary files a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json and b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json differ diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs index 747f58d0a98..ccf612ba944 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Runtime.CompilerServices; using Microsoft.Extensions.Compliance.Redaction; diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs index 26df96f54d6..c9a69972a6d 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs @@ -11,7 +11,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; #else -using System.Diagnostics.CodeAnalysis; using System.Text; #endif @@ -123,7 +122,6 @@ private static byte[] CreateSha256Hash(ReadOnlySpan value, byte[] hashKey) '8', '9', '+', '/', '=', }; - [SuppressMessage("Code smell", "S109", Justification = "Bit operation.")] private static int ConvertBytesToBase64(byte[] hashToConvert, Span destination, int remainingBytesToPad, int startOffset) { var iterations = BytesOfHashWeUse - remainingBytesToPad; diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs index eab81632049..43637a7d726 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static class FakeRedactionServiceCollectionExtensions /// /// Registers the fake redactor provider that always returns fake redactor instances. /// - /// Container used to register fake redaction classes. + /// The container used to register fake redaction classes. /// The value of . /// is . public static IServiceCollection AddFakeRedaction(this IServiceCollection services) @@ -42,10 +42,10 @@ public static IServiceCollection AddFakeRedaction(this IServiceCollection servic /// /// Registers the fake redactor provider that always returns fake redactor instances. /// - /// Container used to register fake redaction classes. + /// The container used to register fake redaction classes. /// Configures fake redactor. /// The value of . - /// or > are . + /// or is . public static IServiceCollection AddFakeRedaction(this IServiceCollection services, Action configure) { _ = Throw.IfNull(services); diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunk.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunk.cs new file mode 100644 index 00000000000..f4f52622458 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunk.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents a chunk of content extracted from an . +/// +/// The type of the content. +[DebuggerDisplay("Content = {Content}")] +public sealed class IngestionChunk +{ + private Dictionary? _metadata; + + /// + /// Initializes a new instance of the class. + /// + /// The content of the chunk. + /// The document from which this chunk was extracted. + /// Additional context for the chunk. + /// + /// or is . + /// + /// + /// is a string that is empty or contains only white-space characters. + /// + public IngestionChunk(T content, IngestionDocument document, string? context = null) + { + if (typeof(T) == typeof(string)) + { + Content = (T)(object)Throw.IfNullOrEmpty((string)(object)content!); + } + else + { + Content = Throw.IfNull(content); + } + + Document = Throw.IfNull(document); + Context = context; + } + + /// + /// Gets the content of the chunk. + /// + public T Content { get; } + + /// + /// Gets the document from which this chunk was extracted. + /// + public IngestionDocument Document { get; } + + /// + /// Gets additional context for the chunk. + /// + public string? Context { get; } + + /// + /// Gets a value indicating whether this chunk has metadata. + /// + public bool HasMetadata => _metadata?.Count > 0; + + /// + /// Gets the metadata associated with this chunk. + /// + public IDictionary Metadata => _metadata ??= []; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkProcessor.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkProcessor.cs new file mode 100644 index 00000000000..cd262305089 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkProcessor.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Processes chunks in a pipeline. +/// +/// The type of the chunk content. +public abstract class IngestionChunkProcessor +{ + /// + /// Processes chunks asynchronously. + /// + /// The chunks to process. + /// The token to monitor for cancellation requests. + /// The processed chunks. + public abstract IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkWriter.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkWriter.cs new file mode 100644 index 00000000000..119265caf6e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkWriter.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Writes chunks to a destination. +/// +/// The type of the chunk content. +public abstract class IngestionChunkWriter : IDisposable +{ + /// + /// Writes chunks asynchronously. + /// + /// The chunks to write. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous write operation. + public abstract Task WriteAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default); + + /// + /// Disposes the writer and releases all associated resources. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes the writer. + /// + /// true if called from dispose, false if called from finalizer. + protected virtual void Dispose(bool disposing) + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunker.cs new file mode 100644 index 00000000000..0f386c67de8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunker.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Splits an into chunks. +/// +/// The type of the chunk content. +public abstract class IngestionChunker +{ + /// + /// Splits a document into chunks asynchronously. + /// + /// The document to split. + /// The token to monitor for cancellation requests. + /// The chunks created from the document. + public abstract IAsyncEnumerable> ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocument.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocument.cs new file mode 100644 index 00000000000..119a4acee91 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocument.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// A format-agnostic container that normalizes diverse input formats into a structured hierarchy. +/// +public sealed class IngestionDocument +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for the document. + /// is . + public IngestionDocument(string identifier) + { + Identifier = Throw.IfNullOrEmpty(identifier); + } + + /// + /// Gets the unique identifier for the document. + /// + public string Identifier { get; } + + /// + /// Gets the sections of the document. + /// + public IList Sections { get; } = []; + + /// + /// Iterate over all elements in the document, including those in nested sections. + /// + /// An enumerable collection of elements. + /// + /// Sections themselves are not included. + /// + public IEnumerable EnumerateContent() + { + Stack elementsToProcess = new(); + + for (int sectionIndex = Sections.Count - 1; sectionIndex >= 0; sectionIndex--) + { + elementsToProcess.Push(Sections[sectionIndex]); + } + + while (elementsToProcess.Count > 0) + { + IngestionDocumentElement currentElement = elementsToProcess.Pop(); + + if (currentElement is not IngestionDocumentSection nestedSection) + { + yield return currentElement; + } + else + { + for (int i = nestedSection.Elements.Count - 1; i >= 0; i--) + { + elementsToProcess.Push(nestedSection.Elements[i]); + } + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentElement.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentElement.cs new file mode 100644 index 00000000000..af790e32f29 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentElement.cs @@ -0,0 +1,231 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +#pragma warning disable SA1402 // File may only contain a single type + +/// +/// Represents an element within an . +/// +[DebuggerDisplay("Type = {GetType().Name}, Markdown = {GetMarkdown()}")] +public abstract class IngestionDocumentElement +{ +#pragma warning disable IDE1006 // Naming Styles + private protected string _markdown; +#pragma warning restore IDE1006 // Naming Styles + + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the element. + /// is or empty. + private protected IngestionDocumentElement(string markdown) + { + _markdown = string.IsNullOrEmpty(markdown) ? throw new ArgumentNullException(nameof(markdown)) : markdown; + } + + private protected IngestionDocumentElement() + { + _markdown = null!; + } + + private Dictionary? _metadata; + + /// + /// Gets or sets the textual content of the element. + /// + public string? Text { get; set; } + + /// + /// Gets the markdown representation of the element. + /// + /// The markdown representation. + public virtual string GetMarkdown() => _markdown; + + /// + /// Gets or sets the page number where this element appears. + /// + public int? PageNumber { get; set; } + + /// + /// Gets a value indicating whether this element has metadata. + /// + public bool HasMetadata => _metadata?.Count > 0; + + /// + /// Gets the metadata associated with this element. + /// + public IDictionary Metadata => _metadata ??= []; +} + +/// +/// A section can be just a page or a logical grouping of elements in a document. +/// +public sealed class IngestionDocumentSection : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the section. + public IngestionDocumentSection(string markdown) + : base(markdown) + { + } + + /// + /// Initializes a new instance of the class. + /// + public IngestionDocumentSection() + { + } + + /// + /// Gets the elements within this section. + /// + public IList Elements { get; } = []; + + /// + public override string GetMarkdown() + => string.Join(Environment.NewLine, Elements.Select(e => e.GetMarkdown())); +} + +/// +/// Represents a paragraph in a document. +/// +public sealed class IngestionDocumentParagraph : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the paragraph. + public IngestionDocumentParagraph(string markdown) + : base(markdown) + { + } +} + +/// +/// Represents a header in a document. +/// +public sealed class IngestionDocumentHeader : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the header. + public IngestionDocumentHeader(string markdown) + : base(markdown) + { + } + + /// + /// Gets or sets the level of the header. + /// + public int? Level + { + get => field; + set + { + if (value.HasValue) + { + field = Throw.IfOutOfRange(value.Value, min: 1, max: 10, nameof(value)); + } + else + { + field = null; + } + } + } +} + +/// +/// Represents a footer in a document. +/// +public sealed class IngestionDocumentFooter : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the footer. + public IngestionDocumentFooter(string markdown) + : base(markdown) + { + } +} + +/// +/// Represents a table in a document. +/// +public sealed class IngestionDocumentTable : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the table. + /// The cells of the table. + /// is . +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional +#pragma warning disable S3967 // Multidimensional arrays should not be used + public IngestionDocumentTable(string markdown, IngestionDocumentElement?[,] cells) + : base(markdown) + { + Cells = Throw.IfNull(cells); + } + + /// + /// Gets the cells of the table. + /// Each table can be represented as a two-dimensional array of cell contents, with the first row being the headers. + /// + /// + /// This information is useful when chunking large tables that exceed token count limit. + /// Null represents an empty cell ( can't return an empty string). + /// +#pragma warning disable CA1819 // Properties should not return arrays + public IngestionDocumentElement?[,] Cells { get; } +#pragma warning restore CA1819 // Properties should not return arrays +#pragma warning restore S3967 // Multidimensional arrays should not be used +#pragma warning restore CA1814 // Prefer jagged arrays over multidimensional +} + +/// +/// Represents an image in a document. +/// +public sealed class IngestionDocumentImage : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the image. + public IngestionDocumentImage(string markdown) + : base(markdown) + { + } + + /// + /// Gets or sets the binary content of the image. + /// + public ReadOnlyMemory? Content { get; set; } + + /// + /// Gets or sets the media type of the image. + /// + public string? MediaType { get; set; } + + /// + /// Gets or sets the alternative text for the image. + /// + /// + /// Alternative text is a brief, descriptive text that explains the content, context, or function of an image when the image cannot be displayed or accessed. + /// This property can be used when generating the embedding for the image that is part of larger chunk. + /// + public string? AlternativeText { get; set; } +} + +#pragma warning restore SA1402 // File may only contain a single type diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentProcessor.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentProcessor.cs new file mode 100644 index 00000000000..c5f39e7a419 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentProcessor.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Part of the document processing pipeline that takes a as input and produces a (potentially modified) as output. +/// +public abstract class IngestionDocumentProcessor +{ + /// + /// Processes the given ingestion document. + /// + /// The ingestion document to process. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous processing operation, with the processed document as the result. + public abstract Task ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs new file mode 100644 index 00000000000..8bdb651321a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads source content and converts it to an . +/// +public abstract class IngestionDocumentReader +{ + /// + /// Reads a file and converts it to an . + /// + /// The file to read. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous read operation. + /// is . + public Task ReadAsync(FileInfo source, CancellationToken cancellationToken = default) + { + string identifier = Throw.IfNull(source).FullName; // entire path is more unique than just part of it. + return ReadAsync(source, identifier, GetMediaType(source), cancellationToken); + } + + /// + /// Reads a file and converts it to an . + /// + /// The file to read. + /// The unique identifier for the document. + /// The media type of the file. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous read operation. + /// or is or empty. + public virtual async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + using FileStream stream = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, FileOptions.Asynchronous); + return await ReadAsync(stream, identifier, string.IsNullOrEmpty(mediaType) ? GetMediaType(source) : mediaType!, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads a stream and converts it to an . + /// + /// The stream to read. + /// The unique identifier for the document. + /// The media type of the content. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous read operation. + public abstract Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default); + + private static string GetMediaType(FileInfo source) + => source.Extension switch + { + ".123" => "application/vnd.lotus-1-2-3", + ".602" => "application/x-t602", + ".abw" => "application/x-abiword", + ".bmp" => "image/bmp", + ".cgm" => "image/cgm", + ".csv" => "text/csv", + ".cwk" => "application/x-cwk", + ".dbf" => "application/vnd.dbf", + ".dif" => "application/x-dif", + ".doc" => "application/msword", + ".docm" => "application/vnd.ms-word.document.macroEnabled.12", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dot" => "application/msword", + ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", + ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".epub" => "application/epub+zip", + ".et" => "application/vnd.ms-excel", + ".eth" => "application/ethos", + ".fods" => "application/vnd.oasis.opendocument.spreadsheet", + ".gif" => "image/gif", + ".htm" => "text/html", + ".html" => "text/html", + ".hwp" => "application/x-hwp", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".key" => "application/x-iwork-keynote-sffkey", + ".lwp" => "application/vnd.lotus-wordpro", + ".mcw" => "application/macwriteii", + ".mw" => "application/macwriteii", + ".numbers" => "application/x-iwork-numbers-sffnumbers", + ".ods" => "application/vnd.oasis.opendocument.spreadsheet", + ".pages" => "application/x-iwork-pages-sffpages", + ".pbd" => "application/x-pagemaker", + ".pdf" => "application/pdf", + ".png" => "image/png", + ".pot" => "application/vnd.ms-powerpoint", + ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", + ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".prn" => "application/x-prn", + ".qpw" => "application/x-quattro-pro", + ".rtf" => "application/rtf", + ".sda" => "application/vnd.stardivision.draw", + ".sdd" => "application/vnd.stardivision.impress", + ".sdp" => "application/sdp", + ".sdw" => "application/vnd.stardivision.writer", + ".sgl" => "application/vnd.stardivision.writer", + ".slk" => "text/vnd.sylk", + ".sti" => "application/vnd.sun.xml.impress.template", + ".stw" => "application/vnd.sun.xml.writer.template", + ".svg" => "image/svg+xml", + ".sxg" => "application/vnd.sun.xml.writer.global", + ".sxi" => "application/vnd.sun.xml.impress", + ".sxw" => "application/vnd.sun.xml.writer", + ".sylk" => "text/vnd.sylk", + ".tiff" => "image/tiff", + ".tsv" => "text/tab-separated-values", + ".txt" => "text/plain", + ".uof" => "application/vnd.uoml+xml", + ".uop" => "application/vnd.openofficeorg.presentation", + ".uos1" => "application/vnd.uoml+xml", + ".uos2" => "application/vnd.uoml+xml", + ".uot" => "application/x-uo", + ".vor" => "application/vnd.stardivision.writer", + ".webp" => "image/webp", + ".wpd" => "application/wordperfect", + ".wps" => "application/vnd.ms-works", + ".wq1" => "application/x-lotus", + ".wq2" => "application/x-lotus", + ".xls" => "application/vnd.ms-excel", + ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", + ".xlr" => "application/vnd.ms-works", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xlw" => "application/vnd.ms-excel", + ".xml" => "application/xml", + ".zabw" => "application/x-abiword", + _ => "application/octet-stream" + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj new file mode 100644 index 00000000000..f3f16874b4c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj @@ -0,0 +1,23 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Abstractions representing Data Ingestion components for RAG. + RAG + RAG;ingestion;documents + true + preview + false + 75 + 75 + + $(NoWarn);S1694 + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md new file mode 100644 index 00000000000..0285f27fb3d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md @@ -0,0 +1,39 @@ +# Microsoft.Extensions.DataIngestion.Abstractions + +.NET developers need to efficiently process, chunk, and retrieve information from diverse document formats while preserving semantic meaning and structural context. The `Microsoft.Extensions.DataIngestion` libraries provide a unified approach for representing document ingestion components. + +## The packages + +The [Microsoft.Extensions.DataIngestion.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion.Abstractions) package provides the core exchange types, including [`IngestionDocument`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestiondocument), [`IngestionChunker`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunker-1), [`IngestionChunkProcessor`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkprocessor-1), and [`IngestionChunkWriter`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkwriter-1). Any .NET library that provides document processing capabilities can implement these abstractions to enable seamless integration with consuming code. + +The [Microsoft.Extensions.DataIngestion](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion) package has an implicit dependency on the `Microsoft.Extensions.DataIngestion.Abstractions` package. This package enables you to easily integrate components such as enrichment processors, vector storage writers, and telemetry into your applications using familiar dependency injection and pipeline patterns. For example, it provides processors for sentiment analysis, keyword extraction, and summarization that can be chained together in ingestion pipelines. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.DataIngestion.Abstractions`. + +To also have access to higher-level utilities for working with document ingestion components, reference the `Microsoft.Extensions.DataIngestion` package instead (which itself references `Microsoft.Extensions.DataIngestion.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.DataIngestion` package along with one or more libraries that provide concrete implementations of the abstractions, such as `Microsoft.Extensions.DataIngestion.MarkItDown` or `Microsoft.Extensions.DataIngestion.Markdig`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.Abstractions --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Documentation + +Refer to the [Microsoft.Extensions.DataIngestion libraries documentation](https://learn.microsoft.com/dotnet/dataingestion/microsoft-extensions-dataingestion) for more information and API usage examples. + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs new file mode 100644 index 00000000000..b75fc2e7f50 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads documents by converting them to Markdown using the MarkItDown MCP server. +/// +public class MarkItDownMcpReader : IngestionDocumentReader +{ + private readonly Uri _mcpServerUri; + private readonly McpClientOptions? _options; + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the MarkItDown MCP server (e.g., http://localhost:3001/mcp). + /// Optional MCP client options for configuring the connection. + public MarkItDownMcpReader(Uri mcpServerUri, McpClientOptions? options = null) + { + _mcpServerUri = Throw.IfNull(mcpServerUri); + _options = options; + } + + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + if (!source.Exists) + { + throw new FileNotFoundException("The specified file does not exist.", source.FullName); + } + + // Read file content as base64 data URI +#if NET + byte[] fileBytes = await File.ReadAllBytesAsync(source.FullName, cancellationToken).ConfigureAwait(false); +#else + byte[] fileBytes; + using (FileStream fs = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.Asynchronous)) + { + using MemoryStream ms = new(); + await fs.CopyToAsync(ms).ConfigureAwait(false); + fileBytes = ms.ToArray(); + } +#endif + string dataUri = CreateDataUri(fileBytes, mediaType); + + string markdown = await ConvertToMarkdownAsync(dataUri, cancellationToken).ConfigureAwait(false); + + return MarkdownParser.Parse(markdown, identifier); + } + + /// + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + // Read stream content as base64 data URI + using MemoryStream ms = new(); +#if NET + await source.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); +#else + await source.CopyToAsync(ms).ConfigureAwait(false); +#endif + byte[] fileBytes = ms.ToArray(); + string dataUri = CreateDataUri(fileBytes, mediaType); + + string markdown = await ConvertToMarkdownAsync(dataUri, cancellationToken).ConfigureAwait(false); + + return MarkdownParser.Parse(markdown, identifier); + } + +#pragma warning disable S3995 // URI return values should not be strings + private static string CreateDataUri(byte[] fileBytes, string? mediaType) +#pragma warning restore S3995 // URI return values should not be strings + { + string base64Content = Convert.ToBase64String(fileBytes); + string mimeType = string.IsNullOrEmpty(mediaType) ? "application/octet-stream" : mediaType!; + return $"data:{mimeType};base64,{base64Content}"; + } + + private async Task ConvertToMarkdownAsync(string dataUri, CancellationToken cancellationToken) + { + // Create HTTP client transport for MCP + HttpClientTransport transport = new(new HttpClientTransportOptions + { + Endpoint = _mcpServerUri + }); + + await using (transport.ConfigureAwait(false)) + { + // Create MCP client + McpClient client = await McpClient.CreateAsync(transport, _options, loggerFactory: null, cancellationToken).ConfigureAwait(false); + + await using (client.ConfigureAwait(false)) + { + // Build parameters for convert_to_markdown tool + Dictionary parameters = new() + { + ["uri"] = dataUri + }; + + // Call the convert_to_markdown tool + var result = await client.CallToolAsync("convert_to_markdown", parameters, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Extract markdown content from result + // The result is expected to be in the format: { "content": [{ "type": "text", "text": "markdown content" }] } + if (result.Content != null && result.Content.Count > 0) + { + foreach (var content in result.Content) + { + if (content.Type == "text" && content is TextContentBlock textBlock) + { + return textBlock.Text; + } + } + } + } + } + + throw new InvalidOperationException("Failed to convert document to markdown: unexpected response format from MCP server."); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownReader.cs new file mode 100644 index 00000000000..79b60f3ad5d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownReader.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads documents by converting them to Markdown using the MarkItDown tool. +/// +public class MarkItDownReader : IngestionDocumentReader +{ + private readonly FileInfo? _exePath; + private readonly bool _extractImages; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the MarkItDown executable. When not provided, "markitdown" needs to be added to PATH. + /// A value indicating whether to extract images. + public MarkItDownReader(FileInfo? exePath = null, bool extractImages = false) + { + _exePath = exePath; + _extractImages = extractImages; + } + + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + if (!source.Exists) + { + throw new FileNotFoundException("The specified file does not exist.", source.FullName); + } + + // Manually set ProcessStartInfo.WorkingDirectory to a "safe location": + // - If exePath is provided, use its directory. + // - Otherwise, use AppContext.BaseDirectory (the directory of the running application). + string workingDirectory = _exePath?.Directory?.FullName ?? AppContext.BaseDirectory; + + ProcessStartInfo startInfo = new() + { + FileName = _exePath?.FullName ?? "markitdown", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }; + + // Force UTF-8 encoding in the environment (will produce garbage otherwise). + startInfo.Environment["PYTHONIOENCODING"] = "utf-8"; + startInfo.Environment["LC_ALL"] = "C.UTF-8"; + startInfo.Environment["LANG"] = "C.UTF-8"; + +#if NET + startInfo.ArgumentList.Add(source.FullName); + if (_extractImages) + { + startInfo.ArgumentList.Add("--keep-data-uris"); + } +#else + startInfo.Arguments = $"\"{source.FullName}\"" + (_extractImages ? " --keep-data-uris" : string.Empty); +#endif + + string outputContent = string.Empty; + using (Process process = new() { StartInfo = startInfo }) + { + process.Start(); + + outputContent = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#if NET + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); +#else + process.WaitForExit(); +#endif + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"MarkItDown process failed with exit code {process.ExitCode}."); + } + } + + return MarkdownParser.Parse(outputContent, identifier); + } + + /// + /// The contents of are copied to a temporary file. + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + // Instead of creating a temporary file, we could write to the StandardInput of the process. + // MarkItDown says it supports reading from stdin, but it does not work as expected. + // Even the sample command line does not work with stdin: "cat example.pdf | markitdown" + // I can be doing something wrong, but for now, let's write to a temporary file. + string inputFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + FileStream inputFile = new(inputFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.Asynchronous); + + try + { + await source +#if NET + .CopyToAsync(inputFile, cancellationToken) +#else + .CopyToAsync(inputFile) +#endif + .ConfigureAwait(false); + + inputFile.Close(); + + return await ReadAsync(new FileInfo(inputFilePath), identifier, mediaType, cancellationToken).ConfigureAwait(false); + } + finally + { +#if NET + await inputFile.DisposeAsync().ConfigureAwait(false); +#else + inputFile.Dispose(); +#endif + File.Delete(inputFilePath); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj new file mode 100644 index 00000000000..013097ea6c7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj @@ -0,0 +1,29 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Implementation of IngestionDocumentReader abstraction for MarkItDown. + RAG + RAG;ingestion;documents;markitdown + true + preview + false + 75 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md new file mode 100644 index 00000000000..095011b77f1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md @@ -0,0 +1,91 @@ +# Microsoft.Extensions.DataIngestion.MarkItDown + +Provides an implementation of the `IngestionDocumentReader` class for the [MarkItDown](https://github.com/microsoft/markitdown/) utility. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.MarkItDown --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Creating a MarkItDownReader for Data Ingestion (Local Process) + +Use `MarkItDownReader` to convert documents using the MarkItDown executable installed locally: + +```csharp +using Microsoft.Extensions.DataIngestion; + +IngestionDocumentReader reader = + new MarkItDownReader(new FileInfo(@"pathToMarkItDown.exe"), extractImages: true); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +### Creating a MarkItDownMcpReader for Data Ingestion (MCP Server) + +Use `MarkItDownMcpReader` to convert documents using a MarkItDown MCP server: + +```csharp +using Microsoft.Extensions.DataIngestion; + +// Connect to a MarkItDown MCP server (e.g., running in Docker) +IngestionDocumentReader reader = + new MarkItDownMcpReader(new Uri("http://localhost:3001/mcp")); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +The MarkItDown MCP server can be run using Docker: + +```bash +docker run -p 3001:3001 mcp/markitdown --http --host 0.0.0.0 --port 3001 +``` + +Or installed via pip: + +```bash +pip install markitdown-mcp-server +markitdown-mcp --http --host 0.0.0.0 --port 3001 +``` + +### Integrating with Aspire + +Aspire can be used for seamless integration with [MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp). Sample AppHost logic: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") + .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") + .WithHttpEndpoint(targetPort: 3001, name: "http"); + +var webApp = builder.AddProject("name"); + +webApp.WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); + +builder.Build().Run(); +``` + +Sample Ingestion Service: + +```csharp +string url = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; + +IngestionDocumentReader reader = new MarkItDownMcpReader(new Uri(url)); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs new file mode 100644 index 00000000000..8ef2b27d152 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs @@ -0,0 +1,299 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Text; +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class MarkdownParser +{ + internal static IngestionDocument Parse(string markdown, string identifier) + { + _ = Throw.IfNullOrEmpty(markdown); + _ = Throw.IfNullOrEmpty(identifier); + + // Markdig's "UseAdvancedExtensions" option includes many common extensions beyond + // CommonMark, such as citations, figures, footnotes, grid tables, mathematics + // task lists, diagrams, and more. + var pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + MarkdownDocument markdownDocument = Markdown.Parse(markdown, pipeline); + return Map(markdownDocument, markdown, identifier); + } + +#if !NET + internal static System.Threading.Tasks.Task ReadToEndAsync(this System.IO.StreamReader reader, System.Threading.CancellationToken cancellationToken) + => cancellationToken.IsCancellationRequested ? System.Threading.Tasks.Task.FromCanceled(cancellationToken) : reader.ReadToEndAsync(); +#endif + + private static IngestionDocument Map(MarkdownDocument markdownDocument, string documentMarkdown, string identifier) + { + IngestionDocumentSection rootSection = new(documentMarkdown); + IngestionDocument result = new(identifier) + { + Sections = { rootSection } + }; + + bool previousWasBreak = false; + foreach (Block block in markdownDocument) + { + if (block is ThematicBreakBlock breakBlock) + { + // We have encountered a thematic break (horizontal rule): ----------- etc. + previousWasBreak = true; + continue; + } + + if (block is LinkReferenceDefinitionGroup linkReferenceGroup) + { + continue; // In the future, we might want to handle links differently. + } + + if (IsEmptyBlock(block)) + { + continue; + } + + rootSection.Elements.Add(MapBlock(documentMarkdown, previousWasBreak, block)); + previousWasBreak = false; + } + + return result; + } + + private static bool IsEmptyBlock(Block block) // Block with no text. Sample: QuoteBlock the next block is a quote. + => block is LeafBlock emptyLeafBlock && (emptyLeafBlock.Inline is null || emptyLeafBlock.Inline.FirstChild is null); + + private static IngestionDocumentElement MapBlock(string documentMarkdown, bool previousWasBreak, Block block) + { + string elementMarkdown = documentMarkdown.Substring(block.Span.Start, block.Span.Length); + + IngestionDocumentElement element = block switch + { + LeafBlock leafBlock => MapLeafBlockToElement(leafBlock, previousWasBreak, elementMarkdown), + ListBlock listBlock => MapListBlock(listBlock, previousWasBreak, documentMarkdown, elementMarkdown), + QuoteBlock quoteBlock => MapQuoteBlock(quoteBlock, previousWasBreak, documentMarkdown, elementMarkdown), + Table table => new IngestionDocumentTable(elementMarkdown, GetCells(table, documentMarkdown)), + _ => throw new NotSupportedException($"Block type '{block.GetType().Name}' is not supported.") + }; + + return element; + } + + private static IngestionDocumentElement MapLeafBlockToElement(LeafBlock block, bool previousWasBreak, string elementMarkdown) + => block switch + { + HeadingBlock heading => new IngestionDocumentHeader(elementMarkdown) + { + Text = GetText(heading.Inline), + Level = heading.Level + }, + ParagraphBlock footer when previousWasBreak => new IngestionDocumentFooter(elementMarkdown) + { + Text = GetText(footer.Inline), + }, + ParagraphBlock image when image.Inline!.Descendants().FirstOrDefault() is LinkInline link && link.IsImage => MapImage(elementMarkdown, link), + ParagraphBlock paragraph => new IngestionDocumentParagraph(elementMarkdown) + { + Text = GetText(paragraph.Inline), + }, + CodeBlock codeBlock => new IngestionDocumentParagraph(elementMarkdown) + { + Text = GetText(codeBlock.Inline), + }, + _ => throw new NotSupportedException($"Block type '{block.GetType().Name}' is not supported.") + }; + + private static IngestionDocumentImage MapImage(string elementMarkdown, LinkInline link) + { + IngestionDocumentImage result = new(elementMarkdown); + + // ![Alt text](data:image/type;base64,...) + if (link.FirstChild is LiteralInline literal) + { + result.AlternativeText = literal.Content.ToString(); + } + + if (link.Url is not null && link.Url.StartsWith("data:image/", StringComparison.Ordinal)) + { + // Parse the data URL format: data:image/{type};base64,{data} + ReadOnlySpan url = link.Url.AsSpan("data:".Length); + + // Find the semicolon that separates media type from encoding + int semicolonIndex = url.IndexOf(';'); + if (semicolonIndex > 0) + { + ReadOnlySpan mediaType = url.Slice(0, semicolonIndex); + + // Find the comma that separates encoding from data + int commaIndex = url.IndexOf(','); + if (commaIndex > semicolonIndex) + { + // Check if it's base64 encoded + ReadOnlySpan encoding = url.Slice(semicolonIndex + 1, commaIndex - semicolonIndex - 1); + if (encoding.SequenceEqual("base64".AsSpan())) + { + result.Content = Convert.FromBase64String(url.Slice(commaIndex + 1).ToString()); + result.MediaType = mediaType.ToString(); + } + } + } + } + + return result; + } + + private static IngestionDocumentSection MapListBlock(ListBlock listBlock, bool previousWasBreak, string documentMarkdown, string listMarkdown) + { + IngestionDocumentSection list = new(listMarkdown); + foreach (Block? item in listBlock) + { + if (item is not ListItemBlock listItemBlock) + { + continue; + } + + foreach (Block? child in listItemBlock) + { + if (child is not LeafBlock leafBlock || IsEmptyBlock(leafBlock)) + { + continue; // Skip empty blocks in lists + } + + string childMarkdown = documentMarkdown.Substring(leafBlock.Span.Start, leafBlock.Span.Length); + IngestionDocumentElement element = MapLeafBlockToElement(leafBlock, previousWasBreak, childMarkdown); + list.Elements.Add(element); + } + } + + return list; + } + + private static IngestionDocumentSection MapQuoteBlock(QuoteBlock quoteBlock, bool previousWasBreak, string documentMarkdown, string elementMarkdown) + { + IngestionDocumentSection quote = new(elementMarkdown); + foreach (Block child in quoteBlock) + { + if (IsEmptyBlock(child)) + { + continue; // Skip empty blocks in quotes + } + + quote.Elements.Add(MapBlock(documentMarkdown, previousWasBreak, child)); + } + + return quote; + } + + private static string? GetText(ContainerInline? containerInline) + { + Debug.Assert(containerInline != null, "ContainerInline should not be null here."); + Debug.Assert(containerInline!.FirstChild != null, "FirstChild should not be null here."); + + if (ReferenceEquals(containerInline.FirstChild, containerInline.LastChild)) + { + // If there is only one child, return its text. + return containerInline.FirstChild!.ToString(); + } + + StringBuilder content = new(100); + foreach (Inline inline in containerInline) + { +#pragma warning disable IDE0058 // Expression value is never used + if (inline is LiteralInline literalInline) + { + content.Append(literalInline.Content); + } + else if (inline is LineBreakInline) + { + content.AppendLine(); // Append a new line for line breaks + } + else if (inline is ContainerInline another) + { + // EmphasisInline is also a ContainerInline, but it does not get any special treatment, + // as we use raw text here (instead of a markdown, where emphasis can be expressed). + content.Append(GetText(another)); + } + else if (inline is CodeInline codeInline) + { + content.Append(codeInline.Content); + } + else + { + throw new NotSupportedException($"Inline type '{inline.GetType().Name}' is not supported."); + } +#pragma warning restore IDE0058 // Expression value is never used + } + + return content.ToString(); + } + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional +#pragma warning disable S3967 // Multidimensional arrays should not be used + private static IngestionDocumentElement?[,] GetCells(Table table, string outputContent) + { + int firstRowIndex = SkipFirstRow(table, outputContent) ? 1 : 0; + + // For some reason, table.ColumnDefinitions.Count returns one extra column. + var cells = new IngestionDocumentElement?[table.Count - firstRowIndex, table.ColumnDefinitions.Count - 1]; + + for (int rowIndex = firstRowIndex; rowIndex < table.Count; rowIndex++) + { + var tableRow = (TableRow)table[rowIndex]; + int columnIndex = 0; + for (int cellIndex = 0; cellIndex < tableRow.Count; cellIndex++) + { + var tableCell = (TableCell)tableRow[cellIndex]; + var content = tableCell.Count switch + { + 0 => null, + 1 => MapBlock(outputContent, previousWasBreak: false, tableCell[0]), + _ => throw new NotSupportedException($"Cells with {tableCell.Count} elements are not supported.") + }; + + for (int columnSpan = 0; columnSpan < tableCell.ColumnSpan; columnSpan++, columnIndex++) + { + // tableCell.ColumnIndex defaults to -1, so it's not used here. + cells[rowIndex - firstRowIndex, columnIndex] = content; + } + } + } + + return cells; + + // Some parsers like MarkItDown include a row with invalid markdown before the separator row: + // | | | | | + // | --- | --- | --- | --- | + static bool SkipFirstRow(Table table, string outputContent) + { + if (table.Count > 0) + { + var firstRow = (TableRow)table[0]; + for (int cellIndex = 0; cellIndex < firstRow.Count; cellIndex++) + { + var tableCell = (TableCell)firstRow[cellIndex]; + if (!string.IsNullOrWhiteSpace(MapBlock(outputContent, previousWasBreak: false, tableCell[0]).Text)) + { + return false; + } + } + + return true; + } + + return false; + } + } +#pragma warning restore CA1814 // Prefer jagged arrays over multidimensional +#pragma warning restore S3967 // Multidimensional arrays should not be used +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownReader.cs new file mode 100644 index 00000000000..1afabd03139 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownReader.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads Markdown content and converts it to an . +/// +public sealed class MarkdownReader : IngestionDocumentReader +{ + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + +#if NET + string fileContent = await File.ReadAllTextAsync(source.FullName, cancellationToken).ConfigureAwait(false); +#else + using FileStream stream = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, FileOptions.Asynchronous); + string fileContent = await ReadToEndAsync(stream, cancellationToken).ConfigureAwait(false); +#endif + return MarkdownParser.Parse(fileContent, identifier); + } + + /// + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + string fileContent = await ReadToEndAsync(source, cancellationToken).ConfigureAwait(false); + return MarkdownParser.Parse(fileContent, identifier); + } + + private static async Task ReadToEndAsync(Stream source, CancellationToken cancellationToken) + { + using StreamReader reader = +#if NET + new(source, leaveOpen: true); +#else + new(source, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); +#endif + + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj new file mode 100644 index 00000000000..5dbe9c27a34 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj @@ -0,0 +1,24 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Implementation of IngestionDocumentReader abstraction for Markdown. + RAG + RAG;ingestion;documents;markdown + true + preview + false + 75 + 75 + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md new file mode 100644 index 00000000000..c6a2328699c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md @@ -0,0 +1,35 @@ +# Microsoft.Extensions.DataIngestion.Markdig + +Provides an implementation of the `IngestionDocumentReader` class for the Markdown files using [MarkDig](https://github.com/xoofx/markdig) library. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.Markdig --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Creating a MarkdownReader for Data Ingestion + +```csharp +using Microsoft.Extensions.DataIngestion; + +IngestionDocumentReader reader = new MarkdownReader(); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ElementsChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ElementsChunker.cs new file mode 100644 index 00000000000..a50508f2a5e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ElementsChunker.cs @@ -0,0 +1,273 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.ML.Tokenizers; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion.Chunkers; + +#pragma warning disable IDE0058 // Expression value is never used + +internal sealed class ElementsChunker +{ + private readonly Tokenizer _tokenizer; + private readonly int _maxTokensPerChunk; + private readonly StringBuilder _currentChunk; + + internal ElementsChunker(IngestionChunkerOptions options) + { + _ = Throw.IfNull(options); + + _tokenizer = options.Tokenizer; + _maxTokensPerChunk = options.MaxTokensPerChunk; + + // Token count != character count, but StringBuilder will grow as needed. + _currentChunk = new(capacity: _maxTokensPerChunk); + } + + // Goals: + // 1. Create chunks that do not exceed _maxTokensPerChunk when tokenized. + // 2. Maintain context in each chunk. + // 3. If a single IngestionDocumentElement exceeds _maxTokensPerChunk, it should be split intelligently (e.g., paragraphs can be split into sentences, tables into rows). + internal IEnumerable> Process(IngestionDocument document, string context, List elements) + { + // Not using yield return here as we use ref structs. + List> chunks = []; + + int contextTokenCount = CountTokens(context.AsSpan()); + int totalTokenCount = contextTokenCount; + + // If the context itself exceeds the max tokens per chunk, we can't do anything. + if (contextTokenCount >= _maxTokensPerChunk) + { + ThrowTokenCountExceeded(); + } + + _currentChunk.Append(context); + + for (int elementIndex = 0; elementIndex < elements.Count; elementIndex++) + { + IngestionDocumentElement element = elements[elementIndex]; + string? semanticContent = element switch + { + // Image exposes: + // - Markdown: ![Alt Text](url) which is not very useful for embedding. + // - AlternativeText: usually a short description of the image, can be null or empty. It is usually less than 50 words. + // - Text: result of OCR, can be longer, but also can be null or empty. It can be several hundred words. + // We prefer AlternativeText over Text, as it is usually more relevant. + IngestionDocumentImage image => image.AlternativeText ?? image.Text, + _ => element.GetMarkdown() + }; + + if (string.IsNullOrEmpty(semanticContent)) + { + continue; // An image can come with Markdown, but no AlternativeText or Text. + } + + int elementTokenCount = CountTokens(semanticContent.AsSpan()); + if (elementTokenCount + totalTokenCount <= _maxTokensPerChunk) + { + totalTokenCount += elementTokenCount; + AppendNewLineAndSpan(_currentChunk, semanticContent.AsSpan()); + } + else if (element is IngestionDocumentTable table) + { + ValueStringBuilder tableBuilder = new(initialCapacity: 8000); + + try + { + AddMarkdownTableRow(table, rowIndex: 0, ref tableBuilder); + AddMarkdownTableSeparatorRow(columnCount: table.Cells.GetLength(1), ref tableBuilder); + + int headerLength = tableBuilder.Length; + int headerTokenCount = CountTokens(tableBuilder.AsSpan()); + + // We can't respect the limit if context and header themselves use more tokens. + if (contextTokenCount + headerTokenCount >= _maxTokensPerChunk) + { + ThrowTokenCountExceeded(); + } + + if (headerTokenCount + totalTokenCount >= _maxTokensPerChunk) + { + // We can't add the header row, so commit what we have accumulated so far. + Commit(); + } + + totalTokenCount += headerTokenCount; + int tableLength = headerLength; + + int rowCount = table.Cells.GetLength(0); + for (int rowIndex = 1; rowIndex < rowCount; rowIndex++) + { + AddMarkdownTableRow(table, rowIndex, ref tableBuilder); + + int lastRowTokens = CountTokens(tableBuilder.AsSpan(tableLength)); + + // Appending this row would exceed the limit. + if (totalTokenCount + lastRowTokens > _maxTokensPerChunk) + { + // We append the table as long as it's not just the header. + if (rowIndex != 1) + { + AppendNewLineAndSpan(_currentChunk, tableBuilder.AsSpan(0, tableLength - Environment.NewLine.Length)); + } + + // And commit the table we built so far. + Commit(); + + // Erase previous rows and keep only the header. + tableBuilder.Length = headerLength; + tableLength = headerLength; + totalTokenCount += headerTokenCount; + + if (totalTokenCount + lastRowTokens > _maxTokensPerChunk) + { + // This row is simply too big even for a fresh chunk: + ThrowTokenCountExceeded(); + } + + AddMarkdownTableRow(table, rowIndex, ref tableBuilder); + } + + tableLength = tableBuilder.Length; + totalTokenCount += lastRowTokens; + } + + AppendNewLineAndSpan(_currentChunk, tableBuilder.AsSpan(0, tableLength - Environment.NewLine.Length)); + } + finally + { + tableBuilder.Dispose(); + } + } + else + { + ReadOnlySpan remainingContent = semanticContent.AsSpan(); + + while (!remainingContent.IsEmpty) + { + int index = _tokenizer.GetIndexByTokenCount( + text: remainingContent, + maxTokenCount: _maxTokensPerChunk - totalTokenCount, + out string? normalizedText, + out int tokenCount, + considerNormalization: false); // We don't normalize, just append as-is to keep original content. + + // some tokens fit + if (index > 0) + { + // We could try to split by sentences or other delimiters, but it's complicated. + // For simplicity, we will just split at the last new line that fits. + // Our promise is not to go over the max token count, not to create perfect chunks. + int newLineIndex = remainingContent.Slice(0, index).LastIndexOf('\n'); + if (newLineIndex > 0) + { + index = newLineIndex + 1; // We want to include the new line character (works for "\r\n" as well). + tokenCount = CountTokens(remainingContent.Slice(0, index)); + } + + totalTokenCount += tokenCount; + ReadOnlySpan spanToAppend = remainingContent.Slice(0, index); + AppendNewLineAndSpan(_currentChunk, spanToAppend); + remainingContent = remainingContent.Slice(index); + } + else if (totalTokenCount == contextTokenCount) + { + // We are at the beginning of a chunk, and even a single token does not fit. + ThrowTokenCountExceeded(); + } + + if (!remainingContent.IsEmpty) + { + Commit(); + } + } + } + + if (totalTokenCount == _maxTokensPerChunk) + { + Commit(); + } + } + + if (totalTokenCount > contextTokenCount) + { + chunks.Add(new(_currentChunk.ToString(), document, context)); + } + + _currentChunk.Clear(); + + return chunks; + + void Commit() + { + chunks.Add(new(_currentChunk.ToString(), document, context)); + + // We keep the context in the current chunk as it's the same for all elements. + _currentChunk.Remove( + startIndex: context.Length, + length: _currentChunk.Length - context.Length); + totalTokenCount = contextTokenCount; + } + + static void ThrowTokenCountExceeded() + => throw new InvalidOperationException("Can't fit in the current chunk. Consider increasing max tokens per chunk."); + } + + private static void AppendNewLineAndSpan(StringBuilder stringBuilder, ReadOnlySpan chars) + { + // Don't start an empty chunk (no context provided) with a new line. + if (stringBuilder.Length > 0) + { + stringBuilder.AppendLine(); + } + +#if NET + stringBuilder.Append(chars); +#else + stringBuilder.Append(chars.ToString()); +#endif + } + + private static void AddMarkdownTableRow(IngestionDocumentTable table, int rowIndex, ref ValueStringBuilder vsb) + { + for (int columnIndex = 0; columnIndex < table.Cells.GetLength(1); columnIndex++) + { + vsb.Append('|'); + vsb.Append(' '); + string? cellContent = table.Cells[rowIndex, columnIndex] switch + { + null => null, + IngestionDocumentImage img => img.AlternativeText ?? img.Text, + IngestionDocumentElement other => other.GetMarkdown() + }; + vsb.Append(cellContent); + vsb.Append(' '); + } + + vsb.Append('|'); + vsb.Append(Environment.NewLine); + } + + private static void AddMarkdownTableSeparatorRow(int columnCount, ref ValueStringBuilder vsb) + { + const int DashCount = 3; // The dash count does not need to match the header length. + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + vsb.Append('|'); + vsb.Append(' '); + vsb.Append('-', DashCount); + vsb.Append(' '); + } + + vsb.Append('|'); + vsb.Append(Environment.NewLine); + } + + private int CountTokens(ReadOnlySpan input) + => _tokenizer.CountTokens(input, considerNormalization: false); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/HeaderChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/HeaderChunker.cs new file mode 100644 index 00000000000..8f3039c7b2f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/HeaderChunker.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.Extensions.DataIngestion.Chunkers; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Splits documents into chunks based on headers and their corresponding levels, preserving the header context. +/// +public sealed class HeaderChunker : IngestionChunker +{ + private const int MaxHeaderLevel = 10; + private readonly ElementsChunker _elementsChunker; + + /// + /// Initializes a new instance of the class. + /// + /// The options for the chunker. + public HeaderChunker(IngestionChunkerOptions options) + { + _elementsChunker = new(options); + } + + /// + public override async IAsyncEnumerable> ProcessAsync(IngestionDocument document, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(document); + + List elements = []; + string?[] headers = new string?[MaxHeaderLevel + 1]; + + foreach (IngestionDocumentElement element in document.EnumerateContent()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (element is IngestionDocumentHeader header) + { + foreach (var chunk in SplitIntoChunks(document, headers, elements)) + { + yield return chunk; + } + + int headerLevel = header.Level.GetValueOrDefault(); + headers[headerLevel] = header.GetMarkdown(); + headers.AsSpan(headerLevel + 1).Clear(); // clear all lower level headers + + continue; // don't add headers to the elements list, they are part of the context + } + + elements.Add(element); + } + + // take care of any remaining paragraphs + foreach (var chunk in SplitIntoChunks(document, headers, elements)) + { + yield return chunk; + } + } + + private IEnumerable> SplitIntoChunks(IngestionDocument document, string?[] headers, List elements) + { + if (elements.Count > 0) + { + string chunkHeader = string.Join(" ", headers.Where(h => !string.IsNullOrEmpty(h))); + + foreach (var chunk in _elementsChunker.Process(document, chunkHeader, elements)) + { + yield return chunk; + } + + elements.Clear(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/IngestionChunkerOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/IngestionChunkerOptions.cs new file mode 100644 index 00000000000..294f4c92d27 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/IngestionChunkerOptions.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.ML.Tokenizers; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Options for configuring the ingestion chunker. +/// +public class IngestionChunkerOptions +{ + // Default values come from https://learn.microsoft.com/en-us/azure/search/vector-search-how-to-chunk-documents#text-split-skill-example + private const int DefaultOverlapTokens = 500; + private const int DefaultTokensPerChunk = 2_000; + private int? _overlapTokens; + + /// + /// Initializes a new instance of the class. + /// + /// The tokenizer to use for tokenizing input. + public IngestionChunkerOptions(Tokenizer tokenizer) + { + Tokenizer = Throw.IfNull(tokenizer); + } + + /// + /// Gets the instance used to process and tokenize input data. + /// + public Tokenizer Tokenizer { get; } + + /// + /// Gets or sets the maximum number of tokens allowed in each chunk. Default is 2000. + /// + public int MaxTokensPerChunk + { + get => field == default ? DefaultTokensPerChunk : field; + set + { + _ = Throw.IfLessThanOrEqual(value, 0); + + if (_overlapTokens.HasValue && value <= _overlapTokens.Value) + { + Throw.ArgumentOutOfRangeException(nameof(value), "Chunk size must be greater than chunk overlap."); + } + + field = value; + } + } + + /// + /// Gets or sets the number of overlapping tokens between consecutive chunks. Default is 500. + /// + public int OverlapTokens + { + get + { + if (_overlapTokens.HasValue) + { + return _overlapTokens.Value; + } + else if (MaxTokensPerChunk > DefaultOverlapTokens) + { + return DefaultOverlapTokens; + } + else + { + return 0; + } + } + set + { + if (Throw.IfLessThan(value, 0) >= MaxTokensPerChunk) + { + Throw.ArgumentOutOfRangeException(nameof(value), "Chunk overlap must be less than chunk size."); + } + + _overlapTokens = value; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/SemanticSimilarityChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/SemanticSimilarityChunker.cs new file mode 100644 index 00000000000..78971cfe920 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/SemanticSimilarityChunker.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Numerics.Tensors; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion.Chunkers; + +/// +/// Splits a into chunks based on semantic similarity between its elements based on cosine distance of their embeddings. +/// +public sealed class SemanticSimilarityChunker : IngestionChunker +{ + private readonly ElementsChunker _elementsChunker; + private readonly IEmbeddingGenerator> _embeddingGenerator; + private readonly float _thresholdPercentile; + + /// + /// Initializes a new instance of the class. + /// + /// Embedding generator. + /// The options for the chunker. + /// Threshold percentile to consider the chunks to be sufficiently similar. 95th percentile will be used if not specified. + public SemanticSimilarityChunker( + IEmbeddingGenerator> embeddingGenerator, + IngestionChunkerOptions options, + float? thresholdPercentile = null) + { + _embeddingGenerator = embeddingGenerator; + _elementsChunker = new(options); + + if (thresholdPercentile < 0f || thresholdPercentile > 100f) + { + Throw.ArgumentOutOfRangeException(nameof(thresholdPercentile), "Threshold percentile must be between 0 and 100."); + } + + _thresholdPercentile = thresholdPercentile ?? 95.0f; + } + + /// + public override async IAsyncEnumerable> ProcessAsync(IngestionDocument document, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(document); + + List<(IngestionDocumentElement, float)> distances = await CalculateDistancesAsync(document, cancellationToken).ConfigureAwait(false); + foreach (var chunk in MakeChunks(document, distances)) + { + yield return chunk; + } + } + + private async Task> CalculateDistancesAsync(IngestionDocument documents, CancellationToken cancellationToken) + { + List<(IngestionDocumentElement element, float distance)> elementDistances = []; + List semanticContents = []; + + foreach (IngestionDocumentElement element in documents.EnumerateContent()) + { + string? semanticContent = element is IngestionDocumentImage img + ? img.AlternativeText ?? img.Text + : element.GetMarkdown(); + + if (!string.IsNullOrEmpty(semanticContent)) + { + elementDistances.Add((element, default)); + semanticContents.Add(semanticContent!); + } + } + + if (elementDistances.Count > 0) + { + var embeddings = await _embeddingGenerator.GenerateAsync(semanticContents, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (embeddings.Count != elementDistances.Count) + { + Throw.InvalidOperationException("The number of embeddings returned does not match the number of document elements."); + } + + for (int i = 0; i < elementDistances.Count - 1; i++) + { + float distance = 1 - TensorPrimitives.CosineSimilarity(embeddings[i].Vector.Span, embeddings[i + 1].Vector.Span); + elementDistances[i] = (elementDistances[i].element, distance); + } + } + + return elementDistances; + } + + private IEnumerable> MakeChunks(IngestionDocument document, List<(IngestionDocumentElement element, float distance)> elementDistances) + { + float distanceThreshold = Percentile(elementDistances); + + List elementAccumulator = []; + string context = string.Empty; + for (int i = 0; i < elementDistances.Count; i++) + { + var (element, distance) = elementDistances[i]; + + elementAccumulator.Add(element); + if (distance > distanceThreshold || i == elementDistances.Count - 1) + { + foreach (var chunk in _elementsChunker.Process(document, context, elementAccumulator)) + { + yield return chunk; + } + elementAccumulator.Clear(); + } + } + } + + private float Percentile(List<(IngestionDocumentElement element, float distance)> elementDistances) + { + if (elementDistances.Count == 0) + { + return 0f; + } + else if (elementDistances.Count == 1) + { + return elementDistances[0].distance; + } + + float[] sorted = new float[elementDistances.Count]; + for (int elementIndex = 0; elementIndex < elementDistances.Count; elementIndex++) + { + sorted[elementIndex] = elementDistances[elementIndex].distance; + } + Array.Sort(sorted); + + float i = (_thresholdPercentile / 100f) * (sorted.Length - 1); + int i0 = (int)i; + int i1 = Math.Min(i0 + 1, sorted.Length - 1); + return sorted[i0] + ((i - i0) * (sorted[i1] - sorted[i0])); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ValueStringBuilder.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ValueStringBuilder.cs new file mode 100644 index 00000000000..199d55262b3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ValueStringBuilder.cs @@ -0,0 +1,288 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Let's lie about the file being auto-generated to suppress all kinds of analyzer warnings. +// + +#nullable enable + +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace System.Text +{ + // Code from https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/System/Text/ValueStringBuilder.cs + // See https://github.com/dotnet/runtime/issues/25587 for more details. + internal ref partial struct ValueStringBuilder + { + private char[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + // This is not expected to be called this with negative capacity + Debug.Assert(capacity >= 0); + + // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception. + if ((uint)capacity > (uint)_chars.Length) + Grow(capacity - _pos); + } + + /// + /// Ensures that the builder is terminated with a NUL character. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void NullTerminate() + { + EnsureCapacity(_pos + 1); + _chars[_pos] = '\0'; + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public ref char GetPinnableReference() + { + return ref MemoryMarshal.GetReference(_chars); + } + + public ref char this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + public override string ToString() + { + string s = _chars.Slice(0, _pos).ToString(); + Dispose(); + return s; + } + + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; + + public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos); + public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start); + public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public void Insert(int index, char value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + public void Insert(int index, string? s) + { + if (s == null) + { + return; + } + + int count = s.Length; + + if (_pos > (_chars.Length - count)) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(index)); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c) + { + int pos = _pos; + Span chars = _chars; + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = c; + _pos = pos + 1; + } + else + { + GrowAndAppend(c); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? s) + { + if (s == null) + { + return; + } + + int pos = _pos; + if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } + else + { + AppendSlow(s); + } + } + + private void AppendSlow(string s) + { + int pos = _pos; + if (pos > _chars.Length - s.Length) + { + Grow(s.Length); + } + + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(pos)); + _pos += s.Length; + } + + public void Append(char c, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + Span dst = _chars.Slice(_pos, count); + for (int i = 0; i < dst.Length; i++) + { + dst[i] = c; + } + _pos += count; + } + + public void Append(scoped ReadOnlySpan value) + { + int pos = _pos; + if (pos > _chars.Length - value.Length) + { + Grow(value.Length); + } + + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) + { + Grow(length); + } + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(char c) + { + Grow(1); + Append(c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + Debug.Assert(additionalCapacityBeyondPos > 0); + Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try + // to double the size if possible, bounding the doubling to not go beyond the max array length. + int newCapacity = (int)Math.Max( + (uint)(_pos + additionalCapacityBeyondPos), + Math.Min((uint)_chars.Length * 2, ArrayMaxLength)); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. + // This could also go negative if the actual required length wraps around. + char[] poolArray = ArrayPool.Shared.Rent(newCapacity); + + _chars.Slice(0, _pos).CopyTo(poolArray); + + char[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + char[]? toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/DiagnosticsConstants.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/DiagnosticsConstants.cs new file mode 100644 index 00000000000..4251bef6ae3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/DiagnosticsConstants.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.DataIngestion; + +internal static class DiagnosticsConstants +{ + internal const string ActivitySourceName = "Experimental.Microsoft.Extensions.DataIngestion"; + internal const string ErrorTypeTagName = "error.type"; + + internal static class ProcessDirectory + { + internal const string ActivityName = "ProcessDirectory"; + internal const string DirectoryPathTagName = "rag.directory.path"; + internal const string SearchPatternTagName = "rag.directory.search.pattern"; + internal const string SearchOptionTagName = "rag.directory.search.option"; + } + + internal static class ProcessFiles + { + internal const string ActivityName = "ProcessFiles"; + internal const string FileCountTagName = "rag.file.count"; + } + + internal static class ProcessSource + { + internal const string DocumentIdTagName = "rag.document.id"; + } + + internal static class ProcessFile + { + internal const string ActivityName = "ProcessFile"; + internal const string FilePathTagName = "rag.file.path"; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs new file mode 100644 index 00000000000..1eeb94058ee --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs @@ -0,0 +1,195 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.DataIngestion.DiagnosticsConstants; + +namespace Microsoft.Extensions.DataIngestion; + +#pragma warning disable IDE0058 // Expression value is never used +#pragma warning disable IDE0063 // Use simple 'using' statement +#pragma warning disable CA1031 // Do not catch general exception types + +/// +/// Represents a pipeline for ingesting data from documents and processing it into chunks. +/// +/// The type of the chunk content. +public sealed class IngestionPipeline : IDisposable +{ + private readonly IngestionDocumentReader _reader; + private readonly IngestionChunker _chunker; + private readonly IngestionChunkWriter _writer; + private readonly ActivitySource _activitySource; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The reader for ingestion documents. + /// The chunker to split documents into chunks. + /// The writer for processing chunks. + /// The options for the ingestion pipeline. + /// The logger factory for creating loggers. + public IngestionPipeline( + IngestionDocumentReader reader, + IngestionChunker chunker, + IngestionChunkWriter writer, + IngestionPipelineOptions? options = default, + ILoggerFactory? loggerFactory = default) + { + _reader = Throw.IfNull(reader); + _chunker = Throw.IfNull(chunker); + _writer = Throw.IfNull(writer); + _activitySource = new((options ?? new()).ActivitySourceName); + _logger = loggerFactory?.CreateLogger>(); + } + + /// + public void Dispose() + { + _writer.Dispose(); + _activitySource.Dispose(); + } + + /// + /// Gets the document processors in the pipeline. + /// + public IList DocumentProcessors { get; } = []; + + /// + /// Gets the chunk processors in the pipeline. + /// + public IList> ChunkProcessors { get; } = []; + + /// + /// Processes all files in the specified directory that match the given search pattern and option. + /// + /// The directory to process. + /// The search pattern for file selection. + /// The search option for directory traversal. + /// The cancellation token for the operation. + /// A task representing the asynchronous operation. + public async IAsyncEnumerable ProcessAsync(DirectoryInfo directory, string searchPattern = "*.*", + SearchOption searchOption = SearchOption.TopDirectoryOnly, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(directory); + Throw.IfNullOrEmpty(searchPattern); + Throw.IfOutOfRange((int)searchOption, (int)SearchOption.TopDirectoryOnly, (int)SearchOption.AllDirectories); + + using (Activity? rootActivity = _activitySource.StartActivity(ProcessDirectory.ActivityName)) + { + rootActivity?.SetTag(ProcessDirectory.DirectoryPathTagName, directory.FullName) + .SetTag(ProcessDirectory.SearchPatternTagName, searchPattern) + .SetTag(ProcessDirectory.SearchOptionTagName, searchOption.ToString()); + _logger?.ProcessingDirectory(directory.FullName, searchPattern, searchOption); + + await foreach (var ingestionResult in ProcessAsync(directory.EnumerateFiles(searchPattern, searchOption), rootActivity, cancellationToken).ConfigureAwait(false)) + { + yield return ingestionResult; + } + } + } + + /// + /// Processes the specified files. + /// + /// The collection of files to process. + /// The cancellation token for the operation. + /// A task representing the asynchronous operation. + public async IAsyncEnumerable ProcessAsync(IEnumerable files, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(files); + + using (Activity? rootActivity = _activitySource.StartActivity(ProcessFiles.ActivityName)) + { + await foreach (var ingestionResult in ProcessAsync(files, rootActivity, cancellationToken).ConfigureAwait(false)) + { + yield return ingestionResult; + } + } + } + + private static string GetShortName(object any) => any.GetType().Name; + + private static void TraceException(Activity? activity, Exception ex) + { + activity?.SetTag(ErrorTypeTagName, ex.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, ex.Message); + } + + private async IAsyncEnumerable ProcessAsync(IEnumerable files, Activity? rootActivity, + [EnumeratorCancellation] CancellationToken cancellationToken) + { +#if NET + if (System.Linq.Enumerable.TryGetNonEnumeratedCount(files, out int count)) +#else + if (files is IReadOnlyCollection { Count: int count }) +#endif + { + rootActivity?.SetTag(ProcessFiles.FileCountTagName, count); + _logger?.LogFileCount(count); + } + + foreach (FileInfo fileInfo in files) + { + using (Activity? processFileActivity = _activitySource.StartActivity(ProcessFile.ActivityName, ActivityKind.Internal, parentContext: rootActivity?.Context ?? default)) + { + processFileActivity?.SetTag(ProcessFile.FilePathTagName, fileInfo.FullName); + _logger?.ReadingFile(fileInfo.FullName, GetShortName(_reader)); + + IngestionDocument? document = null; + Exception? failure = null; + try + { + document = await _reader.ReadAsync(fileInfo, cancellationToken).ConfigureAwait(false); + + processFileActivity?.SetTag(ProcessSource.DocumentIdTagName, document.Identifier); + _logger?.ReadDocument(document.Identifier); + + document = await IngestAsync(document, processFileActivity, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + TraceException(processFileActivity, ex); + _logger?.IngestingFailed(ex, document?.Identifier ?? fileInfo.FullName); + + failure = ex; + } + + string documentId = document?.Identifier ?? fileInfo.FullName; + yield return new IngestionResult(documentId, document, failure); + } + } + } + + private async Task IngestAsync(IngestionDocument document, Activity? parentActivity, CancellationToken cancellationToken) + { + foreach (IngestionDocumentProcessor processor in DocumentProcessors) + { + document = await processor.ProcessAsync(document, cancellationToken).ConfigureAwait(false); + + // A DocumentProcessor might change the document identifier (for example by extracting it from its content), so update the ID tag. + parentActivity?.SetTag(ProcessSource.DocumentIdTagName, document.Identifier); + } + + IAsyncEnumerable> chunks = _chunker.ProcessAsync(document, cancellationToken); + foreach (var processor in ChunkProcessors) + { + chunks = processor.ProcessAsync(chunks, cancellationToken); + } + + _logger?.WritingChunks(GetShortName(_writer)); + await _writer.WriteAsync(chunks, cancellationToken).ConfigureAwait(false); + _logger?.WroteChunks(document.Identifier); + + return document; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipelineOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipelineOptions.cs new file mode 100644 index 00000000000..3b30e616b5f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipelineOptions.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +#pragma warning disable SA1500 // Braces for multi-line statements should not share line +#pragma warning disable SA1513 // Closing brace should be followed by blank line + +/// +/// Options for configuring the ingestion pipeline. +/// +public sealed class IngestionPipelineOptions +{ + /// + /// Gets or sets the name of the used for diagnostics. + /// + public string ActivitySourceName + { + get; + set => field = Throw.IfNullOrEmpty(value); + } = DiagnosticsConstants.ActivitySourceName; + + internal IngestionPipelineOptions Clone() => new() + { + ActivitySourceName = ActivitySourceName, + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs new file mode 100644 index 00000000000..1a4e57ea3b8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents the result of an ingestion operation. +/// +public sealed class IngestionResult +{ + /// + /// Gets the ID of the document that was ingested. + /// + public string DocumentId { get; } + + /// + /// Gets the ingestion document created from the source file, if reading the document has succeeded. + /// + public IngestionDocument? Document { get; } + + /// + /// Gets the exception that occurred during ingestion, if any. + /// + public Exception? Exception { get; } + + /// + /// Gets a value indicating whether the ingestion succeeded. + /// + public bool Succeeded => Exception is null; + + internal IngestionResult(string documentId, IngestionDocument? document, Exception? exception) + { + DocumentId = Throw.IfNullOrEmpty(documentId); + Document = document; + Exception = exception; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs new file mode 100644 index 00000000000..58732e8ead7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +#pragma warning disable S109 // Magic numbers should not be used + +namespace Microsoft.Extensions.DataIngestion +{ + internal static partial class Log + { + [LoggerMessage(0, LogLevel.Information, "Starting to process files in directory '{directory}' with search pattern '{searchPattern}' and search option '{searchOption}'.")] + internal static partial void ProcessingDirectory(this ILogger logger, string directory, string searchPattern, System.IO.SearchOption searchOption); + + [LoggerMessage(1, LogLevel.Information, "Processing {fileCount} files.")] + internal static partial void LogFileCount(this ILogger logger, int fileCount); + + [LoggerMessage(2, LogLevel.Information, "Reading file '{filePath}' using '{reader}'.")] + internal static partial void ReadingFile(this ILogger logger, string filePath, string reader); + + [LoggerMessage(3, LogLevel.Information, "Read document '{documentId}'.")] + internal static partial void ReadDocument(this ILogger logger, string documentId); + + [LoggerMessage(4, LogLevel.Information, "Writing chunks using {writer}.")] + internal static partial void WritingChunks(this ILogger logger, string writer); + + [LoggerMessage(5, LogLevel.Information, "Wrote chunks for document '{documentId}'.")] + internal static partial void WroteChunks(this ILogger logger, string documentId); + + [LoggerMessage(6, LogLevel.Error, "An error occurred while ingesting document '{identifier}'.")] + internal static partial void IngestingFailed(this ILogger logger, Exception exception, string identifier); + + [LoggerMessage(7, LogLevel.Error, "The AI chat service returned {resultCount} instead of {expectedCount} results.")] + internal static partial void UnexpectedResultsCount(this ILogger logger, int resultCount, int expectedCount); + + [LoggerMessage(8, LogLevel.Error, "Unexpected enricher failure.")] + internal static partial void UnexpectedEnricherFailure(this ILogger logger, Exception exception); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj new file mode 100644 index 00000000000..b7515183a86 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj @@ -0,0 +1,31 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Data Ingestion utilities for RAG. + RAG + RAG;ingestion;documents + true + false + true + preview + false + 75 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs new file mode 100644 index 00000000000..ad7b7d645d6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches document chunks with a classification label based on their content. +/// +/// This class uses a chat-based language model to analyze the content of document chunks and assign a +/// single, most relevant classification label. The classification is performed using a predefined set of classes, with +/// an optional fallback class for cases where no suitable classification can be determined. +public sealed class ClassificationEnricher : IngestionChunkProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for the classification enricher. + /// The set of predefined classification classes. + /// The fallback class to use when no suitable classification is found. When not provided, it defaults to "Unknown". + public ClassificationEnricher(EnricherOptions options, ReadOnlySpan predefinedClasses, + string? fallbackClass = null) + { + _options = Throw.IfNull(options).Clone(); + if (string.IsNullOrWhiteSpace(fallbackClass)) + { + fallbackClass = "Unknown"; + } + + Validate(predefinedClasses, fallbackClass!); + _systemPrompt = CreateSystemPrompt(predefinedClasses, fallbackClass!); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the classification. + /// + public static string MetadataKey => "classification"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); + + private static void Validate(ReadOnlySpan predefinedClasses, string fallbackClass) + { + if (predefinedClasses.Length == 0) + { + Throw.ArgumentException(nameof(predefinedClasses), "Predefined classes must be provided."); + } + + HashSet predefinedClassesSet = new(StringComparer.Ordinal) { fallbackClass }; + foreach (string predefinedClass in predefinedClasses) + { + if (!predefinedClassesSet.Add(predefinedClass)) + { + if (predefinedClass.Equals(fallbackClass, StringComparison.Ordinal)) + { + Throw.ArgumentException(nameof(predefinedClasses), $"Fallback class '{fallbackClass}' must not be one of the predefined classes."); + } + + Throw.ArgumentException(nameof(predefinedClasses), $"Duplicate class found: '{predefinedClass}'."); + } + } + } + + private static ChatMessage CreateSystemPrompt(ReadOnlySpan predefinedClasses, string fallbackClass) + { + StringBuilder sb = new("You are a classification expert. For each of the following texts, assign a single, most relevant class. Use only the following predefined classes: "); + +#if NET9_0_OR_GREATER + sb.AppendJoin(", ", predefinedClasses!); +#else +#pragma warning disable IDE0058 // Expression value is never used + for (int i = 0; i < predefinedClasses.Length; i++) + { + sb.Append(predefinedClasses[i]); + if (i < predefinedClasses.Length - 1) + { + sb.Append(", "); + } + } +#endif + sb.Append(" and return ").Append(fallbackClass).Append(" when unable to classify."); +#pragma warning restore IDE0058 // Expression value is never used + + return new(ChatRole.System, sb.ToString()); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs new file mode 100644 index 00000000000..182e07d9c1f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents options for enrichers that use an AI chat client. +/// +public class EnricherOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The AI chat client to be used. + public EnricherOptions(IChatClient chatClient) + { + ChatClient = Throw.IfNull(chatClient); + } + + /// + /// Gets the AI chat client to be used. + /// + public IChatClient ChatClient { get; } + + /// + /// Gets or sets the options for the . + /// + public ChatOptions? ChatOptions { get; set; } + + /// + /// Gets or sets the logger factory to be used for logging. + /// + /// + /// Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + /// This logger factory can be used to create loggers to log such failures. + /// + public ILoggerFactory? LoggerFactory { get; set; } + + /// + /// Gets or sets the batch size for processing chunks. Default is 20. + /// + public int BatchSize { get; set => field = Throw.IfLessThanOrEqual(value, 0); } = 20; + + internal EnricherOptions Clone() => new(ChatClient) + { + ChatOptions = ChatOptions?.Clone(), + LoggerFactory = LoggerFactory, + BatchSize = BatchSize + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs new file mode 100644 index 00000000000..b133e0fa31a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches elements with alternative text using an AI service, +/// so the generated embeddings can include the image content information. +/// +public sealed class ImageAlternativeTextEnricher : IngestionDocumentProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for generating alternative text. + public ImageAlternativeTextEnricher(EnricherOptions options) + { + _options = Throw.IfNull(options).Clone(); + _systemPrompt = new(ChatRole.System, "For each of the following images, write a detailed alternative text with fewer than 50 words."); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + public override async Task ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(document); + + List? batch = null; + + foreach (var element in document.EnumerateContent()) + { + if (element is IngestionDocumentImage image) + { + if (ShouldProcess(image)) + { + batch ??= new(_options.BatchSize); + batch.Add(image); + + if (batch.Count == _options.BatchSize) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + batch.Clear(); + } + } + } + else if (element is IngestionDocumentTable table) + { + foreach (var cell in table.Cells) + { + if (cell is IngestionDocumentImage cellImage && ShouldProcess(cellImage)) + { + batch ??= new(_options.BatchSize); + batch.Add(cellImage); + + if (batch.Count == _options.BatchSize) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + batch.Clear(); + } + } + } + } + } + + if (batch?.Count > 0) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + } + + return document; + } + + private static bool ShouldProcess(IngestionDocumentImage img) => + img.Content.HasValue && !string.IsNullOrEmpty(img.MediaType) && string.IsNullOrEmpty(img.AlternativeText); + + private async Task ProcessAsync(List batch, CancellationToken cancellationToken) + { + List contents = new(batch.Count); + foreach (var image in batch) + { + contents.Add(new DataContent(image.Content!.Value, image.MediaType!)); + } + + try + { + ChatResponse response = await _options.ChatClient.GetResponseAsync( + [_systemPrompt, new(ChatRole.User, contents)], + _options.ChatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (response.Result.Length == contents.Count) + { + for (int i = 0; i < response.Result.Length; i++) + { + batch[i].AlternativeText = response.Result[i]; + } + } + else + { + _logger?.UnexpectedResultsCount(response.Result.Length, contents.Count); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + // Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + _logger?.UnexpectedEnricherFailure(ex); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs new file mode 100644 index 00000000000..c12c805544d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches chunks with keyword extraction using an AI chat model. +/// +/// +/// It adds "keywords" metadata to each chunk. It's an array of strings representing the extracted keywords. +/// +public sealed class KeywordEnricher : IngestionChunkProcessor +{ + private const int DefaultMaxKeywords = 5; + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for generating keywords. + /// The set of predefined keywords for extraction. + /// The maximum number of keywords to extract. When not provided, it defaults to 5. + /// The confidence threshold for keyword inclusion. When not provided, it defaults to 0.7. + /// + /// If no predefined keywords are provided, the model will extract keywords based on the content alone. + /// Such results may vary more significantly between different AI models. + /// + public KeywordEnricher(EnricherOptions options, ReadOnlySpan predefinedKeywords, + int? maxKeywords = null, double? confidenceThreshold = null) + { + _options = Throw.IfNull(options).Clone(); + Validate(predefinedKeywords); + + double threshold = confidenceThreshold.HasValue + ? Throw.IfOutOfRange(confidenceThreshold.Value, 0.0, 1.0, nameof(confidenceThreshold)) + : 0.7; + int keywordsCount = maxKeywords.HasValue + ? Throw.IfLessThanOrEqual(maxKeywords.Value, 0, nameof(maxKeywords)) + : DefaultMaxKeywords; + _systemPrompt = CreateSystemPrompt(keywordsCount, predefinedKeywords, threshold); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the keywords. + /// + public static string MetadataKey => "keywords"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); + + private static void Validate(ReadOnlySpan predefinedKeywords) + { + if (predefinedKeywords.Length == 0) + { + return; + } + + HashSet result = new(StringComparer.Ordinal); + foreach (string keyword in predefinedKeywords) + { + if (!result.Add(keyword)) + { + Throw.ArgumentException(nameof(predefinedKeywords), $"Duplicate keyword found: '{keyword}'"); + } + } + } + + private static ChatMessage CreateSystemPrompt(int maxKeywords, ReadOnlySpan predefinedKeywords, double confidenceThreshold) + { + StringBuilder sb = new($"You are a keyword extraction expert. For each of the following texts, extract up to {maxKeywords} most relevant keywords. "); + + if (predefinedKeywords.Length > 0) + { +#pragma warning disable IDE0058 // Expression value is never used + sb.Append("Focus on extracting keywords from the following predefined list: "); +#if NET9_0_OR_GREATER + sb.AppendJoin(", ", predefinedKeywords!); +#else + for (int i = 0; i < predefinedKeywords.Length; i++) + { + sb.Append(predefinedKeywords[i]); + if (i < predefinedKeywords.Length - 1) + { + sb.Append(", "); + } + } +#endif + + sb.Append(". "); + } + + sb.Append("Exclude keywords with confidence score below ").Append(confidenceThreshold).Append('.'); +#pragma warning restore IDE0058 // Expression value is never used + + return new(ChatRole.System, sb.ToString()); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SentimentEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SentimentEnricher.cs new file mode 100644 index 00000000000..985451970a9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SentimentEnricher.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches chunks with sentiment analysis using an AI chat model. +/// +/// +/// It adds "sentiment" metadata to each chunk. It can be Positive, Negative, Neutral or Unknown when confidence score is below the threshold. +/// +public sealed class SentimentEnricher : IngestionChunkProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for sentiment analysis. + /// The confidence threshold for sentiment determination. When not provided, it defaults to 0.7. + public SentimentEnricher(EnricherOptions options, double? confidenceThreshold = null) + { + _options = Throw.IfNull(options).Clone(); + + double threshold = confidenceThreshold.HasValue ? Throw.IfOutOfRange(confidenceThreshold.Value, 0.0, 1.0, nameof(confidenceThreshold)) : 0.7; + + string prompt = $""" + You are a sentiment analysis expert. For each of the following texts, analyze the sentiment and return Positive/Negative/Neutral or + Unknown when confidence score is below {threshold}. + """; + _systemPrompt = new(ChatRole.System, prompt); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the sentiment. + /// + public static string MetadataKey => "sentiment"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs new file mode 100644 index 00000000000..7e2da4d12f5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches chunks with summary text using an AI chat model. +/// +/// +/// It adds "summary" text metadata to each chunk. +/// +public sealed class SummaryEnricher : IngestionChunkProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for summary generation. + /// The maximum number of words for the summary. When not provided, it defaults to 100. + public SummaryEnricher(EnricherOptions options, int? maxWordCount = null) + { + _options = Throw.IfNull(options).Clone(); + + int wordCount = maxWordCount.HasValue ? Throw.IfLessThanOrEqual(maxWordCount.Value, 0, nameof(maxWordCount)) : 100; + _systemPrompt = new(ChatRole.System, $"For each of the following texts, write a summary text with no more than {wordCount} words."); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the summary. + /// + public static string MetadataKey => "summary"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion/README.md new file mode 100644 index 00000000000..9886465cff6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/README.md @@ -0,0 +1,34 @@ +# Microsoft.Extensions.DataIngestion + +.NET developers need to efficiently process, chunk, and retrieve information from diverse document formats while preserving semantic meaning and structural context. The `Microsoft.Extensions.DataIngestion` libraries provide a unified approach for representing document ingestion components. + +## The packages + +The [Microsoft.Extensions.DataIngestion.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion.Abstractions) package provides the core exchange types, including [`IngestionDocument`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestiondocument), [`IngestionChunker`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunker-1), [`IngestionChunkProcessor`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkprocessor-1), and [`IngestionChunkWriter`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkwriter-1). Any .NET library that provides document processing capabilities can implement these abstractions to enable seamless integration with consuming code. + +The [Microsoft.Extensions.DataIngestion](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion) package has an implicit dependency on the `Microsoft.Extensions.DataIngestion.Abstractions` package. This package enables you to easily integrate components such as enrichment processors, vector storage writers, and telemetry into your applications using familiar dependency injection and pipeline patterns. For example, it provides the [`SentimentEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.sentimentenricher), [`KeywordEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.keywordenricher), and [`SummaryEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.summaryenricher) processors that can be chained together in ingestion pipelines. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.DataIngestion.Abstractions`. + +To also have access to higher-level utilities for working with document ingestion components, reference the `Microsoft.Extensions.DataIngestion` package instead (which itself references `Microsoft.Extensions.DataIngestion.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.DataIngestion` package along with one or more libraries that provide concrete implementations of the abstractions, such as `Microsoft.Extensions.DataIngestion.MarkItDown` or `Microsoft.Extensions.DataIngestion.Markdig`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion --prerelease +``` +Or directly in the C# project file: + +```xml + + + +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs new file mode 100644 index 00000000000..b210019401b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +#if NET10_0_OR_GREATER +using System.Linq; +#endif +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class Batching +{ + internal static async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, + EnricherOptions options, + string metadataKey, + ChatMessage systemPrompt, + ILogger? logger, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TMetadata : notnull + { + _ = Throw.IfNull(chunks); + + await foreach (var batch in chunks.Chunk(options.BatchSize).WithCancellation(cancellationToken)) + { + List contents = new(batch.Length); + foreach (var chunk in batch) + { + contents.Add(new TextContent(chunk.Content)); + } + + try + { + ChatResponse response = await options.ChatClient.GetResponseAsync( + [ + systemPrompt, + new(ChatRole.User, contents) + ], options.ChatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (response.Result.Length == contents.Count) + { + for (int i = 0; i < response.Result.Length; i++) + { + batch[i].Metadata[metadataKey] = response.Result[i]; + } + } + else + { + logger?.UnexpectedResultsCount(response.Result.Length, contents.Count); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + // Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + logger?.UnexpectedEnricherFailure(ex); + } + + foreach (var chunk in batch) + { + yield return chunk; + } + } + } + +#if !NET10_0_OR_GREATER +#pragma warning disable VSTHRD200 // Use "Async" suffix for async methods + private static IAsyncEnumerable Chunk(this IAsyncEnumerable source, int count) +#pragma warning restore VSTHRD200 // Use "Async" suffix for async methods + { + _ = Throw.IfNull(source); + _ = Throw.IfLessThanOrEqual(count, 0); + + return CoreAsync(source, count); + + static async IAsyncEnumerable CoreAsync(IAsyncEnumerable source, int count, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var buffer = new TSource[count]; + int index = 0; + + await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + buffer[index++] = item; + + if (index == count) + { + index = 0; + yield return buffer; + } + } + + if (index > 0) + { + Array.Resize(ref buffer, index); + yield return buffer; + } + } + } +#endif +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs new file mode 100644 index 00000000000..f231ac6535b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs @@ -0,0 +1,193 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.VectorData; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Writes chunks to the using the default schema. +/// +/// The type of the chunk content. +public sealed class VectorStoreWriter : IngestionChunkWriter +{ + // The names are lowercase with no special characters to ensure compatibility with various vector stores. + private const string KeyName = "key"; + private const string EmbeddingName = "embedding"; + private const string ContentName = "content"; + private const string ContextName = "context"; + private const string DocumentIdName = "documentid"; + + private readonly VectorStore _vectorStore; + private readonly int _dimensionCount; + private readonly VectorStoreWriterOptions _options; + + private VectorStoreCollection>? _vectorStoreCollection; + + /// + /// Initializes a new instance of the class. + /// + /// The to use to store the instances. + /// The number of dimensions that the vector has. This value is required when creating collections. + /// The options for the vector store writer. + /// When is null. + /// When is less or equal zero. + public VectorStoreWriter(VectorStore vectorStore, int dimensionCount, VectorStoreWriterOptions? options = default) + { + _vectorStore = Throw.IfNull(vectorStore); + _dimensionCount = Throw.IfLessThanOrEqual(dimensionCount, 0); + _options = options ?? new VectorStoreWriterOptions(); + } + + /// + /// Gets the underlying used to store the chunks. + /// + /// + /// The collection is initialized when is called for the first time. + /// + /// The collection has not been initialized yet. + /// Call first. + public VectorStoreCollection> VectorStoreCollection + => _vectorStoreCollection ?? throw new InvalidOperationException("The collection has not been initialized yet. Call WriteAsync first."); + + /// + public override async Task WriteAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chunks); + + IReadOnlyList? preExistingKeys = null; + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken)) + { + if (_vectorStoreCollection is null) + { + _vectorStoreCollection = _vectorStore.GetDynamicCollection(_options.CollectionName, GetVectorStoreRecordDefinition(chunk)); + + await _vectorStoreCollection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + } + + // We obtain the IDs of the pre-existing chunks for given document, + // and delete them after we finish inserting the new chunks, + // to avoid a situation where we delete the chunks and then fail to insert the new ones. + preExistingKeys ??= await GetPreExistingChunksIdsAsync(chunk.Document, cancellationToken).ConfigureAwait(false); + + var key = Guid.NewGuid(); + Dictionary record = new() + { + [KeyName] = key, + [ContentName] = chunk.Content, + [EmbeddingName] = chunk.Content, + [ContextName] = chunk.Context, + [DocumentIdName] = chunk.Document.Identifier, + }; + + if (chunk.HasMetadata) + { + foreach (var metadata in chunk.Metadata) + { + record[metadata.Key] = metadata.Value; + } + } + + await _vectorStoreCollection.UpsertAsync(record, cancellationToken).ConfigureAwait(false); + } + + if (preExistingKeys?.Count > 0) + { + await _vectorStoreCollection!.DeleteAsync(preExistingKeys, cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override void Dispose(bool disposing) + { + try + { + _vectorStoreCollection?.Dispose(); + } + finally + { + _vectorStore.Dispose(); + base.Dispose(disposing); + } + } + + private VectorStoreCollectionDefinition GetVectorStoreRecordDefinition(IngestionChunk representativeChunk) + { + VectorStoreCollectionDefinition definition = new() + { + Properties = + { + new VectorStoreKeyProperty(KeyName, typeof(Guid)), + + // By using T as the type here we allow the vector store + // to handle the conversion from T to the actual vector type it supports. + new VectorStoreVectorProperty(EmbeddingName, typeof(T), _dimensionCount) + { + DistanceFunction = _options.DistanceFunction, + IndexKind = _options.IndexKind + }, + new VectorStoreDataProperty(ContentName, typeof(T)), + new VectorStoreDataProperty(ContextName, typeof(string)), + new VectorStoreDataProperty(DocumentIdName, typeof(string)) + { + IsIndexed = true + } + } + }; + + if (representativeChunk.HasMetadata) + { + foreach (var metadata in representativeChunk.Metadata) + { + Type propertyType = metadata.Value.GetType(); + definition.Properties.Add(new VectorStoreDataProperty(metadata.Key, propertyType) + { + // We use lowercase storage names to ensure compatibility with various vector stores. +#pragma warning disable CA1308 // Normalize strings to uppercase + StorageName = metadata.Key.ToLowerInvariant() +#pragma warning restore CA1308 // Normalize strings to uppercase + + // We could consider indexing for certain keys like classification etc. but for now we leave it as non-indexed. + // The reason is that not every DB supports it, moreover we would need to expose the ability to configure it. + }); + } + } + + return definition; + } + + private async Task> GetPreExistingChunksIdsAsync(IngestionDocument document, CancellationToken cancellationToken) + { + if (!_options.IncrementalIngestion) + { + return []; + } + + // Each Vector Store has a different max top count limit, so we use low value and loop. + const int MaxTopCount = 1_000; + + List keys = []; + int insertedCount; + do + { + insertedCount = 0; + + await foreach (var record in _vectorStoreCollection!.GetAsync( + filter: record => (string)record[DocumentIdName]! == document.Identifier, + top: MaxTopCount, + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + keys.Add(record[KeyName]!); + insertedCount++; + } + } + while (insertedCount == MaxTopCount); + + return keys; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriterOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriterOptions.cs new file mode 100644 index 00000000000..cbc2036061a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriterOptions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents options for the . +/// +public sealed class VectorStoreWriterOptions +{ + /// + /// Gets or sets the name of the collection. When not provided, "chunks" will be used. + /// + public string CollectionName + { + get => field ?? "chunks"; + set => field = Throw.IfNullOrEmpty(value); + } + + /// + /// Gets or sets the distance function to use when creating the collection. + /// + /// + /// When not provided, the default specific to given database will be used. Check for available values. + /// + public string? DistanceFunction { get; set; } + + /// + /// Gets or sets the index kind to use when creating the collection. + /// + public string? IndexKind { get; set; } + + /// + /// Gets or sets a value indicating whether to perform incremental ingestion. + /// + /// + /// When enabled, the writer will delete the chunks for the given document after inserting the new ones. + /// Effectively the ingestion will "replace" the existing chunks for the document with the new ones. + /// + public bool IncrementalIngestion { get; set; } = true; +} diff --git a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs index 5cb40f05a2e..c2c0581e6c3 100644 --- a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj index 7d02c3f1e90..5dd62090e1e 100644 --- a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj +++ b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj @@ -1,6 +1,7 @@ Microsoft.Extensions.DependencyInjection + $(NetCoreTargetFrameworks);netstandard2.0;net462 Extensions to auto-activate registered singletons in the dependency injection system. Fundamentals diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs index 28d65ba84f3..2dd3438a960 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Shared.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs index a7a08eb4d3e..fe7d146c579 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs @@ -14,7 +14,7 @@ public interface IExceptionSummarizationBuilder /// /// Gets the service collection into which the summary provider instances are registered. /// - public IServiceCollection Services { get; } + IServiceCollection Services { get; } /// /// Adds a summary provider to the builder. diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs index c9c4c4ef48a..3087bded812 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs @@ -15,5 +15,5 @@ public interface IExceptionSummarizer /// /// The exception to summarize. /// The summary of the given . - public ExceptionSummary Summarize(Exception exception); + ExceptionSummary Summarize(Exception exception); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs index 24cfd0d079e..000e34ac0c7 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs @@ -26,15 +26,15 @@ public interface IExceptionSummaryProvider /// This method should only get invoked with an exception which is type compatible with a type /// described by . /// - public int Describe(Exception exception, out string? additionalDetails); + int Describe(Exception exception, out string? additionalDetails); /// /// Gets the set of supported exception types that can be handled by this provider. /// - public IEnumerable SupportedExceptionTypes { get; } + IEnumerable SupportedExceptionTypes { get; } /// /// Gets the set of description strings exposed by this provider. /// - public IReadOnlyList Descriptions { get; } + IReadOnlyList Descriptions { get; } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj index 0073e039f8f..4b4993a7b99 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj @@ -3,6 +3,7 @@ Microsoft.Extensions.Diagnostics.ExceptionSummarization Lets you retrieve exception summary information. Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs index 6772a33f0d3..e8164908150 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs @@ -13,7 +13,7 @@ public interface IManualHealthCheck : IDisposable /// /// Gets or sets the health status. /// - public HealthCheckResult Result { get; set; } + HealthCheckResult Result { get; set; } } /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs index 0302b1a896f..d9faa46f338 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs @@ -10,22 +10,20 @@ internal sealed class ManualHealthCheck : IManualHealthCheck { private static readonly object _lock = new(); - private HealthCheckResult _result; - public HealthCheckResult Result { get { lock (_lock) { - return _result; + return field; } } set { lock (_lock) { - _result = value; + field = value; } } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs index a3fc5f3ed76..bf97ab0ab69 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs @@ -2,14 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; namespace Microsoft.Extensions.Diagnostics.HealthChecks; /// /// Threshold settings for . /// -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "In place numbers make the ranges cleaner")] public class ResourceUsageThresholds { /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs index af777ea08ab..15ccdf52f59 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs @@ -3,7 +3,6 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Shared.Data.Validation; @@ -12,7 +11,6 @@ namespace Microsoft.Extensions.Diagnostics.Probes; /// /// Options to control TCP-based health check probes. /// -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "In place numbers make the ranges cleaner")] public class TcpEndpointProbesOptions { private const int DefaultMaxPendingConnections = 10; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ITcpStateInfoProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ITcpStateInfoProvider.cs index 0c51e5fc45a..b62d8365d70 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ITcpStateInfoProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ITcpStateInfoProvider.cs @@ -12,11 +12,11 @@ internal interface ITcpStateInfoProvider /// Gets the last known TCP/IP v4 state of the system. /// /// An instance of . - TcpStateInfo GetpIpV4TcpStateInfo(); + TcpStateInfo GetIpV4TcpStateInfo(); /// /// Gets the last known TCP/IP v6 state of the system. /// /// An instance of . - TcpStateInfo GetpIpV6TcpStateInfo(); + TcpStateInfo GetIpV6TcpStateInfo(); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStats.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStats.cs new file mode 100644 index 00000000000..5b1315e7a50 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStats.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; + +/// +/// Represents one line of statistics from "/proc/diskstats" +/// See https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats for details. +/// +internal sealed class DiskStats +{ + public int MajorNumber { get; set; } + public int MinorNumber { get; set; } + public string DeviceName { get; set; } = string.Empty; + public ulong ReadsCompleted { get; set; } + public ulong ReadsMerged { get; set; } + public ulong SectorsRead { get; set; } + public uint TimeReadingMs { get; set; } + public ulong WritesCompleted { get; set; } + public ulong WritesMerged { get; set; } + public ulong SectorsWritten { get; set; } + public uint TimeWritingMs { get; set; } + public uint IoInProgress { get; set; } + public uint TimeIoMs { get; set; } + public uint WeightedTimeIoMs { get; set; } + + // The following fields are available starting from kernel 4.18; if absent, remain 0 + public ulong DiscardsCompleted { get; set; } + public ulong DiscardsMerged { get; set; } + public ulong SectorsDiscarded { get; set; } + public uint TimeDiscardingMs { get; set; } + + // The following fields are available starting from kernel 5.5; if absent, remain 0 + public ulong FlushRequestsCompleted { get; set; } + public uint TimeFlushingMs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs new file mode 100644 index 00000000000..a95506d40fe --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Shared.Pools; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; + +/// +/// Handles reading and parsing of Linux procfs-diskstats file(/proc/diskstats). +/// +internal sealed class DiskStatsReader(IFileSystem fileSystem) : IDiskStatsReader +{ + private static readonly FileInfo _diskStatsFile = new("/proc/diskstats"); + private static readonly ObjectPool> _sharedBufferWriterPool = BufferWriterPool.CreateBufferWriterPool(); + + /// + /// Reads and returns all disk statistics entries. + /// + /// List of . + public DiskStats[] ReadAll(string[] skipDevicePrefixes) + { + var diskStatsList = new List(); + + using ReturnableBufferWriter bufferWriter = new(_sharedBufferWriterPool); + using IEnumerator> enumerableLines = fileSystem.ReadAllByLines(_diskStatsFile, bufferWriter.Buffer).GetEnumerator(); + + while (enumerableLines.MoveNext()) + { + string line = enumerableLines.Current.Trim().ToString(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + DiskStats stat = DiskStatsReader.ParseLine(line); + if (!skipDevicePrefixes.Any(prefix => + stat.DeviceName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + { + diskStatsList.Add(stat); + } + } + catch (Exception) + { + // ignore parsing errors + } + } + + return diskStatsList.ToArray(); + } + + /// + /// Parses one line of text into a DiskStats object. + /// + /// one line in "/proc/diskstats". + /// parsed DiskStats object. + private static DiskStats ParseLine(string line) + { + // Split by any whitespace and remove empty entries +#pragma warning disable EA0009 + string[] parts = line.Split(Array.Empty(), StringSplitOptions.RemoveEmptyEntries); +#pragma warning restore EA0009 + + if (parts.Length < 14) + { + throw new FormatException($"Not enough fields: expected at least 14, got {parts.Length}"); + } + + // See https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats + var diskStats = new DiskStats + { + MajorNumber = int.Parse(parts[0], CultureInfo.InvariantCulture), + MinorNumber = int.Parse(parts[1], CultureInfo.InvariantCulture), + DeviceName = parts[2], + ReadsCompleted = ulong.Parse(parts[3], CultureInfo.InvariantCulture), + ReadsMerged = ulong.Parse(parts[4], CultureInfo.InvariantCulture), + SectorsRead = ulong.Parse(parts[5], CultureInfo.InvariantCulture), + TimeReadingMs = uint.Parse(parts[6], CultureInfo.InvariantCulture), + WritesCompleted = ulong.Parse(parts[7], CultureInfo.InvariantCulture), + WritesMerged = ulong.Parse(parts[8], CultureInfo.InvariantCulture), + SectorsWritten = ulong.Parse(parts[9], CultureInfo.InvariantCulture), + TimeWritingMs = uint.Parse(parts[10], CultureInfo.InvariantCulture), + IoInProgress = uint.Parse(parts[11], CultureInfo.InvariantCulture), + TimeIoMs = uint.Parse(parts[12], CultureInfo.InvariantCulture), + WeightedTimeIoMs = uint.Parse(parts[13], CultureInfo.InvariantCulture) + }; + + // Parse additional fields if present + if (parts.Length >= 18) + { + diskStats.DiscardsCompleted = ulong.Parse(parts[14], CultureInfo.InvariantCulture); + diskStats.DiscardsMerged = ulong.Parse(parts[15], CultureInfo.InvariantCulture); + diskStats.SectorsDiscarded = ulong.Parse(parts[16], CultureInfo.InvariantCulture); + diskStats.TimeDiscardingMs = uint.Parse(parts[17], CultureInfo.InvariantCulture); + } + + if (parts.Length >= 20) + { + diskStats.FlushRequestsCompleted = ulong.Parse(parts[18], CultureInfo.InvariantCulture); + diskStats.TimeFlushingMs = uint.Parse(parts[19], CultureInfo.InvariantCulture); + } + + return diskStats; + } +} diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/IDiskStatsReader.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/IDiskStatsReader.cs new file mode 100644 index 00000000000..d4087731401 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/IDiskStatsReader.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; + +/// +/// An interface for reading disk statistics. +/// +internal interface IDiskStatsReader +{ + /// + /// Gets all the disk statistics from the system. + /// + /// List of instances. + DiskStats[] ReadAll(string[] skipDevicePrefixes); +} diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs new file mode 100644 index 00000000000..4a7b80225ba --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs @@ -0,0 +1,189 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.IO; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Shared.Instruments; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; + +internal sealed class LinuxSystemDiskMetrics +{ + // The kernel's block layer always reports counts in 512-byte "sectors" regardless of the underlying device's real block size + // https://docs.kernel.org/block/stat.html#read-sectors-write-sectors-discard-sectors + private const int LinuxDiskSectorSize = 512; + private const int MinimumDiskStatsRefreshIntervalInSeconds = 10; + private const string DeviceKey = "system.device"; + private const string DirectionKey = "disk.io.direction"; + + // Exclude devices with these prefixes because they represent virtual, loopback, or device-mapper disks + // that do not correspond to real physical storage. Including them would distort system disk I/O metrics. + private static readonly string[] _skipDevicePrefixes = new[] { "ram", "loop", "dm-" }; + private static readonly KeyValuePair _directionReadTag = new(DirectionKey, "read"); + private static readonly KeyValuePair _directionWriteTag = new(DirectionKey, "write"); + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly IDiskStatsReader _diskStatsReader; + private readonly object _lock = new(); + private readonly FrozenDictionary _baselineDiskStatsDict = FrozenDictionary.Empty; + private readonly TimeSpan _retryInterval = TimeSpan.FromMinutes(5); + + private DateTimeOffset _lastDiskStatsFailure = DateTimeOffset.MinValue; + private bool _diskStatsUnavailable; + + private DiskStats[] _diskStatsSnapshot = []; + private DateTimeOffset _lastRefreshTime = DateTimeOffset.MinValue; + + public LinuxSystemDiskMetrics( + ILogger? logger, + IMeterFactory meterFactory, + IOptions options, + TimeProvider timeProvider, + IDiskStatsReader diskStatsReader) + { + _logger = logger ?? NullLogger.Instance; + _timeProvider = timeProvider; + _diskStatsReader = diskStatsReader; + if (!options.Value.EnableSystemDiskIoMetrics) + { + return; + } + + // We need to read the disk stats once to get the baseline values + _baselineDiskStatsDict = GetAllDiskStats().ToFrozenDictionary(d => d.DeviceName); + +#pragma warning disable CA2000 // Dispose objects before losing scope + // We don't dispose the meter because IMeterFactory handles that + // It's a false-positive, see: https://github.com/dotnet/roslyn-analyzers/issues/6912. + // Related documentation: https://github.com/dotnet/docs/pull/37170 + Meter meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); +#pragma warning restore CA2000 // Dispose objects before losing scope + + // The metric is aligned with + // https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskio + _ = meter.CreateObservableCounter( + ResourceUtilizationInstruments.SystemDiskIo, + GetDiskIoMeasurements, + unit: "By", + description: "Disk bytes transferred"); + + // The metric is aligned with + // https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskoperations + _ = meter.CreateObservableCounter( + ResourceUtilizationInstruments.SystemDiskOperations, + GetDiskOperationMeasurements, + unit: "{operation}", + description: "Disk operations"); + + // The metric is aligned with + // https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskio_time + _ = meter.CreateObservableCounter( + ResourceUtilizationInstruments.SystemDiskIoTime, + GetDiskIoTimeMeasurements, + unit: "s", + description: "Time disk spent activated"); + } + + private IEnumerable> GetDiskIoMeasurements() + { + List> measurements = []; + DiskStats[] diskStatsSnapshot = GetDiskStatsSnapshot(); + + foreach (DiskStats diskStats in diskStatsSnapshot) + { + _ = _baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats); + long readBytes = (long)(diskStats.SectorsRead - baselineDiskStats?.SectorsRead ?? 0L) * LinuxDiskSectorSize; + long writeBytes = (long)(diskStats.SectorsWritten - baselineDiskStats?.SectorsWritten ?? 0L) * LinuxDiskSectorSize; + measurements.Add(new Measurement(readBytes, new TagList { _directionReadTag, new(DeviceKey, diskStats.DeviceName) })); + measurements.Add(new Measurement(writeBytes, new TagList { _directionWriteTag, new(DeviceKey, diskStats.DeviceName) })); + } + + return measurements; + } + + private IEnumerable> GetDiskOperationMeasurements() + { + List> measurements = []; + DiskStats[] diskStatsSnapshot = GetDiskStatsSnapshot(); + + foreach (DiskStats diskStats in diskStatsSnapshot) + { + _ = _baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats); + long readCount = (long)(diskStats.ReadsCompleted - baselineDiskStats?.ReadsCompleted ?? 0L); + long writeCount = (long)(diskStats.WritesCompleted - baselineDiskStats?.WritesCompleted ?? 0L); + measurements.Add(new Measurement(readCount, new TagList { _directionReadTag, new(DeviceKey, diskStats.DeviceName) })); + measurements.Add(new Measurement(writeCount, new TagList { _directionWriteTag, new(DeviceKey, diskStats.DeviceName) })); + } + + return measurements; + } + + private IEnumerable> GetDiskIoTimeMeasurements() + { + List> measurements = []; + DiskStats[] diskStatsSnapshot = GetDiskStatsSnapshot(); + + foreach (DiskStats diskStats in diskStatsSnapshot) + { + _ = _baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats); + double ioTimeSeconds = (diskStats.TimeIoMs - baselineDiskStats?.TimeIoMs ?? 0) / 1000.0; // Convert to seconds + measurements.Add(new Measurement(ioTimeSeconds, new TagList { new(DeviceKey, diskStats.DeviceName) })); + } + + return measurements; + } + + private DiskStats[] GetDiskStatsSnapshot() + { + lock (_lock) + { + DateTimeOffset now = _timeProvider.GetUtcNow(); + if (_diskStatsSnapshot.Length == 0 || (now - _lastRefreshTime).TotalSeconds > MinimumDiskStatsRefreshIntervalInSeconds) + { + _diskStatsSnapshot = GetAllDiskStats(); + _lastRefreshTime = now; + } + } + + return _diskStatsSnapshot; + } + + private DiskStats[] GetAllDiskStats() + { + if (_diskStatsUnavailable && + _timeProvider.GetUtcNow() - _lastDiskStatsFailure < _retryInterval) + { + return Array.Empty(); + } + + try + { + DiskStats[] diskStatsList = _diskStatsReader.ReadAll(_skipDevicePrefixes); + _diskStatsUnavailable = false; + + return diskStatsList; + } + catch (Exception ex) when ( + ex is FileNotFoundException || + ex is DirectoryNotFoundException || + ex is UnauthorizedAccessException) + { + _logger.HandleDiskStatsException(ex.Message); + _lastDiskStatsFailure = _timeProvider.GetUtcNow(); + _diskStatsUnavailable = true; + } + catch (Exception ex) + { + _logger.HandleDiskStatsException(ex.Message); + } + + return Array.Empty(); + } +} diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs index af90e447df7..50886f1ceef 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; using System.IO; using Microsoft.Extensions.ObjectPool; using Microsoft.Shared.Diagnostics; @@ -151,7 +150,7 @@ public long GetHostCpuUsageInNanoseconds() $"'{_procStat}' should contain whitespace separated values according to POSIX. We've failed trying to get {i}th value. File content: '{new string(stat)}'."); } - stat = stat.Slice(next, stat.Length - next); + stat = stat.Slice(next); } return (long)(total / (double)_userHz * NanosecondsInSecond); @@ -255,8 +254,6 @@ public ulong GetMemoryUsageInBytes() return (ulong)memoryUsage; } - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "Shifting bits left by number n is multiplying the value by 2 to the power of n.")] public ulong GetHostAvailableMemory() { // The value we are interested in starts with this. We just want to make sure it is true. @@ -374,8 +371,6 @@ static void ThrowException(ReadOnlySpan content) => /// /// The input must contain only number. If there is something more than whitespace before the number, it will return failure (-1). /// - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "We are adding another digit, so we need to multiply by ten.")] private static int GetNextNumber(ReadOnlySpan buffer, out long number) { int numberStart = 0; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs index abb4230d6bc..a50a72f5d9a 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using Microsoft.Extensions.ObjectPool; @@ -131,7 +130,7 @@ public string GetCgroupPath(string filename) } // Extract the part after the last colon and cache it for future use - ReadOnlySpan trimmedPath = fileContent.Slice(colonIndex + 1); + ReadOnlySpan trimmedPath = fileContent[(colonIndex + 1)..]; _cachedCgroupPath = "/sys/fs/cgroup" + trimmedPath.ToString().TrimEnd('/') + "/"; return $"{_cachedCgroupPath}{filename}"; @@ -195,7 +194,7 @@ public long GetHostCpuUsageInNanoseconds() $"'{_procStat}' should contain whitespace separated values according to POSIX. We've failed trying to get {i}th value. File content: '{new string(stat)}'."); } - stat = stat.Slice(next, stat.Length - next); + stat = stat.Slice(next); } return (long)(total / (double)_userHz * NanosecondsInSecond); @@ -400,8 +399,6 @@ public ulong GetMemoryUsageInBytes() return (ulong)memoryUsageTotal; } - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "Shifting bits left by number n is multiplying the value by 2 to the power of n.")] public ulong GetHostAvailableMemory() { // The value we are interested in starts with this. We just want to make sure it is true. @@ -564,8 +561,6 @@ private static (long cpuUsageNanoseconds, long nrPeriods) ParseCpuUsageFromFile( /// /// The input must contain only number. If there is something more than whitespace before the number, it will return failure (-1). /// - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "We are adding another digit, so we need to multiply by ten.")] private static int GetNextNumber(ReadOnlySpan buffer, out long number) { int numberStart = 0; @@ -788,9 +783,7 @@ private static bool TryParseCpuWeightFromFile(IFileSystem fileSystem, FileInfo c // where y is the CPU pod weight (e.g. cpuPodWeight) and x is the CPU share of cgroup v1 (e.g. cpuUnits). // https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2254-cgroup-v2#phase-1-convert-from-cgroups-v1-settings-to-v2 // We invert the formula to calculate CPU share from CPU pod weight: -#pragma warning disable S109 // Magic numbers should not be used - using the formula, forgive. cpuUnits = ((cpuPodWeight - 1) * 262142 / 9999) + 2; -#pragma warning restore S109 // Magic numbers should not be used return true; } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index c6dde5c0da1..611b96f4f1d 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.Metrics; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -14,39 +16,30 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider { private const double One = 1.0; private const long Hundred = 100L; - private const double CpuLimitThreshold110Percent = 1.1; + private const double NanosecondsInSecond = 1_000_000_000; - // Meters to track CPU utilization threshold exceedances - private readonly Counter? _cpuUtilizationLimit100PercentExceededCounter; - private readonly Counter? _cpuUtilizationLimit110PercentExceededCounter; - - private readonly bool _useDeltaNrPeriods; private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); private readonly ILogger _logger; private readonly ILinuxUtilizationParser _parser; private readonly ulong _memoryLimit; + private readonly long _cpuPeriodsInterval; private readonly TimeSpan _cpuRefreshInterval; private readonly TimeSpan _memoryRefreshInterval; private readonly TimeProvider _timeProvider; - private readonly double _scaleRelativeToCpuLimit; - private readonly double _scaleRelativeToCpuRequest; private readonly double _scaleRelativeToCpuRequestForTrackerApi; + private readonly TimeSpan _retryInterval = TimeSpan.FromMinutes(5); + private DateTimeOffset _lastFailure = DateTimeOffset.MinValue; + private int _measurementsUnavailable; + private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; - - // Track the actual timestamp when we read CPU values - private DateTimeOffset _lastCpuMeasurementTime; - private double _cpuPercentage = double.NaN; private double _lastCpuCoresUsed = double.NaN; - private double _memoryPercentage; + private ulong _memoryUsage; private long _previousCgroupCpuTime; private long _previousHostCpuTime; - private long _cpuUtilizationLimit100PercentExceeded; - private long _cpuUtilizationLimit110PercentExceeded; - private long _cpuPeriodsInterval; private long _previousCgroupCpuPeriodCounter; public SystemResources Resources { get; } @@ -59,7 +52,6 @@ public LinuxUtilizationProvider(IOptions options, ILi DateTimeOffset now = _timeProvider.GetUtcNow(); _cpuRefreshInterval = options.Value.CpuConsumptionRefreshInterval; _memoryRefreshInterval = options.Value.MemoryConsumptionRefreshInterval; - _useDeltaNrPeriods = options.Value.UseDeltaNrPeriodsForCpuCalculation; _refreshAfterCpu = now; _refreshAfterMemory = now; _memoryLimit = _parser.GetAvailableMemoryInBytes(); @@ -69,8 +61,8 @@ public LinuxUtilizationProvider(IOptions options, ILi float hostCpus = _parser.GetHostCpuCount(); float cpuLimit = _parser.GetCgroupLimitedCpus(); float cpuRequest = _parser.GetCgroupRequestCpu(); - _scaleRelativeToCpuLimit = hostCpus / cpuLimit; - _scaleRelativeToCpuRequest = hostCpus / cpuRequest; + float scaleRelativeToCpuLimit = hostCpus / cpuLimit; + float scaleRelativeToCpuRequest = hostCpus / cpuRequest; _scaleRelativeToCpuRequestForTrackerApi = hostCpus; // the division by cpuRequest is performed later on in the ResourceUtilization class #pragma warning disable CA2000 // Dispose objects before losing scope @@ -80,45 +72,75 @@ public LinuxUtilizationProvider(IOptions options, ILi var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope - if (options.Value.CalculateCpuUsageWithoutHostDelta) + if (options.Value.UseLinuxCalculationV2) { cpuLimit = _parser.GetCgroupLimitV2(); - - // Try to get the CPU request from cgroup cpuRequest = _parser.GetCgroupRequestCpuV2(); // Get Cpu periods interval from cgroup _cpuPeriodsInterval = _parser.GetCgroupPeriodsIntervalInMicroSecondsV2(); (_previousCgroupCpuTime, _previousCgroupCpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); - // Initialize the counters - _cpuUtilizationLimit100PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_100_percent_exceeded"); - _cpuUtilizationLimit110PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_110_percent_exceeded"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilizationLimit(cpuLimit), unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValue: () => CpuUtilizationWithoutHostDelta() / cpuRequest, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationLimit(cpuLimit)), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationRequest(cpuRequest)), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuTime, + observeValues: GetCpuTime, + unit: "1"); } else { - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuLimit, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuRequest, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuRequest, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuLimit), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuRequest), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessCpuUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuRequest), + unit: "1"); } - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: MemoryUtilization, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessMemoryUtilization, observeValue: MemoryUtilization, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, + observeValues: () => GetMeasurementWithRetry(MemoryPercentage), + unit: "1"); + + _ = meter.CreateObservableUpDownCounter( + name: ResourceUtilizationInstruments.ContainerMemoryUsage, + observeValues: () => GetMeasurementWithRetry(() => (long)MemoryUsage()), + unit: "By", + description: "Memory usage of the container."); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessMemoryUtilization, + observeValues: () => GetMeasurementWithRetry(MemoryPercentage), + unit: "1"); // cpuRequest is a CPU request (aka guaranteed number of CPU units) for pod, for host its 1 core // cpuLimit is a CPU limit (aka max CPU units available) for a pod or for a host. // _memoryLimit - Resource Memory Limit (in k8s terms) // _memoryLimit - To keep the contract, this parameter will get the Host available memory Resources = new SystemResources(cpuRequest, cpuLimit, _memoryLimit, _memoryLimit); - Log.SystemResourcesInfo(_logger, cpuLimit, cpuRequest, _memoryLimit, _memoryLimit); + _logger.SystemResourcesInfo(cpuLimit, cpuRequest, _memoryLimit, _memoryLimit); } - public double CpuUtilizationWithoutHostDelta() + public double CpuUtilizationV2() { DateTimeOffset now = _timeProvider.GetUtcNow(); - double actualElapsedNanoseconds = (now - _lastCpuMeasurementTime).TotalNanoseconds; lock (_cpuLocker) { if (now < _refreshAfterCpu) @@ -127,79 +149,34 @@ public double CpuUtilizationWithoutHostDelta() } } - var (cpuUsageTime, cpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); + (long cpuUsageTime, long cpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); lock (_cpuLocker) { - if (now >= _refreshAfterCpu) + if (now < _refreshAfterCpu) { - long deltaCgroup = cpuUsageTime - _previousCgroupCpuTime; - double coresUsed; - - if (_useDeltaNrPeriods) - { - long deltaPeriodCount = cpuPeriodCounter - _previousCgroupCpuPeriodCounter; - long deltaCpuPeriodInNanoseconds = deltaPeriodCount * _cpuPeriodsInterval * 1000; - - if (deltaCgroup > 0 && deltaPeriodCount > 0) - { - coresUsed = deltaCgroup / (double)deltaCpuPeriodInNanoseconds; - - Log.CpuUsageDataV2(_logger, cpuUsageTime, _previousCgroupCpuTime, deltaCpuPeriodInNanoseconds, coresUsed); - - _lastCpuCoresUsed = coresUsed; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cpuUsageTime; - _previousCgroupCpuPeriodCounter = cpuPeriodCounter; - } - } - else - { - if (deltaCgroup > 0) - { - coresUsed = deltaCgroup / actualElapsedNanoseconds; - - Log.CpuUsageDataV2(_logger, cpuUsageTime, _previousCgroupCpuTime, actualElapsedNanoseconds, coresUsed); - - _lastCpuCoresUsed = coresUsed; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cpuUsageTime; - - // Update the timestamp for next calculation - _lastCpuMeasurementTime = now; - } - } + return _lastCpuCoresUsed; } - } - return _lastCpuCoresUsed; - } + long deltaCgroup = cpuUsageTime - _previousCgroupCpuTime; + long deltaPeriodCount = cpuPeriodCounter - _previousCgroupCpuPeriodCounter; - /// - /// Calculates CPU utilization relative to the CPU limit. - /// - /// The CPU limit to use for the calculation. - /// CPU usage as a ratio of the limit. - public double CpuUtilizationLimit(float cpuLimit) - { - double utilization = CpuUtilizationWithoutHostDelta() / cpuLimit; + if (deltaCgroup <= 0 || deltaPeriodCount <= 0) + { + return _lastCpuCoresUsed; + } - // Increment counter if utilization exceeds 1 (100%) - if (utilization > 1.0) - { - _cpuUtilizationLimit100PercentExceededCounter?.Add(1); - _cpuUtilizationLimit100PercentExceeded++; - Log.CounterMessage100(_logger, _cpuUtilizationLimit100PercentExceeded); - } + long deltaCpuPeriodInNanoseconds = deltaPeriodCount * _cpuPeriodsInterval * 1000; + double coresUsed = deltaCgroup / (double)deltaCpuPeriodInNanoseconds; - // Increment counter if utilization exceeds 110% - if (utilization > CpuLimitThreshold110Percent) - { - _cpuUtilizationLimit110PercentExceededCounter?.Add(1); - _cpuUtilizationLimit110PercentExceeded++; - Log.CounterMessage110(_logger, _cpuUtilizationLimit110PercentExceeded); + _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, deltaCpuPeriodInNanoseconds, coresUsed); + + _lastCpuCoresUsed = coresUsed; + _refreshAfterCpu = now.Add(_cpuRefreshInterval); + _previousCgroupCpuTime = cpuUsageTime; + _previousCgroupCpuPeriodCounter = cpuPeriodCounter; } - return utilization; + return _lastCpuCoresUsed; } public double CpuUtilization() @@ -219,29 +196,33 @@ public double CpuUtilization() lock (_cpuLocker) { - if (now >= _refreshAfterCpu) + if (now < _refreshAfterCpu) { - long deltaHost = hostCpuTime - _previousHostCpuTime; - long deltaCgroup = cgroupCpuTime - _previousCgroupCpuTime; - - if (deltaHost > 0 && deltaCgroup > 0) - { - double percentage = Math.Min(One, (double)deltaCgroup / deltaHost); + return _cpuPercentage; + } - Log.CpuUsageData(_logger, cgroupCpuTime, hostCpuTime, _previousCgroupCpuTime, _previousHostCpuTime, percentage); + long deltaHost = hostCpuTime - _previousHostCpuTime; + long deltaCgroup = cgroupCpuTime - _previousCgroupCpuTime; - _cpuPercentage = percentage; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cgroupCpuTime; - _previousHostCpuTime = hostCpuTime; - } + if (deltaHost <= 0 || deltaCgroup <= 0) + { + return _cpuPercentage; } + + double percentage = Math.Min(One, (double)deltaCgroup / deltaHost); + + _logger.CpuUsageData(cgroupCpuTime, hostCpuTime, _previousCgroupCpuTime, _previousHostCpuTime, percentage); + + _cpuPercentage = percentage; + _refreshAfterCpu = now.Add(_cpuRefreshInterval); + _previousCgroupCpuTime = cgroupCpuTime; + _previousHostCpuTime = hostCpuTime; } return _cpuPercentage; } - public double MemoryUtilization() + public ulong MemoryUsage() { DateTimeOffset now = _timeProvider.GetUtcNow(); @@ -249,26 +230,24 @@ public double MemoryUtilization() { if (now < _refreshAfterMemory) { - return _memoryPercentage; + return _memoryUsage; } } - ulong memoryUsed = _parser.GetMemoryUsageInBytes(); + ulong memoryUsage = _parser.GetMemoryUsageInBytes(); lock (_memoryLocker) { if (now >= _refreshAfterMemory) { - double memoryPercentage = Math.Min(One, (double)memoryUsed / _memoryLimit); - - _memoryPercentage = memoryPercentage; + _memoryUsage = memoryUsage; _refreshAfterMemory = now.Add(_memoryRefreshInterval); } } - Log.MemoryUsageData(_logger, memoryUsed, _memoryLimit, _memoryPercentage); + _logger.MemoryUsageData(_memoryUsage); - return _memoryPercentage; + return _memoryUsage; } /// @@ -288,4 +267,71 @@ public Snapshot GetSnapshot() userTimeSinceStart: TimeSpan.FromTicks((long)(cgroupTime / Hundred * _scaleRelativeToCpuRequestForTrackerApi)), memoryUsageInBytes: memoryUsed); } + + private double MemoryPercentage() + { + ulong memoryUsage = MemoryUsage(); + double memoryPercentage = Math.Min(One, (double)memoryUsage / _memoryLimit); + + _logger.MemoryPercentageData(memoryUsage, _memoryLimit, memoryPercentage); + return memoryPercentage; + } + + private Measurement[] GetMeasurementWithRetry(Func func) + where T : struct + { + if (!TryGetValueWithRetry(func, out T value)) + { + return Array.Empty>(); + } + + return new[] { new Measurement(value) }; + } + + private bool TryGetValueWithRetry(Func func, out T value) + where T : struct + { + value = default; + if (Volatile.Read(ref _measurementsUnavailable) == 1 && + _timeProvider.GetUtcNow() - _lastFailure < _retryInterval) + { + return false; + } + + try + { + value = func(); + _ = Interlocked.CompareExchange(ref _measurementsUnavailable, 0, 1); + + return true; + } + catch (Exception ex) when ( + ex is System.IO.FileNotFoundException || + ex is System.IO.DirectoryNotFoundException || + ex is System.UnauthorizedAccessException) + { + _lastFailure = _timeProvider.GetUtcNow(); + _ = Interlocked.Exchange(ref _measurementsUnavailable, 1); + + return false; + } + } + + // Math.Min() is used below to mitigate margin errors and various kinds of precisions losses + // due to the fact that the calculation itself is not an atomic operation: + private double CpuUtilizationRequest(double cpuRequest) => Math.Min(One, CpuUtilizationV2() / cpuRequest); + private double CpuUtilizationLimit(double cpuLimit) => Math.Min(One, CpuUtilizationV2() / cpuLimit); + + private IEnumerable> GetCpuTime() + { + if (TryGetValueWithRetry(_parser.GetHostCpuUsageInNanoseconds, out long systemCpuTime)) + { + yield return new Measurement(systemCpuTime / NanosecondsInSecond, [new KeyValuePair("cpu.mode", "system")]); + } + + if (TryGetValueWithRetry(CpuUtilizationV2, out double userCpuTime)) + { + yield return new Measurement(userCpuTime, [new KeyValuePair("cpu.mode", "user")]); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs index 918087b1b78..0721a0ff998 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs @@ -7,15 +7,12 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; [SuppressMessage("Usage", "CA1801:Review unused parameters", Justification = "Generators.")] -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Generators.")] internal static partial class Log { [LoggerMessage(1, LogLevel.Debug, -#pragma warning disable S103 // Lines should not be too long "Computed CPU usage with CgroupCpuTime = {cgroupCpuTime}, HostCpuTime = {hostCpuTime}, PreviousCgroupCpuTime = {previousCgroupCpuTime}, PreviousHostCpuTime = {previousHostCpuTime}, CpuPercentage = {cpuPercentage}.")] -#pragma warning restore S103 // Lines should not be too long public static partial void CpuUsageData( - ILogger logger, + this ILogger logger, long cgroupCpuTime, long hostCpuTime, long previousCgroupCpuTime, @@ -24,36 +21,39 @@ public static partial void CpuUsageData( [LoggerMessage(2, LogLevel.Debug, "Computed memory usage with MemoryUsedInBytes = {memoryUsed}, MemoryLimit = {memoryLimit}, MemoryPercentage = {memoryPercentage}.")] - public static partial void MemoryUsageData( - ILogger logger, + public static partial void MemoryPercentageData( + this ILogger logger, ulong memoryUsed, double memoryLimit, double memoryPercentage); [LoggerMessage(3, LogLevel.Debug, "System resources information: CpuLimit = {cpuLimit}, CpuRequest = {cpuRequest}, MemoryLimit = {memoryLimit}, MemoryRequest = {memoryRequest}.")] - public static partial void SystemResourcesInfo(ILogger logger, double cpuLimit, double cpuRequest, ulong memoryLimit, ulong memoryRequest); + public static partial void SystemResourcesInfo( + this ILogger logger, + double cpuLimit, + double cpuRequest, + ulong memoryLimit, + ulong memoryRequest); [LoggerMessage(4, LogLevel.Debug, -#pragma warning disable S103 // Lines should not be too long "For CgroupV2, Computed CPU usage with CgroupCpuTime = {cgroupCpuTime}, PreviousCgroupCpuTime = {previousCgroupCpuTime}, ActualElapsedNanoseconds = {actualElapsedNanoseconds}, CpuCores = {cpuCores}.")] -#pragma warning restore S103 // Lines should not be too long public static partial void CpuUsageDataV2( - ILogger logger, + this ILogger logger, long cgroupCpuTime, long previousCgroupCpuTime, double actualElapsedNanoseconds, double cpuCores); - [LoggerMessage(5, LogLevel.Debug, - "CPU utilization exceeded 100%: Counter = {counterValue}")] - public static partial void CounterMessage100( - ILogger logger, - long counterValue); + [LoggerMessage(5, LogLevel.Warning, + "Error while getting disk stats: Error={errorMessage}")] + public static partial void HandleDiskStatsException( + this ILogger logger, + string errorMessage); [LoggerMessage(6, LogLevel.Debug, - "CPU utilization exceeded 110%: Counter = {counterValue}")] - public static partial void CounterMessage110( - ILogger logger, - long counterValue); + "Computed memory usage = {memoryUsed}.")] + public static partial void MemoryUsageData( + this ILogger logger, + ulong memoryUsed); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs index a7e0c6b2303..5add8ccfe75 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxNetworkMetrics.cs @@ -1,9 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Metrics; +using System.IO; +using System.Threading; using Microsoft.Shared.Instruments; namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network; @@ -11,10 +14,18 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network; internal sealed class LinuxNetworkMetrics { private readonly ITcpStateInfoProvider _tcpStateInfoProvider; + private readonly TimeProvider _timeProvider; - public LinuxNetworkMetrics(IMeterFactory meterFactory, ITcpStateInfoProvider tcpStateInfoProvider) + private readonly TimeSpan _retryInterval = TimeSpan.FromMinutes(5); + private DateTimeOffset _lastV4Failure = DateTimeOffset.MinValue; + private DateTimeOffset _lastV6Failure = DateTimeOffset.MinValue; + private int _v4Unavailable; + private int _v6Unavailable; + + public LinuxNetworkMetrics(IMeterFactory meterFactory, ITcpStateInfoProvider tcpStateInfoProvider, TimeProvider timeProvider) { _tcpStateInfoProvider = tcpStateInfoProvider; + _timeProvider = timeProvider; #pragma warning disable CA2000 // Dispose objects before losing scope // We don't dispose the meter because IMeterFactory handles that @@ -36,10 +47,9 @@ public LinuxNetworkMetrics(IMeterFactory meterFactory, ITcpStateInfoProvider tcp tags: commonTags); } - private IEnumerable> GetMeasurements() + public IEnumerable> GetMeasurements() { const string NetworkTypeKey = "network.type"; - const string NetworkStateKey = "system.network.state"; // These are covered in https://github.com/open-telemetry/semantic-conventions/blob/main/docs/rpc/rpc-metrics.md#attributes: KeyValuePair tcpVersionFourTag = new(NetworkTypeKey, "ipv4"); @@ -48,33 +58,59 @@ private IEnumerable> GetMeasurements() List> measurements = new(24); // IPv4: - TcpStateInfo stateV4 = _tcpStateInfoProvider.GetpIpV4TcpStateInfo(); - measurements.Add(new Measurement(stateV4.ClosedCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "close") })); - measurements.Add(new Measurement(stateV4.ListenCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "listen") })); - measurements.Add(new Measurement(stateV4.SynSentCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "syn_sent") })); - measurements.Add(new Measurement(stateV4.SynRcvdCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "syn_recv") })); - measurements.Add(new Measurement(stateV4.EstabCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "established") })); - measurements.Add(new Measurement(stateV4.FinWait1Count, new TagList { tcpVersionFourTag, new(NetworkStateKey, "fin_wait_1") })); - measurements.Add(new Measurement(stateV4.FinWait2Count, new TagList { tcpVersionFourTag, new(NetworkStateKey, "fin_wait_2") })); - measurements.Add(new Measurement(stateV4.CloseWaitCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "close_wait") })); - measurements.Add(new Measurement(stateV4.ClosingCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "closing") })); - measurements.Add(new Measurement(stateV4.LastAckCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "last_ack") })); - measurements.Add(new Measurement(stateV4.TimeWaitCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "time_wait") })); + TcpStateInfo stateV4 = GetTcpStateInfoWithRetry(_tcpStateInfoProvider.GetIpV4TcpStateInfo, ref _v4Unavailable, ref _lastV4Failure); + CreateMeasurements(tcpVersionFourTag, measurements, stateV4); // IPv6: - TcpStateInfo stateV6 = _tcpStateInfoProvider.GetpIpV6TcpStateInfo(); - measurements.Add(new Measurement(stateV6.ClosedCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "close") })); - measurements.Add(new Measurement(stateV6.ListenCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "listen") })); - measurements.Add(new Measurement(stateV6.SynSentCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "syn_sent") })); - measurements.Add(new Measurement(stateV6.SynRcvdCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "syn_recv") })); - measurements.Add(new Measurement(stateV6.EstabCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "established") })); - measurements.Add(new Measurement(stateV6.FinWait1Count, new TagList { tcpVersionSixTag, new(NetworkStateKey, "fin_wait_1") })); - measurements.Add(new Measurement(stateV6.FinWait2Count, new TagList { tcpVersionSixTag, new(NetworkStateKey, "fin_wait_2") })); - measurements.Add(new Measurement(stateV6.CloseWaitCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "close_wait") })); - measurements.Add(new Measurement(stateV6.ClosingCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "closing") })); - measurements.Add(new Measurement(stateV6.LastAckCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "last_ack") })); - measurements.Add(new Measurement(stateV6.TimeWaitCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "time_wait") })); + TcpStateInfo stateV6 = GetTcpStateInfoWithRetry(_tcpStateInfoProvider.GetIpV6TcpStateInfo, ref _v6Unavailable, ref _lastV6Failure); + CreateMeasurements(tcpVersionSixTag, measurements, stateV6); return measurements; } + + private static void CreateMeasurements(KeyValuePair tcpVersionTag, List> measurements, TcpStateInfo state) + { + const string NetworkStateKey = "system.network.state"; + + measurements.Add(new Measurement(state.ClosedCount, new TagList { tcpVersionTag, new(NetworkStateKey, "close") })); + measurements.Add(new Measurement(state.ListenCount, new TagList { tcpVersionTag, new(NetworkStateKey, "listen") })); + measurements.Add(new Measurement(state.SynSentCount, new TagList { tcpVersionTag, new(NetworkStateKey, "syn_sent") })); + measurements.Add(new Measurement(state.SynRcvdCount, new TagList { tcpVersionTag, new(NetworkStateKey, "syn_recv") })); + measurements.Add(new Measurement(state.EstabCount, new TagList { tcpVersionTag, new(NetworkStateKey, "established") })); + measurements.Add(new Measurement(state.FinWait1Count, new TagList { tcpVersionTag, new(NetworkStateKey, "fin_wait_1") })); + measurements.Add(new Measurement(state.FinWait2Count, new TagList { tcpVersionTag, new(NetworkStateKey, "fin_wait_2") })); + measurements.Add(new Measurement(state.CloseWaitCount, new TagList { tcpVersionTag, new(NetworkStateKey, "close_wait") })); + measurements.Add(new Measurement(state.ClosingCount, new TagList { tcpVersionTag, new(NetworkStateKey, "closing") })); + measurements.Add(new Measurement(state.LastAckCount, new TagList { tcpVersionTag, new(NetworkStateKey, "last_ack") })); + measurements.Add(new Measurement(state.TimeWaitCount, new TagList { tcpVersionTag, new(NetworkStateKey, "time_wait") })); + } + + private TcpStateInfo GetTcpStateInfoWithRetry( + Func getStateInfoFunc, + ref int unavailableFlag, + ref DateTimeOffset lastFailureTime) + { + if (Volatile.Read(ref unavailableFlag) == 0 || _timeProvider.GetUtcNow() - lastFailureTime > _retryInterval) + { + try + { + TcpStateInfo state = getStateInfoFunc(); + _ = Interlocked.Exchange(ref unavailableFlag, 0); + return state; + } + catch (Exception ex) when ( + ex is FileNotFoundException || + ex is DirectoryNotFoundException || + ex is UnauthorizedAccessException) + { + lastFailureTime = _timeProvider.GetUtcNow(); + _ = Interlocked.Exchange(ref unavailableFlag, 1); + return new TcpStateInfo(); + } + } + else + { + return new TcpStateInfo(); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxTcpStateInfo.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxTcpStateInfo.cs index 390bcda64ea..66bc3e1501e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxTcpStateInfo.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Network/LinuxTcpStateInfo.cs @@ -23,13 +23,13 @@ public LinuxTcpStateInfo(IOptions options, LinuxNetwo _parser = parser; } - public TcpStateInfo GetpIpV4TcpStateInfo() + public TcpStateInfo GetIpV4TcpStateInfo() { RefreshSnapshotIfNeeded(); return _iPv4Snapshot; } - public TcpStateInfo GetpIpV6TcpStateInfo() + public TcpStateInfo GetIpV6TcpStateInfo() { RefreshSnapshotIfNeeded(); return _iPv6Snapshot; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs index a300450a37d..ad4047ad576 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs @@ -8,19 +8,23 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; [SuppressMessage("Usage", "CA1801:Review unused parameters", Justification = "Generators.")] -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Generators.")] internal static partial class Log { [LoggerMessage(1, LogLevel.Error, "Unable to gather utilization statistics.")] - public static partial void HandledGatherStatisticsException(ILogger logger, Exception e); + public static partial void HandledGatherStatisticsException( + this ILogger logger, + Exception e); [LoggerMessage(2, LogLevel.Error, "Publisher `{Publisher}` was unable to publish utilization statistics.")] - public static partial void HandlePublishUtilizationException(ILogger logger, Exception e, string publisher); + public static partial void HandlePublishUtilizationException( + this ILogger logger, + Exception e, + string publisher); [LoggerMessage(3, LogLevel.Debug, "Snapshot received: TotalTimeSinceStart={totalTimeSinceStart}, KernelTimeSinceStart={kernelTimeSinceStart}, UserTimeSinceStart={userTimeSinceStart}, MemoryUsageInBytes={memoryUsageInBytes}.")] public static partial void SnapshotReceived( - ILogger logger, + this ILogger logger, TimeSpan totalTimeSinceStart, TimeSpan kernelTimeSinceStart, TimeSpan userTimeSinceStart, diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/README.md b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/README.md index ae20ab0297e..41f91b7df44 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/README.md +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/README.md @@ -1,6 +1,10 @@ # Microsoft.Extensions.Diagnostics.ResourceMonitoring -Measures and reports processor and memory usage. This library utilizes control groups (cgroups) in Linux to monitor system resources. Both cgroups v1 and v2 are supported. +Measures and reports processor and memory usage. To monitor system resources, this library: + +- Utilizes control groups (cgroups) in Linux. Both cgroups v1 and v2 are supported. +- Utilized Job Objects in Windows. +- Mac OS is not supported. ## Install the package diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs index d856cedb5ec..fc44fab4d45 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs @@ -101,7 +101,6 @@ public ResourceUtilization GetUtilization(TimeSpan window) return Calculator.CalculateUtilization(t.first, t.last, _provider.Resources); } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentionally Consume All. Allow no escapes.")] internal async Task PublishUtilizationAsync(CancellationToken cancellationToken) { var u = GetUtilization(_publishingWindow); @@ -115,12 +114,11 @@ internal async Task PublishUtilizationAsync(CancellationToken cancellationToken) { // By Design: Swallow the exception, as they're non-actionable in this code path. // Prioritize app reliability over error visibility - Log.HandlePublishUtilizationException(_logger, e, publisher.GetType().FullName!); + _logger.HandlePublishUtilizationException(e, publisher.GetType().FullName!); } } } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentionally Consume All. Allow no escapes.")] [SuppressMessage("Blocker Bug", "S2190:Loops and recursions should not be infinite", Justification = "Terminate when Delay throws an exception on cancellation")] protected override async Task ExecuteAsync(CancellationToken cancellationToken) { @@ -133,13 +131,13 @@ protected override async Task ExecuteAsync(CancellationToken cancellationToken) var snapshot = _provider.GetSnapshot(); _snapshotsStore.Add(snapshot); - Log.SnapshotReceived(_logger, snapshot.TotalTimeSinceStart, snapshot.KernelTimeSinceStart, snapshot.UserTimeSinceStart, snapshot.MemoryUsageInBytes); + _logger.SnapshotReceived(snapshot.TotalTimeSinceStart, snapshot.KernelTimeSinceStart, snapshot.UserTimeSinceStart, snapshot.MemoryUsageInBytes); } catch (Exception e) { // By Design: Swallow the exception, as they're non-actionable in this code path. // Prioritize app reliability over error visibility - Log.HandledGatherStatisticsException(_logger, e); + _logger.HandledGatherStatisticsException(e); } await PublishUtilizationAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs index f042d892ab1..efe5734f86f 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs @@ -10,12 +10,6 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; public partial class ResourceMonitoringOptions { - /// - /// Gets or sets a value indicating whether disk I/O metrics should be enabled. - /// - [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool EnableDiskIoMetrics { get; set; } - /// /// Gets or sets the list of source IPv4 addresses to track the connections for in telemetry. /// @@ -23,9 +17,7 @@ public partial class ResourceMonitoringOptions /// This property is Windows-specific and has no effect on other operating systems. /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public ISet SourceIpAddresses { get; set; } = new HashSet(); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a value indicating whether CPU and Memory utilization metric values should be in range [0, 1] instead of [0, 100]. diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs index 3946add711d..eb890da291e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs @@ -100,21 +100,23 @@ public partial class ResourceMonitoringOptions public TimeSpan MemoryConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; /// - /// Gets or sets a value indicating whether CPU metrics are calculated via cgroup CPU limits instead of Host CPU delta. + /// Gets or sets a value indicating whether CPU metrics for Linux are calculated using V2 method - via cgroup CPU limits instead of Host CPU delta. /// /// /// The default value is . /// + /// + /// This applies to cgroups v2 only and not supported on cgroups v1. + /// This is a more accurate way to calculate CPU utilization on Linux systems, please enable if possible. + /// It will be the default in the future. + /// [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool CalculateCpuUsageWithoutHostDelta { get; set; } + public bool UseLinuxCalculationV2 { get; set; } /// - /// Gets or sets a value indicating whether to use the number of periods in cpu.stat for cgroup CPU usage. - /// We use delta time for CPU usage calculation when this flag is not set. - /// - /// The default value is . - /// - /// + /// Gets or sets a value indicating whether disk I/O metrics should be enabled. + /// + /// Previously EnableDiskIoMetrics. [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool UseDeltaNrPeriodsForCpuCalculation { get; set; } + public bool EnableSystemDiskIoMetrics { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringServiceCollectionExtensions.cs index f018038c614..c368e2bff91 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Diagnostics.ResourceMonitoring; #if !NETFRAMEWORK using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network; #endif @@ -59,19 +60,21 @@ public static IServiceCollection AddResourceMonitoring( return services.AddResourceMonitoringInternal(configure); } - // can't easily test the exception throwing case - [ExcludeFromCodeCoverage] private static IServiceCollection AddResourceMonitoringInternal( this IServiceCollection services, Action configure) { - var builder = new ResourceMonitorBuilder(services); - _ = services.AddMetrics(); - + var builder = new ResourceMonitorBuilder(services); #if NETFRAMEWORK _ = builder.AddWindowsProvider(); #else + bool isSupportedOs = OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); + if (!isSupportedOs) + { + return services; + } + if (OperatingSystem.IsWindows()) { _ = builder.AddWindowsProvider(); @@ -80,10 +83,6 @@ private static IServiceCollection AddResourceMonitoringInternal( { _ = builder.AddLinuxProvider(); } - else - { - throw new PlatformNotSupportedException(); - } #endif configure.Invoke(builder); @@ -129,6 +128,7 @@ private static ResourceMonitorBuilder AddLinuxProvider(this ResourceMonitorBuild builder.Services.TryAddActivatedSingleton(); + builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.PickLinuxParser(); @@ -136,7 +136,9 @@ private static ResourceMonitorBuilder AddLinuxProvider(this ResourceMonitorBuild _ = builder.Services .AddActivatedSingleton() .AddActivatedSingleton() - .AddActivatedSingleton(); + .AddActivatedSingleton() + .AddActivatedSingleton() + .AddActivatedSingleton(); return builder; } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs index 47706b3ce6a..7ceebfa44c6 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs @@ -34,7 +34,7 @@ public WindowsDiskMetrics( IOptions options) { _logger = logger ?? NullLogger.Instance; - if (!options.Value.EnableDiskIoMetrics) + if (!options.Value.EnableSystemDiskIoMetrics) { return; } @@ -96,11 +96,9 @@ private void InitializeDiskCounters(IPerformanceCounterFactory performanceCounte ioTimePerfCounter.InitializeDiskCounters(); _diskIoTimePerfCounter = ioTimePerfCounter; } -#pragma warning disable CA1031 catch (Exception ex) -#pragma warning restore CA1031 { - Log.DiskIoPerfCounterException(_logger, WindowsDiskPerfCounterNames.DiskIdleTimeCounter, ex.Message); + _logger.DiskIoPerfCounterException(WindowsDiskPerfCounterNames.DiskIdleTimeCounter, ex.Message); } // Initialize disk performance counters for "system.disk.io" and "system.disk.operations" metrics @@ -124,11 +122,9 @@ private void InitializeDiskCounters(IPerformanceCounterFactory performanceCounte ratePerfCounter.InitializeDiskCounters(); _diskIoRateCounters.Add(counterName, ratePerfCounter); } -#pragma warning disable CA1031 catch (Exception ex) -#pragma warning restore CA1031 { - Log.DiskIoPerfCounterException(_logger, counterName, ex.Message); + _logger.DiskIoPerfCounterException(counterName, ex.Message); } } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs index fb5223f3d02..bd72e822ad8 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs @@ -13,16 +13,13 @@ internal sealed class ProcessInfo : IProcessInfo public ulong GetMemoryUsage() { ulong memoryUsage = 0; - var processes = Process.GetProcesses(); - foreach (var process in processes) + foreach (var process in Process.GetProcesses()) { try { memoryUsage += (ulong)process.WorkingSet64; } -#pragma warning disable CA1031 // Do not catch general exception types catch -#pragma warning restore CA1031 // Do not catch general exception types { // Ignore various exceptions including, but not limited: // AccessDenied (from kernel processes), @@ -31,9 +28,7 @@ public ulong GetMemoryUsage() } finally { -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) process?.Dispose(); -#pragma warning restore EA0011 // Consider removing unnecessary conditional access operator (?) } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs index 3d23f87dff9..c91cd989362 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs @@ -5,19 +5,18 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows; -#pragma warning disable S109 - internal static partial class Log { [LoggerMessage(1, LogLevel.Information, "Resource Monitoring is running inside a Job Object. For more information about Job Objects see https://aka.ms/job-objects")] - public static partial void RunningInsideJobObject(ILogger logger); + public static partial void RunningInsideJobObject(this ILogger logger); [LoggerMessage(2, LogLevel.Information, "Resource Monitoring is running outside of Job Object. For more information about Job Objects see https://aka.ms/job-objects")] - public static partial void RunningOutsideJobObject(ILogger logger); + public static partial void RunningOutsideJobObject(this ILogger logger); [LoggerMessage(3, LogLevel.Debug, "Computed CPU usage with CpuUsageTicks = {cpuUsageTicks}, OldCpuUsageTicks = {oldCpuUsageTicks}, TimeTickDelta = {timeTickDelta}, CpuUnits = {cpuUnits}, CpuPercentage = {cpuPercentage}.")] - public static partial void CpuUsageData(ILogger logger, + public static partial void CpuUsageData( + this ILogger logger, long cpuUsageTicks, long oldCpuUsageTicks, double timeTickDelta, @@ -25,16 +24,15 @@ public static partial void CpuUsageData(ILogger logger, double cpuPercentage); [LoggerMessage(4, LogLevel.Debug, - "Computed memory usage with CurrentMemoryUsage = {currentMemoryUsage}, TotalMemory = {totalMemory}, MemoryPercentage = {memoryPercentage}.")] - public static partial void MemoryUsageData(ILogger logger, + "Computed memory usage for container: CurrentMemoryUsage = {currentMemoryUsage}, TotalMemory = {totalMemory}")] + public static partial void ContainerMemoryUsageData( + this ILogger logger, ulong currentMemoryUsage, - double totalMemory, - double memoryPercentage); + double totalMemory); -#pragma warning disable S103 // Lines should not be too long [LoggerMessage(5, LogLevel.Debug, "Computed CPU usage with CpuUsageKernelTicks = {cpuUsageKernelTicks}, CpuUsageUserTicks = {cpuUsageUserTicks}, OldCpuUsageTicks = {oldCpuUsageTicks}, TimeTickDelta = {timeTickDelta}, CpuUnits = {cpuUnits}, CpuPercentage = {cpuPercentage}.")] -#pragma warning restore S103 // Lines should not be too long - public static partial void CpuContainerUsageData(ILogger logger, + public static partial void CpuContainerUsageData( + this ILogger logger, long cpuUsageKernelTicks, long cpuUsageUserTicks, long oldCpuUsageTicks, @@ -44,9 +42,25 @@ public static partial void CpuContainerUsageData(ILogger logger, [LoggerMessage(6, LogLevel.Debug, "System resources information: CpuLimit = {cpuLimit}, CpuRequest = {cpuRequest}, MemoryLimit = {memoryLimit}, MemoryRequest = {memoryRequest}.")] - public static partial void SystemResourcesInfo(ILogger logger, double cpuLimit, double cpuRequest, ulong memoryLimit, ulong memoryRequest); + public static partial void SystemResourcesInfo( + this ILogger logger, + double cpuLimit, + double cpuRequest, + ulong memoryLimit, + ulong memoryRequest); [LoggerMessage(7, LogLevel.Warning, "Error initializing disk io perf counter: PerfCounter={counterName}, Error={errorMessage}")] - public static partial void DiskIoPerfCounterException(ILogger logger, string counterName, string errorMessage); + public static partial void DiskIoPerfCounterException( + this ILogger logger, + string counterName, + string errorMessage); + + [LoggerMessage(8, LogLevel.Debug, + "Computed memory usage for current process: ProcessMemoryUsage = {processMemoryUsage}, TotalMemory = {totalMemory}, MemoryPercentage = {memoryPercentage}")] + public static partial void ProcessMemoryPercentageData( + this ILogger logger, + ulong processMemoryUsage, + double totalMemory, + double memoryPercentage); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs index 1acc8d02edd..be3b4e6983f 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsNetworkMetrics.cs @@ -48,7 +48,7 @@ private IEnumerable> GetMeasurements() List> measurements = new(24); // IPv4: - TcpStateInfo stateV4 = _tcpStateInfoProvider.GetpIpV4TcpStateInfo(); + TcpStateInfo stateV4 = _tcpStateInfoProvider.GetIpV4TcpStateInfo(); measurements.Add(new Measurement(stateV4.ClosedCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "close") })); measurements.Add(new Measurement(stateV4.ListenCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "listen") })); measurements.Add(new Measurement(stateV4.SynSentCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "syn_sent") })); @@ -63,7 +63,7 @@ private IEnumerable> GetMeasurements() measurements.Add(new Measurement(stateV4.DeleteTcbCount, new TagList { tcpVersionFourTag, new(NetworkStateKey, "delete") })); // IPv6: - TcpStateInfo stateV6 = _tcpStateInfoProvider.GetpIpV6TcpStateInfo(); + TcpStateInfo stateV6 = _tcpStateInfoProvider.GetIpV6TcpStateInfo(); measurements.Add(new Measurement(stateV6.ClosedCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "close") })); measurements.Add(new Measurement(stateV6.ListenCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "listen") })); measurements.Add(new Measurement(stateV6.SynSentCount, new TagList { tcpVersionSixTag, new(NetworkStateKey, "syn_sent") })); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsTcpStateInfo.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsTcpStateInfo.cs index 732c522cda5..cc0310fd4c8 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsTcpStateInfo.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Network/WindowsTcpStateInfo.cs @@ -42,13 +42,13 @@ public WindowsTcpStateInfo(IOptions options) _refreshAfter = default; } - public TcpStateInfo GetpIpV4TcpStateInfo() + public TcpStateInfo GetIpV4TcpStateInfo() { RefreshSnapshotIfNeeded(); return _iPv4Snapshot; } - public TcpStateInfo GetpIpV6TcpStateInfo() + public TcpStateInfo GetIpV6TcpStateInfo() { RefreshSnapshotIfNeeded(); return _iPv6Snapshot; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs index 56d8bc2e578..35c64adeedc 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; using System.Diagnostics.Metrics; using System.Threading; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; @@ -17,6 +17,7 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider { private const double One = 1.0d; private const double Hundred = 100.0d; + private const double TicksPerSecondDouble = TimeSpan.TicksPerSecond; private readonly Lazy _memoryStatus; @@ -27,6 +28,7 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); + private readonly object _processMemoryLocker = new(); private readonly TimeProvider _timeProvider; private readonly IProcessInfo _processInfo; private readonly ILogger _logger; @@ -40,8 +42,10 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider private long _oldCpuTimeTicks; private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; + private DateTimeOffset _refreshAfterProcessMemory; private double _cpuPercentage = double.NaN; - private double _memoryPercentage; + private ulong _memoryUsage; + private double _processMemoryPercentage; public SystemResources Resources { get; } @@ -61,7 +65,6 @@ public WindowsContainerSnapshotProvider( /// Initializes a new instance of the class. /// /// This constructor enables the mocking of dependencies for the purpose of Unit Testing only. - [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Dependencies for testing")] internal WindowsContainerSnapshotProvider( IMemoryInfo memoryInfo, ISystemInfo systemInfo, @@ -73,7 +76,7 @@ internal WindowsContainerSnapshotProvider( ResourceMonitoringOptions options) { _logger = logger ?? NullLogger.Instance; - Log.RunningInsideJobObject(_logger); + _logger.RunningInsideJobObject(); _metricValueMultiplier = options.UseZeroToOneRangeForMetrics ? One : Hundred; @@ -85,18 +88,18 @@ internal WindowsContainerSnapshotProvider( _timeProvider = timeProvider; - using var jobHandle = _createJobHandleObject(); + using IJobHandle jobHandle = _createJobHandleObject(); - var memoryLimitLong = GetMemoryLimit(jobHandle); + ulong memoryLimitLong = GetMemoryLimit(jobHandle); _memoryLimit = memoryLimitLong; _cpuLimit = GetCpuLimit(jobHandle, systemInfo); // CPU request (aka guaranteed CPU units) is not supported on Windows, so we set it to the same value as CPU limit (aka maximum CPU units). // Memory request (aka guaranteed memory) is not supported on Windows, so we set it to the same value as memory limit (aka maximum memory). - var cpuRequest = _cpuLimit; - var memoryRequest = memoryLimitLong; + double cpuRequest = _cpuLimit; + ulong memoryRequest = memoryLimitLong; Resources = new SystemResources(cpuRequest, _cpuLimit, memoryRequest, memoryLimitLong); - Log.SystemResourcesInfo(_logger, _cpuLimit, cpuRequest, memoryLimitLong, memoryRequest); + _logger.SystemResourcesInfo(_cpuLimit, cpuRequest, memoryLimitLong, memoryRequest); var basicAccountingInfo = jobHandle.GetBasicAccountingInfo(); _oldCpuUsageTicks = basicAccountingInfo.TotalKernelTime + basicAccountingInfo.TotalUserTime; @@ -105,21 +108,44 @@ internal WindowsContainerSnapshotProvider( _memoryRefreshInterval = options.MemoryConsumptionRefreshInterval; _refreshAfterCpu = _timeProvider.GetUtcNow(); _refreshAfterMemory = _timeProvider.GetUtcNow(); + _refreshAfterProcessMemory = _timeProvider.GetUtcNow(); #pragma warning disable CA2000 // Dispose objects before losing scope // We don't dispose the meter because IMeterFactory handles that // An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); + Meter meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope // Container based metrics: - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: CpuPercentage); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetMemoryUsage())); + _ = meter.CreateObservableCounter( + name: ResourceUtilizationInstruments.ContainerCpuTime, + observeValues: GetCpuTime, + unit: "s", + description: "CPU time used by the container."); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValue: CpuPercentage); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, + observeValue: () => Math.Min(_metricValueMultiplier, MemoryUsage() / _memoryLimit * _metricValueMultiplier)); + + _ = meter.CreateObservableUpDownCounter( + name: ResourceUtilizationInstruments.ContainerMemoryUsage, + observeValue: () => (long)MemoryUsage(), + unit: "By", + description: "Memory usage of the container."); // Process based metrics: - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: CpuPercentage); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessMemoryUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetCurrentProcessMemoryUsage())); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessCpuUtilization, + observeValue: CpuPercentage); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessMemoryUtilization, + observeValue: ProcessMemoryPercentage); } public Snapshot GetSnapshot() @@ -155,7 +181,7 @@ private static double GetCpuLimit(IJobHandle jobHandle, ISystemInfo systemInfo) cpuRatio = cpuLimit.CpuRate / CpuCycles; } - var systemInfoValue = systemInfo.GetSystemInfo(); + SYSTEM_INFO systemInfoValue = systemInfo.GetSystemInfo(); // Multiply the cpu ratio by the number of processors to get you the portion // of processors used from the system. @@ -172,7 +198,7 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) if (memoryLimitInBytes <= 0) { - var memoryStatus = _memoryStatus.Value; + MEMORYSTATUSEX memoryStatus = _memoryStatus.Value; // Technically, the unconstrained limit is memoryStatus.TotalPageFile. // Leaving this at physical as it is more understandable to consumers. @@ -182,35 +208,72 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) return memoryLimitInBytes; } - private double MemoryPercentage(Func getMemoryUsage) + private double ProcessMemoryPercentage() { - var now = _timeProvider.GetUtcNow(); + DateTimeOffset now = _timeProvider.GetUtcNow(); + + lock (_processMemoryLocker) + { + if (now < _refreshAfterProcessMemory) + { + return _processMemoryPercentage; + } + } + + ulong processMemoryUsage = _processInfo.GetCurrentProcessMemoryUsage(); + + lock (_processMemoryLocker) + { + if (now >= _refreshAfterProcessMemory) + { + _processMemoryPercentage = Math.Min(_metricValueMultiplier, processMemoryUsage / _memoryLimit * _metricValueMultiplier); + _refreshAfterProcessMemory = now.Add(_memoryRefreshInterval); + + _logger.ProcessMemoryPercentageData(processMemoryUsage, _memoryLimit, _processMemoryPercentage); + } + + return _processMemoryPercentage; + } + } + + private ulong MemoryUsage() + { + DateTimeOffset now = _timeProvider.GetUtcNow(); lock (_memoryLocker) { if (now < _refreshAfterMemory) { - return _memoryPercentage; + return _memoryUsage; } } - var memoryUsage = getMemoryUsage(); + ulong memoryUsage = _processInfo.GetMemoryUsage(); lock (_memoryLocker) { if (now >= _refreshAfterMemory) { - // Don't change calculation order, otherwise we loose some precision: - _memoryPercentage = Math.Min(_metricValueMultiplier, memoryUsage / _memoryLimit * _metricValueMultiplier); + _memoryUsage = memoryUsage; _refreshAfterMemory = now.Add(_memoryRefreshInterval); + _logger.ContainerMemoryUsageData(_memoryUsage, _memoryLimit); } - Log.MemoryUsageData(_logger, memoryUsage, _memoryLimit, _memoryPercentage); - - return _memoryPercentage; + return _memoryUsage; } } + private IEnumerable> GetCpuTime() + { + using IJobHandle jobHandle = _createJobHandleObject(); + var basicAccountingInfo = jobHandle.GetBasicAccountingInfo(); + + yield return new Measurement(basicAccountingInfo.TotalUserTime / TicksPerSecondDouble, + [new KeyValuePair("cpu.mode", "user")]); + yield return new Measurement(basicAccountingInfo.TotalKernelTime / TicksPerSecondDouble, + [new KeyValuePair("cpu.mode", "system")]); + } + private double CpuPercentage() { var now = _timeProvider.GetUtcNow(); @@ -238,8 +301,8 @@ private double CpuPercentage() // Don't change calculation order, otherwise precision is lost: _cpuPercentage = Math.Min(_metricValueMultiplier, usageTickDelta / timeTickDelta * _metricValueMultiplier); - Log.CpuContainerUsageData( - _logger, basicAccountingInfo.TotalKernelTime, basicAccountingInfo.TotalUserTime, _oldCpuUsageTicks, timeTickDelta, _cpuLimit, _cpuPercentage); + _logger.CpuContainerUsageData( + basicAccountingInfo.TotalKernelTime, basicAccountingInfo.TotalUserTime, _oldCpuUsageTicks, timeTickDelta, _cpuLimit, _cpuPercentage); _oldCpuUsageTicks = currentCpuTicks; _oldCpuTimeTicks = now.Ticks; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs index 3a20412424c..82b94dad9e6 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs @@ -2,8 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +#if !NET9_0_OR_GREATER using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; +#endif using System.Diagnostics.Metrics; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; using Microsoft.Extensions.Logging; @@ -44,7 +45,6 @@ public WindowsSnapshotProvider(ILogger? logger, IMeterF { } - [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Dependencies for testing")] internal WindowsSnapshotProvider( ILogger? logger, IMeterFactory meterFactory, @@ -57,7 +57,7 @@ internal WindowsSnapshotProvider( { _logger = logger ?? NullLogger.Instance; - Log.RunningOutsideJobObject(_logger); + _logger.RunningOutsideJobObject(); _metricValueMultiplier = options.UseZeroToOneRangeForMetrics ? One : Hundred; @@ -68,7 +68,7 @@ internal WindowsSnapshotProvider( // any resource requests or resource limits, therefore using physical values // such as number of CPUs and physical memory and using it for both requests and limits (aka 'guaranteed' and 'max'): Resources = new SystemResources(_cpuUnits, _cpuUnits, totalMemory, totalMemory); - Log.SystemResourcesInfo(_logger, _cpuUnits, _cpuUnits, totalMemory, totalMemory); + _logger.SystemResourcesInfo(_cpuUnits, _cpuUnits, totalMemory, totalMemory); _timeProvider = timeProvider; _getCpuTicksFunc = getCpuTicksFunc; @@ -95,18 +95,31 @@ internal WindowsSnapshotProvider( public Snapshot GetSnapshot() { +#if NET9_0_OR_GREATER + var cpuUsage = Environment.CpuUsage; + return new Snapshot( + totalTimeSinceStart: TimeSpan.FromTicks(_timeProvider.GetUtcNow().Ticks), + kernelTimeSinceStart: cpuUsage.PrivilegedTime, + userTimeSinceStart: cpuUsage.UserTime, + memoryUsageInBytes: (ulong)Environment.WorkingSet); +#else using var process = Process.GetCurrentProcess(); - - return new Snapshot(totalTimeSinceStart: TimeSpan.FromTicks(_timeProvider.GetUtcNow().Ticks), + return new Snapshot( + totalTimeSinceStart: TimeSpan.FromTicks(_timeProvider.GetUtcNow().Ticks), kernelTimeSinceStart: process.PrivilegedProcessorTime, userTimeSinceStart: process.UserProcessorTime, memoryUsageInBytes: (ulong)Environment.WorkingSet); +#endif } internal static long GetCpuTicks() { +#if NET9_0_OR_GREATER + return Environment.CpuUsage.TotalTime.Ticks; +#else using var process = Process.GetCurrentProcess(); return process.TotalProcessorTime.Ticks; +#endif } internal static int GetCpuUnits() => Environment.ProcessorCount; @@ -144,7 +157,7 @@ private double MemoryPercentage() _refreshAfterMemory = now.Add(_memoryRefreshInterval); } - Log.MemoryUsageData(_logger, (ulong)currentMemoryUsage, _totalMemory, _memoryPercentage); + _logger.ProcessMemoryPercentageData((ulong)currentMemoryUsage, _totalMemory, _memoryPercentage); return _memoryPercentage; } @@ -175,7 +188,7 @@ private double CpuPercentage() // Don't change calculation order, otherwise we loose some precision: _cpuPercentage = Math.Min(_metricValueMultiplier, usageTickDelta / (double)timeTickDelta * _metricValueMultiplier); - Log.CpuUsageData(_logger, currentCpuTicks, _oldCpuUsageTicks, timeTickDelta, _cpuUnits, _cpuPercentage); + _logger.CpuUsageData(currentCpuTicks, _oldCpuUsageTicks, timeTickDelta, _cpuUnits, _cpuPercentage); _oldCpuUsageTicks = currentCpuTicks; _oldCpuTimeTicks = now.Ticks; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs index 376cc87be8c..24b9f933b9c 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs @@ -129,6 +129,13 @@ internal void AddRecord(FakeLogRecord record) return; } + var customFilter = _options.CustomFilter; + if (customFilter is not null && !customFilter(record)) + { + // record was filtered out by a custom filter + return; + } + lock (_records) { _records.Add(record); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs index be5476e685a..c7c277fc475 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs @@ -3,9 +3,8 @@ using System; using System.Collections.Generic; -using Microsoft.Extensions.Logging; - -#pragma warning disable CA2227 // Collection properties should be read only +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Logging.Testing; @@ -34,6 +33,17 @@ public class FakeLogCollectorOptions /// public ISet FilteredLevels { get; set; } = new HashSet(); + /// + /// Gets or sets custom filter for which records are collected. + /// + /// The default is . + /// + /// Defaults to which doesn't apply any additional filter to the records. + /// If not empty, only records for which the filter function returns will be collected by the fake logger. + /// + [Experimental(DiagnosticIds.Experiments.Telemetry)] + public Func? CustomFilter { get; set; } + /// /// Gets or sets a value indicating whether to collect records that are logged when the associated log level is currently disabled. /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs index 8a3b184732d..09fc86f6cba 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging.Testing; @@ -25,9 +24,7 @@ public class FakeLogRecord /// The optional category for this record, which corresponds to the T in . /// Whether the log level was enabled or not when the method was called. /// The time at which the log record was created. -#pragma warning disable S107 // Methods should not have too many parameters public FakeLogRecord(LogLevel level, EventId id, object? state, Exception? exception, string message, IReadOnlyList scopes, string? category, bool enabled, DateTimeOffset timestamp) -#pragma warning restore S107 // Methods should not have too many parameters { Level = level; Id = id; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs index 3d31a6d6be8..e23c9d10324 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs @@ -5,7 +5,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Linq; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging.Testing; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs index 1d7c5336a97..5d4e854ca6e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging.Testing; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs index 51309d9960c..7102de8987c 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs @@ -238,9 +238,7 @@ public Task WaitForMeasurementsAsync(int minCount, CancellationToken cancellatio }); } -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks return w.TaskSource.Task; -#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks } /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj index 2ab592c4a4a..c38dcdea395 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Diagnostics.Testing + $(NetCoreTargetFrameworks);netstandard2.0;net462 Hand-crafted fakes to make telemetry-related testing easier. Telemetry $(PackageTags);Testing diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs index fbe54413cbf..93c3e534e8e 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs @@ -40,7 +40,6 @@ public DownstreamDependencyMetadataManager(IEnumerable requestRouteAsSpan = routeMetadata.RequestRoute.AsSpan(); - - if (requestRouteAsSpan.Length > 0) + var route = routeMetadata.RequestRoute; + if (!string.IsNullOrEmpty(route)) { - if (requestRouteAsSpan[0] != '/') + var routeSpan = route.AsSpan(); + if (routeSpan.StartsWith("//".AsSpan())) { - requestRouteAsSpan = $"/{routeMetadata.RequestRoute}".AsSpan(); + routeSpan = routeSpan.Slice(1); } - else if (requestRouteAsSpan.StartsWith("//".AsSpan(), StringComparison.OrdinalIgnoreCase)) + + if (routeSpan.Length > 1 && routeSpan[routeSpan.Length - 1] == '/') { - requestRouteAsSpan = requestRouteAsSpan.Slice(1); + routeSpan = routeSpan.Slice(0, routeSpan.Length - 1); } - if (requestRouteAsSpan.Length > 1 && requestRouteAsSpan[requestRouteAsSpan.Length - 1] == '/') + if (routeSpan[0] != '/') { - requestRouteAsSpan = requestRouteAsSpan.Slice(0, requestRouteAsSpan.Length - 1); +#if NET + route = $"/{routeSpan}"; +#else + route = $"/{routeSpan.ToString()}"; +#endif } + else if (routeSpan.Length != route.Length) + { + route = routeSpan.ToString(); + } + + route = _routeRegex.Replace(route, "*").ToUpperInvariant(); } else { - requestRouteAsSpan = "/".AsSpan(); + route = "/"; } - var route = _routeRegex.Replace(requestRouteAsSpan.ToString(), "*"); - route = route.ToUpperInvariant(); for (int i = 0; i < route.Length; i++) { char ch = route[i]; diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs index 309f24e1f2d..2e955605fdc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs @@ -30,9 +30,12 @@ public static IServiceCollection AddHttpClientLatencyTelemetry(this IServiceColl _ = services.RegisterCheckpointNames(HttpCheckpoints.Checkpoints); _ = services.AddOptions(); - _ = services.AddActivatedSingleton(); - _ = services.AddActivatedSingleton(); + _ = services.AddSingleton(); + _ = services.AddSingleton(); _ = services.AddTransient(); + _ = services.AddSingleton(); + _ = services.RegisterMeasureNames(HttpMeasures.Measures); + _ = services.RegisterTagNames(HttpTags.Tags); _ = services.AddHttpClientLogEnricher(); return services.ConfigureAll( diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs index 338326cc690..d5b5f0a40fb 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs @@ -19,9 +19,4 @@ public void Set(ILatencyContext context) { _latencyContext.Value = context; } - - public void Unset() - { - _latencyContext.Value = null; - } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs index bad9b23a415..753b5b9a9f6 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs @@ -23,36 +23,69 @@ internal sealed class HttpClientLatencyLogEnricher : IHttpClientLogEnricher { private static readonly ObjectPool _builderPool = PoolFactory.SharedStringBuilderPool; private readonly HttpClientLatencyContext _latencyContext; - + private readonly HttpLatencyMediator _httpLatencyMediator; private readonly CheckpointToken _enricherInvoked; - public HttpClientLatencyLogEnricher(HttpClientLatencyContext latencyContext, ILatencyContextTokenIssuer tokenIssuer) + public HttpClientLatencyLogEnricher( + HttpClientLatencyContext latencyContext, + ILatencyContextTokenIssuer tokenIssuer, + HttpLatencyMediator httpLatencyMediator) { _latencyContext = latencyContext; + _httpLatencyMediator = httpLatencyMediator; _enricherInvoked = tokenIssuer.GetCheckpointToken(HttpCheckpoints.EnricherInvoked); } - public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage request, HttpResponseMessage? response, Exception? exception) + public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage? request, HttpResponseMessage? response, Exception? exception) { if (response != null) { var lc = _latencyContext.Get(); - lc?.AddCheckpoint(_enricherInvoked); - - StringBuilder stringBuilder = _builderPool.Get(); - - // Add serverName, checkpoints to outgoing http logs. - AppendServerName(response.Headers, stringBuilder); - _ = stringBuilder.Append(','); if (lc != null) { - AppendCheckpoints(lc, stringBuilder); + // Add the checkpoint + lc.AddCheckpoint(_enricherInvoked); + + // Use the mediator to record all metrics + _httpLatencyMediator.RecordEnd(lc, response); } - collector.Add("LatencyInfo", stringBuilder.ToString()); + StringBuilder stringBuilder = _builderPool.Get(); + + try + { + /* Add version, serverName, checkpoints, and measures to outgoing http logs. + * Schemas: 1) ServerName,CheckpointName,CheckpointValue + * 2) v1.0,ServerName,TagName,TagValue,CheckpointName,CheckpointValue,MetricName,MetricValue + */ + + // Add version + _ = stringBuilder.Append("v1.0"); + _ = stringBuilder.Append(','); + + // Add server name + AppendServerName(response.Headers, stringBuilder); + _ = stringBuilder.Append(','); + + // Add tags, checkpoints, and measures + if (lc != null) + { + AppendTags(lc, stringBuilder); + _ = stringBuilder.Append(','); + + AppendCheckpoints(lc, stringBuilder); + _ = stringBuilder.Append(','); - _builderPool.Return(stringBuilder); + AppendMeasures(lc, stringBuilder); + } + + collector.Add("LatencyInfo", stringBuilder.ToString()); + } + finally + { + _builderPool.Return(stringBuilder); + } } } @@ -60,25 +93,70 @@ private static void AppendServerName(HttpHeaders headers, StringBuilder stringBu { if (headers.TryGetValues(TelemetryConstants.ServerApplicationNameHeader, out var values)) { - _ = stringBuilder.Append(values!.First()); + _ = stringBuilder.Append(values.First()); } } private static void AppendCheckpoints(ILatencyContext latencyContext, StringBuilder stringBuilder) { + const int MillisecondsPerSecond = 1000; + var latencyData = latencyContext.LatencyData; - for (int i = 0; i < latencyData.Checkpoints.Length; i++) + var checkpointCount = latencyData.Checkpoints.Length; + + for (int i = 0; i < checkpointCount; i++) { _ = stringBuilder.Append(latencyData.Checkpoints[i].Name); _ = stringBuilder.Append('/'); } _ = stringBuilder.Append(','); - for (int i = 0; i < latencyData.Checkpoints.Length; i++) + + for (int i = 0; i < checkpointCount; i++) + { + var cp = latencyData.Checkpoints[i]; + _ = stringBuilder.Append((long)Math.Round(((double)cp.Elapsed / cp.Frequency) * MillisecondsPerSecond)); + _ = stringBuilder.Append('/'); + } + } + + private static void AppendMeasures(ILatencyContext latencyContext, StringBuilder stringBuilder) + { + var latencyData = latencyContext.LatencyData; + var measureCount = latencyData.Measures.Length; + + for (int i = 0; i < measureCount; i++) + { + _ = stringBuilder.Append(latencyData.Measures[i].Name); + _ = stringBuilder.Append('/'); + } + + _ = stringBuilder.Append(','); + + for (int i = 0; i < measureCount; i++) + { + _ = stringBuilder.Append(latencyData.Measures[i].Value); + _ = stringBuilder.Append('/'); + } + } + + private static void AppendTags(ILatencyContext latencyContext, StringBuilder stringBuilder) + { + var latencyData = latencyContext.LatencyData; + var tagCount = latencyData.Tags.Length; + + for (int i = 0; i < tagCount; i++) + { + _ = stringBuilder.Append(latencyData.Tags[i].Name); + _ = stringBuilder.Append('/'); + } + + _ = stringBuilder.Append(','); + + for (int i = 0; i < tagCount; i++) { - var ms = ((double)latencyData.Checkpoints[i].Elapsed / latencyData.Checkpoints[i].Frequency) * 1000; - _ = stringBuilder.Append(ms); + _ = stringBuilder.Append(latencyData.Tags[i].Value); _ = stringBuilder.Append('/'); } } -} +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.cs new file mode 100644 index 00000000000..25543ad9c5a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET +using System.Net.Http; +using Microsoft.Extensions.Diagnostics.Latency; + +namespace Microsoft.Extensions.Http.Latency.Internal; + +/// +/// Mediator for HTTP latency operations that coordinates recording HTTP metrics in a latency context. +/// +internal sealed class HttpLatencyMediator +{ + private readonly MeasureToken _gcPauseTime; + private readonly TagToken _httpVersionTag; + + public HttpLatencyMediator(ILatencyContextTokenIssuer tokenIssuer) + { + _gcPauseTime = tokenIssuer.GetMeasureToken(HttpMeasures.GCPauseTime); + _httpVersionTag = tokenIssuer.GetTagToken(HttpTags.HttpVersion); + } + + public void RecordStart(ILatencyContext latencyContext) + { + latencyContext.RecordMeasure(_gcPauseTime, (long)System.GC.GetTotalPauseDuration().TotalMilliseconds * -1L); + } + + public void RecordEnd(ILatencyContext latencyContext, HttpResponseMessage? response = null) + { + latencyContext.AddMeasure(_gcPauseTime, (long)System.GC.GetTotalPauseDuration().TotalMilliseconds); + + if (response != null) + { + latencyContext.SetTag(_httpVersionTag, response.Version.ToString()); + } + } +} +#endif diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.netfx.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.netfx.cs new file mode 100644 index 00000000000..c5cb350397e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.netfx.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#if !NET + +using System.Net.Http; +using Microsoft.Extensions.Diagnostics.Latency; + +namespace Microsoft.Extensions.Http.Latency.Internal; + +internal sealed class HttpLatencyMediator +{ + private readonly TagToken _httpVersionTag; + + public HttpLatencyMediator(ILatencyContextTokenIssuer tokenIssuer) + { + _httpVersionTag = tokenIssuer.GetTagToken(HttpTags.HttpVersion); + } + + public void RecordEnd(ILatencyContext latencyContext, HttpResponseMessage? response = null) + { + if (response != null) + { + latencyContext?.SetTag(_httpVersionTag, response.Version.ToString()); + } + } +} +#endif \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs index d0d38a875ec..81a0878b3f5 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Options; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Http.Latency.Internal; @@ -22,19 +21,22 @@ internal sealed class HttpLatencyTelemetryHandler : DelegatingHandler private readonly ILatencyContextProvider _latencyContextProvider; private readonly CheckpointToken _handlerStart; private readonly string _applicationName; +#if NET + private readonly HttpLatencyMediator _latencyMediator; +#endif public HttpLatencyTelemetryHandler(HttpRequestLatencyListener latencyListener, ILatencyContextTokenIssuer tokenIssuer, ILatencyContextProvider latencyContextProvider, - IOptions options, IOptions appMetdata) + IOptions options, IOptions appMetadata, HttpLatencyMediator latencyTelemetryMediator) { - var appMetadata = Throw.IfMemberNull(appMetdata, appMetdata.Value); - var telemetryOptions = Throw.IfMemberNull(options, options.Value); - _latencyListener = latencyListener; _latencyContextProvider = latencyContextProvider; _handlerStart = tokenIssuer.GetCheckpointToken(HttpCheckpoints.HandlerRequestStart); - _applicationName = appMetdata.Value.ApplicationName; + _applicationName = appMetadata.Value.ApplicationName; +#if NET + _latencyMediator = latencyTelemetryMediator; +#endif - if (telemetryOptions.EnableDetailedLatencyBreakdown) + if (options.Value.EnableDetailedLatencyBreakdown) { _latencyListener.Enable(); } @@ -46,12 +48,12 @@ protected async override Task SendAsync(HttpRequestMessage context.AddCheckpoint(_handlerStart); _latencyListener.LatencyContext.Set(context); - request.Headers.Add(TelemetryConstants.ClientApplicationNameHeader, _applicationName); - - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); +#if NET + _latencyMediator.RecordStart(context); +#endif - _latencyListener.LatencyContext.Unset(); + _ = request.Headers.TryAddWithoutValidation(TelemetryConstants.ClientApplicationNameHeader, _applicationName); - return response; + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpMeasures.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpMeasures.cs new file mode 100644 index 00000000000..adb086a99cc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpMeasures.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Http.Latency.Internal; + +internal static class HttpMeasures +{ + public const string GCPauseTime = "gcp"; + public const string ConnectionInitiated = "coni"; + + public static readonly string[] Measures = + [ + GCPauseTime, + ConnectionInitiated + ]; +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs index 744d7a5ddd3..ae5eef41179 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs @@ -15,18 +15,18 @@ internal sealed class HttpRequestLatencyListener : EventListener { private const string SocketProviderName = "System.Net.Sockets"; private const string HttpProviderName = "System.Net.Http"; - private const string NameResolutionProivderName = "System.Net.NameResolution"; + private const string NameResolutionProviderName = "System.Net.NameResolution"; private readonly ConcurrentDictionary _eventSources = new() { [SocketProviderName] = null, [HttpProviderName] = null, - [NameResolutionProivderName] = null + [NameResolutionProviderName] = null }; internal HttpClientLatencyContext LatencyContext { get; } - private readonly EventToCheckpointToken _eventToCheckpointToken; + private readonly EventToToken _eventToToken; private int _enabled; @@ -35,13 +35,14 @@ internal sealed class HttpRequestLatencyListener : EventListener public HttpRequestLatencyListener(HttpClientLatencyContext latencyContext, ILatencyContextTokenIssuer tokenIssuer) { LatencyContext = latencyContext; - _eventToCheckpointToken = new(tokenIssuer); + _eventToToken = new(tokenIssuer); } public void Enable() { if (Interlocked.CompareExchange(ref _enabled, 1, 0) == 0) { + // Enable any already discovered event sources foreach (var eventSource in _eventSources) { if (eventSource.Value != null) @@ -49,16 +50,35 @@ public void Enable() EnableEventSource(eventSource.Value); } } + +#if NETSTANDARD + foreach (var eventSource in EventSource.GetSources()) + { + OnEventSourceCreated(eventSource.Name, eventSource); + } +#else + // Process already existing listeners once again + EventSourceCreated += (_, args) => OnEventSourceCreated(args.EventSource!); +#endif } } internal void OnEventWritten(string eventSourceName, string? eventName) { // If event of interest, add a checkpoint for it. - CheckpointToken? token = _eventToCheckpointToken.GetCheckpointToken(eventSourceName, eventName); + CheckpointToken? token = _eventToToken.GetCheckpointToken(eventSourceName, eventName); if (token.HasValue) { - LatencyContext.Get()?.AddCheckpoint(token.Value); + var latencyContext = LatencyContext.Get(); + latencyContext?.AddCheckpoint(token.Value); + + // If event of interest, add a presence measure for it. + MeasureToken? mtoken = _eventToToken.GetMeasureToken(eventSourceName, eventName); + + if (mtoken.HasValue) + { + latencyContext?.AddMeasure(mtoken.Value, 1L); + } } } @@ -83,13 +103,13 @@ protected override void OnEventWritten(EventWrittenEventArgs eventData) private void EnableEventSource(EventSource eventSource) { - if (Enabled && !eventSource.IsEnabled()) + if (Enabled) { EnableEvents(eventSource, EventLevel.Informational); } } - private sealed class EventToCheckpointToken + private sealed class EventToToken { private static readonly Dictionary _socketMap = new() { @@ -117,39 +137,69 @@ private sealed class EventToCheckpointToken { "ResponseContentStop", HttpCheckpoints.ResponseContentEnd } }; - private readonly FrozenDictionary> _eventToTokenMap; + private static readonly Dictionary _httpMeasureMap = new() + { + { "ConnectionEstablished", HttpMeasures.ConnectionInitiated } + }; - public EventToCheckpointToken(ILatencyContextTokenIssuer tokenIssuer) + private readonly FrozenDictionary> _eventToCheckpointTokenMap; + private readonly FrozenDictionary> _eventToMeasureTokenMap; + + public EventToToken(ILatencyContextTokenIssuer tokenIssuer) { - Dictionary socket = []; + Dictionary socket = new(); foreach (string key in _socketMap.Keys) { socket[key] = tokenIssuer.GetCheckpointToken(_socketMap[key]); } - Dictionary nameResolution = []; + Dictionary nameResolution = new(); foreach (string key in _nameResolutionMap.Keys) { nameResolution[key] = tokenIssuer.GetCheckpointToken(_nameResolutionMap[key]); } - Dictionary http = []; + Dictionary http = new(); foreach (string key in _httpMap.Keys) { http[key] = tokenIssuer.GetCheckpointToken(_httpMap[key]); } - _eventToTokenMap = new Dictionary> + Dictionary httpMeasures = new(); + foreach (string key in _httpMeasureMap.Keys) + { + httpMeasures[key] = tokenIssuer.GetMeasureToken(_httpMeasureMap[key]); + } + + _eventToCheckpointTokenMap = new Dictionary> { { SocketProviderName, socket.ToFrozenDictionary(StringComparer.Ordinal) }, - { NameResolutionProivderName, nameResolution.ToFrozenDictionary(StringComparer.Ordinal) }, + { NameResolutionProviderName, nameResolution.ToFrozenDictionary(StringComparer.Ordinal) }, { HttpProviderName, http.ToFrozenDictionary(StringComparer.Ordinal) } }.ToFrozenDictionary(StringComparer.Ordinal); + + _eventToMeasureTokenMap = new Dictionary> + { + { HttpProviderName, httpMeasures.ToFrozenDictionary(StringComparer.Ordinal) } + }.ToFrozenDictionary(StringComparer.Ordinal); } public CheckpointToken? GetCheckpointToken(string eventSourceName, string? eventName) { - if (eventName != null && _eventToTokenMap.TryGetValue(eventSourceName, out var events)) + if (eventName != null && _eventToCheckpointTokenMap.TryGetValue(eventSourceName, out var events)) + { + if (events.TryGetValue(eventName, out var token)) + { + return token; + } + } + + return null; + } + + public MeasureToken? GetMeasureToken(string eventSourceName, string? eventName) + { + if (eventName != null && _eventToMeasureTokenMap.TryGetValue(eventSourceName, out var events)) { if (events.TryGetValue(eventName, out var token)) { diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpTags.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpTags.cs new file mode 100644 index 00000000000..dd15191e22d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpTags.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Http.Latency.Internal; + +internal static class HttpTags +{ + public const string HttpVersion = "httpver"; + + public static readonly string[] Tags = + [ + HttpVersion + ]; +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs index 803eb03d082..d4cbc839551 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Http.Logging; @@ -51,6 +53,12 @@ public static class HttpClientLoggingTagNames /// public const string ResponseHeaderPrefix = "http.response.header."; + /// + /// URL query parameters prefix. + /// + [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] + public const string UrlQuery = "url.query"; + /// /// HTTP Status Code. /// @@ -61,8 +69,7 @@ public static class HttpClientLoggingTagNames /// /// A read-only of all tag names. public static IReadOnlyList TagNames { get; } = - Array.AsReadOnly(new[] - { + Array.AsReadOnly([ Duration, Host, Method, @@ -71,6 +78,7 @@ public static class HttpClientLoggingTagNames RequestHeaderPrefix, ResponseBody, ResponseHeaderPrefix, - StatusCode - }); + StatusCode, + UrlQuery + ]); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/IHttpClientLogEnricher.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/IHttpClientLogEnricher.cs index c6ee71fa99a..f6fd2b637fc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/IHttpClientLogEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/IHttpClientLogEnricher.cs @@ -20,8 +20,8 @@ public interface IHttpClientLogEnricher /// object associated with the outgoing HTTP request. /// An optional that was thrown within the outgoing HTTP request processing. /// - /// Please be aware that depending on the result of the HTTP request - /// the and parameters may be . + /// Depending on the result of the HTTP request, + /// the and parameters might be . /// void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage request, HttpResponseMessage? response, Exception? exception); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs index bbf8095e384..536c08eee6d 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; using System.Threading; @@ -65,7 +64,6 @@ internal HttpClientLogger( _pathParametersRedactionSkipped = options.RequestPathParameterRedactionMode == HttpRouteParameterRedactionMode.None; } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The logger shouldn't throw")] public async ValueTask LogRequestStartAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) { var logRecord = _logRecordPool.Get(); @@ -109,22 +107,22 @@ internal HttpClientLogger( } } - public async ValueTask LogRequestStopAsync( + public ValueTask LogRequestStopAsync( object? context, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed, CancellationToken cancellationToken = default) - => await LogResponseAsync(context, request, response, null, elapsed, cancellationToken).ConfigureAwait(false); + => LogResponseAsync(context, request, response, null, elapsed, cancellationToken); - public async ValueTask LogRequestFailedAsync( + public ValueTask LogRequestFailedAsync( object? context, HttpRequestMessage request, HttpResponseMessage? response, Exception exception, TimeSpan elapsed, CancellationToken cancellationToken = default) - => await LogResponseAsync(context, request, response, exception, elapsed, cancellationToken).ConfigureAwait(false); + => LogResponseAsync(context, request, response, exception, elapsed, cancellationToken); public object? LogRequestStart(HttpRequestMessage request) => throw new NotSupportedException(SyncLoggingExceptionMessage); @@ -149,7 +147,6 @@ private static LogLevel GetLogLevel(LogRecord logRecord) return LogLevel.Information; } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The logger shouldn't throw")] private async ValueTask LogResponseAsync( object? context, HttpRequestMessage request, @@ -217,8 +214,6 @@ private async ValueTask LogResponseAsync( } } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "We intentionally catch all exception types to make Telemetry code resilient to failures.")] private void FillLogRecord( LogRecord logRecord, LoggerMessageState loggerMessageState, in TimeSpan elapsed, HttpRequestMessage request, HttpResponseMessage? response, Exception? exception) diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs index 8e6bcedc4e7..1679d2c764b 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs @@ -70,6 +70,9 @@ public void ReadResponseHeaders(HttpResponseMessage response, List _redactor.Redact(value, classification); + private void ReadHeaders(HttpHeaders headers, FrozenDictionary headersToLog, List> destination) { #if NET6_0_OR_GREATER diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs index 652636e53a6..a09f0eecf7c 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs @@ -29,6 +29,11 @@ public string Redact(IEnumerable headerValues, DataClassification classi _ => TelemetryConstants.Unknown }; + public string Redact(string value, DataClassification classification) + { + return _redactorProvider.GetRedactor(classification).Redact(value); + } + private string RedactIEnumerable(IEnumerable input, DataClassification classification) { var redactor = _redactorProvider.GetRedactor(classification); diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs index ed5a3c3f33d..c7abf7f0df8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs @@ -2,21 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Frozen; using System.IO; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; -#if NETCOREAPP3_1_OR_GREATER -using Microsoft.Extensions.ObjectPool; -#endif using Microsoft.Shared.Diagnostics; -#if NETCOREAPP3_1_OR_GREATER -using Microsoft.Shared.Pools; -#else -using System.Buffers; -#endif namespace Microsoft.Extensions.Http.Logging.Internal; @@ -27,9 +20,6 @@ internal sealed class HttpRequestBodyReader /// internal readonly TimeSpan RequestReadTimeout; -#if NETCOREAPP3_1_OR_GREATER - private static readonly ObjectPool> _bufferWriterPool = BufferWriterPool.SharedBufferWriterPool; -#endif private readonly FrozenSet _readableRequestContentTypes; private readonly int _requestReadLimit; @@ -93,33 +83,20 @@ private static async ValueTask ReadFromStreamAsync(HttpRequestMessage re #endif var readLimit = Math.Min(readSizeLimit, (int)streamToReadFrom.Length); -#if NETCOREAPP3_1_OR_GREATER - var bufferWriter = _bufferWriterPool.Get(); - try - { - var memory = bufferWriter.GetMemory(readLimit).Slice(0, readLimit); - var charsWritten = await streamToReadFrom.ReadAsync(memory, cancellationToken).ConfigureAwait(false); - - return Encoding.UTF8.GetString(memory[..charsWritten].Span); - } - finally - { - _bufferWriterPool.Return(bufferWriter); - streamToReadFrom.Seek(0, SeekOrigin.Begin); - } - -#else var buffer = ArrayPool.Shared.Rent(readLimit); try { - _ = await streamToReadFrom.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); - return Encoding.UTF8.GetString(buffer.AsSpan(0, readLimit).ToArray()); +#if NET + var read = await streamToReadFrom.ReadAsync(buffer.AsMemory(0, readLimit), cancellationToken).ConfigureAwait(false); +#else + var read = await streamToReadFrom.ReadAsync(buffer, 0, readLimit, cancellationToken).ConfigureAwait(false); +#endif + return Encoding.UTF8.GetString(buffer, 0, read); } finally { ArrayPool.Shared.Return(buffer); streamToReadFrom.Seek(0, SeekOrigin.Begin); } -#endif } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs index a9eaf982ac4..78cb73bbddc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Telemetry.Internal; using Microsoft.Shared.Diagnostics; +using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Http.Logging.Internal; @@ -23,16 +24,18 @@ internal sealed class HttpRequestReader : IHttpRequestReader private readonly IHttpRouteParser _httpRouteParser; private readonly IHttpHeadersReader _httpHeadersReader; private readonly FrozenDictionary _defaultSensitiveParameters; + private readonly FrozenDictionary _queryParameterDataClasses; private readonly bool _logRequestBody; private readonly bool _logResponseBody; + private readonly bool _logRequestQueryParameters; private readonly bool _logRequestHeaders; private readonly bool _logResponseHeaders; private readonly HttpRouteParameterRedactionMode _routeParameterRedactionMode; - // These are not registered in DI as handler today is public and we would need to make all of those types public. + // These are not registered in DI as handler today is public, and we would need to make all of those types public. // They are not implemented as statics to simplify design and pass less arguments around. // Also wanted to encapsulate logic of reading each part of the request to simplify handler logic itself. private readonly HttpRequestBodyReader _httpRequestBodyReader; @@ -77,6 +80,7 @@ internal HttpRequestReader( _downstreamDependencyMetadataManager = downstreamDependencyMetadataManager; _defaultSensitiveParameters = options.RouteParameterDataClasses.ToFrozenDictionary(StringComparer.Ordinal); + _queryParameterDataClasses = options.RequestQueryParametersDataClasses.ToFrozenDictionary(StringComparer.Ordinal); if (options.LogBody) { @@ -86,6 +90,7 @@ internal HttpRequestReader( _logRequestHeaders = options.RequestHeadersDataClasses.Count > 0; _logResponseHeaders = options.ResponseHeadersDataClasses.Count > 0; + _logRequestQueryParameters = options.RequestQueryParametersDataClasses.Count > 0; _httpRequestBodyReader = new HttpRequestBodyReader(options); _httpResponseBodyReader = new HttpResponseBodyReader(options); @@ -93,8 +98,27 @@ internal HttpRequestReader( _routeParameterRedactionMode = options.RequestPathParameterRedactionMode; } + public async Task ReadResponseAsync(LogRecord logRecord, HttpResponseMessage response, + List>? responseHeadersBuffer, + CancellationToken cancellationToken) + { + if (_logResponseHeaders) + { + _httpHeadersReader.ReadResponseHeaders(response, responseHeadersBuffer); + logRecord.ResponseHeaders = responseHeadersBuffer; + } + + if (_logResponseBody) + { + logRecord.ResponseBody = await _httpResponseBodyReader.ReadAsync(response, cancellationToken).ConfigureAwait(false); + } + + logRecord.StatusCode = (int)response.StatusCode; + } + public async Task ReadRequestAsync(LogRecord logRecord, HttpRequestMessage request, - List>? requestHeadersBuffer, CancellationToken cancellationToken) + List>? requestHeadersBuffer, CancellationToken + cancellationToken) { logRecord.Host = request.RequestUri?.Host ?? TelemetryConstants.Unknown; logRecord.Method = request.Method; @@ -111,24 +135,86 @@ public async Task ReadRequestAsync(LogRecord logRecord, HttpRequestMessage reque logRecord.RequestBody = await _httpRequestBodyReader.ReadAsync(request, cancellationToken) .ConfigureAwait(false); } + + if (_logRequestQueryParameters && !string.IsNullOrEmpty(request.RequestUri?.Query)) + { + logRecord.QueryString = ExtractAndRedactQueryParameters(request.RequestUri!.Query); + } + else + { + logRecord.QueryString = string.Empty; + } } - public async Task ReadResponseAsync(LogRecord logRecord, HttpResponseMessage response, - List>? responseHeadersBuffer, - CancellationToken cancellationToken) + private static string UnescapeDataString(ReadOnlySpan value) { - if (_logResponseHeaders) +#if NET9_0_OR_GREATER + return Uri.UnescapeDataString(value); +#else + return Uri.UnescapeDataString(value.ToString()); +#endif + } + + private string ExtractAndRedactQueryParameters(string query) + { + var stringBuilder = PoolFactory.SharedStringBuilderPool.Get(); + try { - _httpHeadersReader.ReadResponseHeaders(response, responseHeadersBuffer); - logRecord.ResponseHeaders = responseHeadersBuffer; - } + ReadOnlySpan querySpan = query.AsSpan(); + int length = querySpan.Length; + int start = 0; - if (_logResponseBody) + // Remove leading '?' + if (length > 0 && querySpan[0] == '?') + { + start = 1; + } + + while (start < length) + { + int amp = querySpan.Slice(start).IndexOf('&'); + int end = amp == -1 ? length : start + amp; + + int eq = querySpan.Slice(start, end - start).IndexOf('='); + if (eq >= 0) + { + var keySpan = querySpan.Slice(start, eq); + var valueSpan = querySpan.Slice(start + eq + 1, end - (start + eq + 1)); + + string key = UnescapeDataString(keySpan); + string value = UnescapeDataString(valueSpan); + + // Only process if the key is in the classification dictionary and value is not empty + if (!string.IsNullOrEmpty(value) && _queryParameterDataClasses.TryGetValue(key, out var classification)) + { + string redacted = _httpHeadersReader.RedactValue(value, classification); + + // Append to string builder directly with proper encoding + if (stringBuilder.Length > 0) + { + _ = stringBuilder.Append('&'); + } + + _ = stringBuilder.Append(Uri.EscapeDataString(key)) + .Append('=') + .Append(Uri.EscapeDataString(redacted)); + } + } + + if (amp == -1) + { + break; + } + + start = end + 1; + } + + return stringBuilder.ToString(); + } + finally { - logRecord.ResponseBody = await _httpResponseBodyReader.ReadAsync(response, cancellationToken).ConfigureAwait(false); + PoolFactory.SharedStringBuilderPool.Return(stringBuilder); } - - logRecord.StatusCode = (int)response.StatusCode; } private void GetRedactedPathAndParameters(HttpRequestMessage request, LogRecord logRecord) @@ -185,3 +271,4 @@ private void GetRedactedPathAndParameters(HttpRequestMessage request, LogRecord } } } + diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs index 0c5b6a672b1..8022ee74197 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs @@ -112,10 +112,7 @@ private static async ValueTask ReadFromStreamAsync(HttpResponseMessage r // if stream is not seekable we need to write the rest of the stream to the pipe // and create a new response content with the pipe reader as stream - _ = Task.Run(async () => - { - await WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken).ConfigureAwait(false); - }, CancellationToken.None); + _ = WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken); // use the pipe reader as stream for the new content var newContent = new StreamContent(pipe.Reader.AsStream()); @@ -130,41 +127,29 @@ private static async ValueTask ReadFromStreamAsync(HttpResponseMessage r } #if NET6_0_OR_GREATER - private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) + private static async ValueTask BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) { Memory memory = writer.GetMemory(bufferSize)[..bufferSize]; -#if NET8_0_OR_GREATER int bytesRead = await stream.ReadAtLeastAsync(memory, bufferSize, false, cancellationToken).ConfigureAwait(false); -#else - int bytesRead = 0; - while (bytesRead < bufferSize) - { - int read = await stream.ReadAsync(memory.Slice(bytesRead), cancellationToken).ConfigureAwait(false); - if (read == 0) - { - break; - } - - bytesRead += read; - } -#endif - if (bytesRead == 0) { return string.Empty; } + var res = Encoding.UTF8.GetString(memory.Span[..bytesRead]); writer.Advance(bytesRead); - return Encoding.UTF8.GetString(memory[..bytesRead].Span); + return res; } private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + while (true) { - Memory memory = writer.GetMemory(ChunkSize)[..ChunkSize]; + Memory memory = writer.GetMemory(ChunkSize); int bytesRead = await stream.ReadAsync(memory, cancellationToken).ConfigureAwait(false); if (bytesRead == 0) @@ -216,7 +201,10 @@ private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, return sb.ToString(); } - private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + private static Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + => Task.Run(() => WriteStreamToPipeImplAsync(stream, writer, cancellationToken), CancellationToken.None); + + private static async Task WriteStreamToPipeImplAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) { while (true) { diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs index 7e21550929e..66da00b5c78 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Http; +using Microsoft.Extensions.Compliance.Classification; namespace Microsoft.Extensions.Http.Logging.Internal; @@ -24,4 +25,12 @@ internal interface IHttpHeadersReader /// An instance of to read headers from. /// Destination to save read headers to. void ReadResponseHeaders(HttpResponseMessage response, List>? destination); + + /// + /// Redact values by using a . + /// + /// A value that needs to be redacted. + /// An instance of to redact a value. + /// Redacted value. + string RedactValue(string value, DataClassification classification); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs index 3ced3da01b9..3453beed4d0 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs @@ -19,4 +19,12 @@ internal interface IHttpHeadersRedactor /// Data classification which is used to get an appropriate redactor to redact headers. /// Returns text and parameter segments of route. string Redact(IEnumerable headerValues, DataClassification classification); + + /// + /// Redacts HTTP header value which results into a . + /// + /// HTTP header value. + /// Data classification which is used to get an appropriate redactor to redact header. + /// Returns text and parameter segments of route. + string Redact(string headerValue, DataClassification classification); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs index c156eb72419..6edd27217ec 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; #if NET using System.Globalization; #endif @@ -14,12 +13,14 @@ namespace Microsoft.Extensions.Http.Logging.Internal; /// /// Logs , and the exceptions due to errors of request/response. /// -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Event ID's.")] internal static partial class Log { - internal const string OriginalFormat = "{OriginalFormat}"; + private const int MinimalPropertyCount = 5; - private const int MinimalPropertyCount = 4; + private const string OriginalFormat = "{OriginalFormat}"; + + private const string OriginalFormatValue = + $"{{{HttpClientLoggingTagNames.Method}}} {{{HttpClientLoggingTagNames.Host}}}/{{{HttpClientLoggingTagNames.Path}}}"; private const string RequestReadErrorMessage = "An error occurred while reading the request data to fill the logger context for request: " + @@ -96,9 +97,10 @@ private static void OutgoingRequest( var statusCodePropertyCount = record.StatusCode.HasValue ? 1 : 0; var requestHeadersCount = record.RequestHeaders?.Count ?? 0; var responseHeadersCount = record.ResponseHeaders?.Count ?? 0; + var urlQueryPropertyCount = string.IsNullOrEmpty(record.QueryString) ? 0 : 1; var spaceToReserve = MinimalPropertyCount + statusCodePropertyCount + requestHeadersCount + responseHeadersCount + - record.PathParametersCount + (record.RequestBody is null ? 0 : 1) + (record.ResponseBody is null ? 0 : 1); + record.PathParametersCount + (record.RequestBody is null ? 0 : 1) + (record.ResponseBody is null ? 0 : 1) + urlQueryPropertyCount; var index = loggerMessageState.ReserveTagSpace(spaceToReserve); loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.Method, record.Method); @@ -106,6 +108,11 @@ private static void OutgoingRequest( loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.Path, record.Path); loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.Duration, record.Duration); + if (!string.IsNullOrEmpty(record.QueryString)) + { + loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.UrlQuery, record.QueryString); + } + if (record.StatusCode.HasValue) { loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.StatusCode, record.StatusCode.Value); @@ -133,16 +140,18 @@ private static void OutgoingRequest( if (record.ResponseBody is not null) { - loggerMessageState.TagArray[index] = new(HttpClientLoggingTagNames.ResponseBody, record.ResponseBody); + loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.ResponseBody, record.ResponseBody); } + // "{OriginalFormat}" property needs to be the last tag in the list. + loggerMessageState.TagArray[index] = new(OriginalFormat, OriginalFormatValue); + logger.Log( level, new(eventId, eventName), loggerMessageState, exception, _originalFormatValueFmtFunc); - if (record.EnrichmentTags is null) { loggerMessageState.Clear(); diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs index 93238e5809c..0ee95787698 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs @@ -75,6 +75,11 @@ internal sealed class LogRecord : IResettable /// public int PathParametersCount { get; set; } + /// + /// Gets or sets formatted query parameters. + /// + public string? QueryString { get; set; } + public bool TryReset() { if (PathParameters != null) @@ -94,6 +99,7 @@ public bool TryReset() RequestHeaders = null; ResponseHeaders = null; PathParametersCount = 0; + QueryString = string.Empty; return true; } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs index fe2d096f7bc..a2e6e41c490 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs @@ -36,6 +36,20 @@ public class LoggingOptions /// public bool LogRequestStart { get; set; } + /// + /// Gets or sets the set of HTTP request query parameters to log and their respective data classifications to use for redaction. + /// + /// + /// The default value is . + /// + /// + /// If empty, no HTTP request query parameters will be logged. + /// If the data class is , no redaction will be done. + /// + [Required] + [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] + public IDictionary RequestQueryParametersDataClasses { get; set; } = new Dictionary(); + /// /// Gets or sets a value indicating whether the HTTP request and response body are logged. /// @@ -74,16 +88,12 @@ public class LoggingOptions /// /// Gets or sets the list of HTTP request content types which are considered text and thus possible to serialize. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public ISet RequestBodyContentTypes { get; set; } = new HashSet(); /// /// Gets or sets the list of HTTP response content types which are considered text and thus possible to serialize. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public ISet ResponseBodyContentTypes { get; set; } = new HashSet(); @@ -97,8 +107,6 @@ public class LoggingOptions /// If empty, no HTTP request headers will be logged. /// If the data class is , no redaction will be done. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public IDictionary RequestHeadersDataClasses { get; set; } = new Dictionary(); @@ -112,8 +120,6 @@ public class LoggingOptions /// If the data class is , no redaction will be done. /// If empty, no HTTP response headers will be logged. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public IDictionary ResponseHeadersDataClasses { get; set; } = new Dictionary(); @@ -142,8 +148,6 @@ public class LoggingOptions /// Gets or sets the route parameters to redact with their corresponding data classifications to apply appropriate redaction. /// [Required] - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] public IDictionary RouteParameterDataClasses { get; set; } = new Dictionary(); /// diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj index cc00c907ded..bc7b8f4a6fe 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj @@ -3,6 +3,7 @@ Microsoft.Extensions.Http.Diagnostics Telemetry support for HTTP Client. Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 @@ -31,13 +32,12 @@ - - - + + diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs index 7d46a6efe54..36f0863ad49 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs @@ -4,7 +4,6 @@ using System; using System.Net.Http; using System.Threading; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Http.Resilience.Hedging.Internals; diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj index 8d280d747cb..1d1a3684305 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Http.Resilience + $(NetCoreTargetFrameworks);netstandard2.0;net462 Resilience mechanisms for HttpClient. Resilience @@ -28,7 +29,7 @@ - $(NoWarn);LA0006 + $(NoWarn);LA0006 @@ -36,10 +37,6 @@ - - - - diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs index db0a8850e20..7f105586ea6 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs @@ -16,8 +16,6 @@ namespace Microsoft.Extensions.Http.Resilience; /// public class HttpRetryStrategyOptions : RetryStrategyOptions { - private bool _shouldRetryAfterHeader; - /// /// Initializes a new instance of the class. /// @@ -47,12 +45,12 @@ public HttpRetryStrategyOptions() /// public bool ShouldRetryAfterHeader { - get => _shouldRetryAfterHeader; + get; set { - _shouldRetryAfterHeader = value; + field = value; - if (_shouldRetryAfterHeader) + if (field) { DelayGenerator = args => args.Outcome.Result switch { diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs index 85168988c7b..eb42ca393fc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs @@ -4,10 +4,10 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; -using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using Polly; +using Polly.Retry; namespace Microsoft.Extensions.Http.Resilience; @@ -52,9 +52,7 @@ public static void DisableFor(this HttpRetryStrategyOptions options, params Http { var result = await shouldHandle(args).ConfigureAwait(args.Context.ContinueOnCapturedContext); - if (result && - args.Outcome.Result is HttpResponseMessage response && - response.RequestMessage is HttpRequestMessage request) + if (result && GetRequestMessage(args) is HttpRequestMessage request) { return !methods.Contains(request.Method); } @@ -62,5 +60,8 @@ args.Outcome.Result is HttpResponseMessage response && return result; }; } + + private static HttpRequestMessage? GetRequestMessage(RetryPredicateArguments args) => + args.Outcome.Result?.RequestMessage ?? args.Context.GetRequestMessage(); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs index b04bd37a2ad..02ba9aa63dd 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Http.Resilience.Internal; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -using Polly; namespace Polly; diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs index 3c1707dcac8..3c376df98e8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs @@ -77,12 +77,10 @@ static async (context, state) => return Outcome.FromResult(response); } -#pragma warning disable CA1031 // Do not catch general exception types catch (Exception e) { return Outcome.FromException(e); } -#pragma warning restore CA1031 // Do not catch general exception types }, context, (instance: this, request)) diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs index 48a565db41b..f30ebb40f18 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs @@ -4,7 +4,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Net.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Http.Resilience.Internal; diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs index 5783053e3c7..ed7b1e02916 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs @@ -18,7 +18,6 @@ public class OrderedGroupsRoutingOptions /// /// Gets or sets the collection of ordered endpoints groups. /// -#pragma warning disable CA2227 // Collection properties should be read only #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code [Required] #if NET8_0_OR_GREATER @@ -29,5 +28,4 @@ public class OrderedGroupsRoutingOptions [ValidateEnumeratedItems] public IList Groups { get; set; } = new List(); #pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs index 2373144556d..028eaca7762 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs @@ -6,15 +6,11 @@ namespace Microsoft.Extensions.Http.Resilience; -#pragma warning disable IDE0032 // Use auto property - /// /// Represents a URI-based endpoint. /// public class UriEndpoint { - private Uri? _uri; - /// /// Gets or sets the URL of the endpoint. /// @@ -22,9 +18,5 @@ public class UriEndpoint /// Only schema, domain name, and port are used. The rest of the URL is constructed from the request URL. /// [Required] - public Uri? Uri - { - get => _uri; - set => _uri = value; - } + public Uri? Uri { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs index da21d5093ae..c8c3980ab9f 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs @@ -19,7 +19,6 @@ public class UriEndpointGroup /// The client must define the endpoint for each endpoint group. /// At least one endpoint must be defined on each endpoint group in order to performed hedged requests. /// -#pragma warning disable CA2227 // Collection properties should be read only #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code #if NET8_0_OR_GREATER [System.ComponentModel.DataAnnotations.Length(1, int.MaxValue)] @@ -29,5 +28,4 @@ public class UriEndpointGroup [ValidateEnumeratedItems] public IList Endpoints { get; set; } = new List(); #pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs index 15599856789..1f8114baaf4 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs @@ -24,7 +24,6 @@ public class WeightedGroupsRoutingOptions /// /// Gets or sets the collection of weighted endpoints groups. /// -#pragma warning disable CA2227 // Collection properties should be read only #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code [Required] #if NET8_0_OR_GREATER @@ -35,5 +34,4 @@ public class WeightedGroupsRoutingOptions [ValidateEnumeratedItems] public IList Groups { get; set; } = new List(); #pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs b/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs index 3de29b7712d..1dd3d6a2054 100644 --- a/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs @@ -48,7 +48,6 @@ public ContextualOptionsFactory( /// [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly", Justification = "The ValueTasks are awaited only once.")] - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We need to catch it all so we can rethrow it all.")] public ValueTask CreateAsync(string name, in TContext context, CancellationToken cancellationToken) where TContext : notnull, IOptionsContext { diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj index ebd63256933..519b6632d07 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Resilience + $(NetCoreTargetFrameworks);netstandard2.0;net462 Extensions to the Polly libraries to enrich telemetry with metadata and exception summaries. Resilience diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs index 545ce4b6134..6bd3a175969 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Extensions.Http.Diagnostics; using Polly; diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs index 243c046b80e..96fa77e27f7 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Extensions.Http.Diagnostics; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Resilience.Internal; using Microsoft.Shared.Diagnostics; using Polly.Telemetry; diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IHostNameFeature.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IHostNameFeature.cs new file mode 100644 index 00000000000..c7489472374 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IHostNameFeature.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Exposes the host name of the end point. +/// +public interface IHostNameFeature +{ + /// + /// Gets the host name of the end point. + /// + public string HostName { get; } +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointBuilder.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointBuilder.cs new file mode 100644 index 00000000000..e051b2bf746 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointBuilder.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Builder to create a instances. +/// +public interface IServiceEndpointBuilder +{ + /// + /// Gets the endpoints. + /// + IList Endpoints { get; } + + /// + /// Gets the feature collection. + /// + IFeatureCollection Features { get; } + + /// + /// Adds a change token to the resulting . + /// + /// The change token. + void AddChangeToken(IChangeToken changeToken); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProvider.cs new file mode 100644 index 00000000000..4a192180b66 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProvider.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Provides details about a service's endpoints. +/// +public interface IServiceEndpointProvider : IAsyncDisposable +{ + /// + /// Resolves the endpoints for the service. + /// + /// The endpoint collection, which resolved endpoints will be added to. + /// The token to monitor for cancellation requests. + /// The resolution status. + ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..009cbf05d76 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProviderFactory.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Creates instances. +/// +public interface IServiceEndpointProviderFactory +{ + /// + /// Tries to create an instance for the specified . + /// + /// The service to create the provider for. + /// The provider. + /// if the provider was created, otherwise. + bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Internal/ServiceEndpointImpl.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Internal/ServiceEndpointImpl.cs new file mode 100644 index 00000000000..151a9309338 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Internal/ServiceEndpointImpl.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.Extensions.ServiceDiscovery.Internal; + +internal sealed class ServiceEndpointImpl(EndPoint endPoint, IFeatureCollection? features = null) : ServiceEndpoint +{ + public override EndPoint EndPoint { get; } = endPoint; + + public override IFeatureCollection Features { get; } = features ?? new FeatureCollection(); + + public override string? ToString() => EndPoint switch + { + IPEndPoint ip when ip.Port == 0 && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 => $"[{ip.Address}]", + IPEndPoint ip when ip.Port == 0 => $"{ip.Address}", + DnsEndPoint dns when dns.Port == 0 => $"{dns.Host}", + DnsEndPoint dns => $"{dns.Host}:{dns.Port}", + _ => EndPoint.ToString()! + }; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.csproj new file mode 100644 index 00000000000..b96d5ff7137 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.csproj @@ -0,0 +1,39 @@ + + + + $(TargetFrameworks);netstandard2.0 + true + Provides abstractions for service discovery. Interfaces defined in this package are implemented in Microsoft.Extensions.ServiceDiscovery and other service discovery packages. + Open + true + Microsoft.Extensions.ServiceDiscovery + + $(NoWarn);S1144;CA1002;S2365;SA1642;IDE0040;CA1307;EA0009;LA0003 + enable + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.json similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json rename to src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.json diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/README.md new file mode 100644 index 00000000000..0d97211313e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/README.md @@ -0,0 +1,7 @@ +# Microsoft.Extensions.ServiceDiscovery.Abstractions + +The `Microsoft.Extensions.ServiceDiscovery.Abstractions` library provides abstractions used by the `Microsoft.Extensions.ServiceDiscovery` library and other libraries which implement service discovery extensions, such as service endpoint providers. For more information, see [Service discovery in .NET](https://learn.microsoft.com/dotnet/core/extensions/service-discovery). + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpoint.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpoint.cs new file mode 100644 index 00000000000..e5a10374adc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpoint.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.ServiceDiscovery.Internal; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Represents an endpoint for a service. +/// +public abstract class ServiceEndpoint +{ + /// + /// Gets the endpoint. + /// + public abstract EndPoint EndPoint { get; } + + /// + /// Gets the collection of endpoint features. + /// + public abstract IFeatureCollection Features { get; } + + /// + /// Creates a new . + /// + /// The endpoint being represented. + /// Features of the endpoint. + /// A newly initialized . + public static ServiceEndpoint Create(EndPoint endPoint, IFeatureCollection? features = null) + { + ArgumentNullException.ThrowIfNull(endPoint); + + return new ServiceEndpointImpl(endPoint, features); + } + + /// + /// Tries to convert a specified string representation to its equivalent, + /// and returns a value that indicates whether the conversion succeeded. + /// + /// A string that consists of an IP address or hostname, optionally followed by a colon and port number, or a URI. + /// When this method returns, contains the equivalent if the conversion succeeded; otherwise, + /// . This parameter is passed uninitialized; any value originally supplied will be overwritten. + /// if the string was successfully parsed into a ; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? value, + [NotNullWhen(true)] out ServiceEndpoint? serviceEndpoint) + { + EndPoint? endPoint = TryParseEndPoint(value); + + if (endPoint != null) + { + serviceEndpoint = Create(endPoint); + return true; + } + else + { + serviceEndpoint = null; + return false; + } + } + + private static EndPoint? TryParseEndPoint(string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { +#pragma warning disable CS8602 + if (value.IndexOf("://", StringComparison.Ordinal) < 0 && Uri.TryCreate($"fakescheme://{value}", default, out var uri)) +#pragma warning restore CS8602 + { + var port = uri.Port > 0 ? uri.Port : 0; + return IPAddress.TryParse(uri.Host, out var ip) + ? new IPEndPoint(ip, port) + : new DnsEndPoint(uri.Host, port); + } + + if (Uri.TryCreate(value, default, out uri)) + { + return new UriEndPoint(uri); + } + } + + return null; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointQuery.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointQuery.cs new file mode 100644 index 00000000000..36fca0893cc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointQuery.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Describes a query for endpoints of a service. +/// +public sealed class ServiceEndpointQuery +{ + private readonly string _originalString; + + /// + /// Initializes a new instance. + /// + /// The string which the query was constructed from. + /// The ordered list of included URI schemes. + /// The service name. + /// The optional endpoint name. + private ServiceEndpointQuery(string originalString, string[] includedSchemes, string serviceName, string? endpointName) + { + _originalString = originalString; + IncludedSchemes = includedSchemes; + ServiceName = serviceName; + EndpointName = endpointName; + } + + /// + /// Tries to parse the provided input as a service endpoint query. + /// + /// The value to parse. + /// The resulting query. + /// if the value was successfully parsed; otherwise . + public static bool TryParse(string input, [NotNullWhen(true)] out ServiceEndpointQuery? query) + { + ArgumentException.ThrowIfNullOrEmpty(input); + + bool hasScheme; + if (!input.Contains("://", StringComparison.Ordinal) + && Uri.TryCreate($"fakescheme://{input}", default, out var uri)) + { + hasScheme = false; + } + else if (Uri.TryCreate(input, default, out uri)) + { + hasScheme = true; + } + else + { + query = null; + return false; + } + + var uriHost = uri.Host; + var segmentSeparatorIndex = uriHost.IndexOf('.'); + string host; + string? endpointName = null; + if (uriHost.StartsWith('_') && segmentSeparatorIndex > 1 && uriHost[^1] != '.') + { + endpointName = uriHost[1..segmentSeparatorIndex]; + + // Skip the endpoint name, including its prefix ('_') and suffix ('.'). + host = uriHost[(segmentSeparatorIndex + 1)..]; + } + else + { + host = uriHost; + } + + // Allow multiple schemes to be separated by a '+', eg. "https+http://host:port". + var schemes = hasScheme ? uri.Scheme.Split('+') : []; + query = new(input, schemes, host, endpointName); + return true; + } + + /// + /// Gets the ordered list of included URI schemes. + /// + public IReadOnlyList IncludedSchemes { get; } + + /// + /// Gets the endpoint name, or if no endpoint name is specified. + /// + public string? EndpointName { get; } + + /// + /// Gets the service name. + /// + public string ServiceName { get; } + + /// + public override string? ToString() => _originalString; +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointSource.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointSource.cs new file mode 100644 index 00000000000..28d987a2f34 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointSource.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Represents a collection of service endpoints. +/// +[DebuggerDisplay("{ToString(),nq}")] +[DebuggerTypeProxy(typeof(ServiceEndpointCollectionDebuggerView))] +public sealed class ServiceEndpointSource +{ + private readonly List? _endpoints; + + /// + /// Initializes a new instance. + /// + /// The endpoints. + /// The change token. + /// The feature collection. + public ServiceEndpointSource(List? endpoints, IChangeToken changeToken, IFeatureCollection features) + { + ArgumentNullException.ThrowIfNull(changeToken); + ArgumentNullException.ThrowIfNull(features); + + _endpoints = endpoints; + Features = features; + ChangeToken = changeToken; + } + + /// + /// Gets the endpoints. + /// + public IReadOnlyList Endpoints => _endpoints ?? (IReadOnlyList)[]; + + /// + /// Gets the change token which indicates when this collection should be refreshed. + /// + public IChangeToken ChangeToken { get; } + + /// + /// Gets the feature collection. + /// + public IFeatureCollection Features { get; } + + /// + public override string ToString() + { + if (_endpoints is not { } eps) + { + return "[]"; + } + + return $"[{string.Join(", ", eps)}]"; + } + + private sealed class ServiceEndpointCollectionDebuggerView(ServiceEndpointSource value) + { + public IChangeToken ChangeToken => value.ChangeToken; + + public IFeatureCollection Features => value.Features; + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public ServiceEndpoint[] Endpoints => value.Endpoints.ToArray(); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/UriEndPoint.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/UriEndPoint.cs new file mode 100644 index 00000000000..d7bb1ab040e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/UriEndPoint.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// An endpoint represented by a . +/// +public class UriEndPoint : EndPoint +{ + /// + /// Creates a new . + /// + /// The . + public UriEndPoint(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + Uri = uri; + } + + /// + /// Gets the associated with this endpoint. + /// + public Uri Uri { get; } + + /// + public override bool Equals(object? obj) + { + return obj is UriEndPoint other && Uri.Equals(other.Uri); + } + + /// + public override int GetHashCode() => Uri.GetHashCode(); + + /// + public override string? ToString() => Uri.ToString(); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptions.cs new file mode 100644 index 00000000000..0cc256bd3c3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +/// +/// Provides configuration options for DNS resolution, including server endpoints, retry attempts, and timeout settings. +/// +public class DnsResolverOptions +{ + /// + /// Gets or sets the collection of server endpoints used for network connections. + /// + public IList Servers { get; set; } = new List(); + + /// + /// Gets or sets the maximum number of attempts per server. + /// + public int MaxAttempts { get; set; } = 2; + + /// + /// Gets or sets the maximum duration per attempt to wait before timing out. + /// + /// + /// The maximum time for resolving a query is * count * . + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(3); + + // override for testing purposes + internal Func, int, int>? _transportOverride; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptionsValidator.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptionsValidator.cs new file mode 100644 index 00000000000..d61da5e5e0b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptionsValidator.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed class DnsResolverOptionsValidator : IValidateOptions +{ + // CancellationTokenSource.CancelAfter has a maximum timeout of Int32.MaxValue milliseconds. + private static readonly TimeSpan s_maxTimeout = TimeSpan.FromMilliseconds(int.MaxValue); + + public ValidateOptionsResult Validate(string? name, DnsResolverOptions options) + { + if (options.Servers is null) + { + return ValidateOptionsResult.Fail($"{nameof(options.Servers)} must not be null."); + } + + if (options.MaxAttempts < 1) + { + return ValidateOptionsResult.Fail($"{nameof(options.MaxAttempts)} must be one or greater."); + } + + if (options.Timeout != Timeout.InfiniteTimeSpan) + { + if (options.Timeout <= TimeSpan.Zero) + { + return ValidateOptionsResult.Fail($"{nameof(options.Timeout)} must not be negative or zero."); + } + + if (options.Timeout > s_maxTimeout) + { + return ValidateOptionsResult.Fail($"{nameof(options.Timeout)} must not be greater than {s_maxTimeout.TotalMilliseconds} milliseconds."); + } + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProvider.cs new file mode 100644 index 00000000000..7a2d1b632e0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProvider.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsServiceEndpointProvider( + ServiceEndpointQuery query, + string hostName, + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : DnsServiceEndpointProviderBase(query, logger, timeProvider), IHostNameFeature +{ + protected override double RetryBackOffFactor => options.CurrentValue.RetryBackOffFactor; + protected override TimeSpan MinRetryPeriod => options.CurrentValue.MinRetryPeriod; + protected override TimeSpan MaxRetryPeriod => options.CurrentValue.MaxRetryPeriod; + protected override TimeSpan DefaultRefreshPeriod => options.CurrentValue.DefaultRefreshPeriod; + + string IHostNameFeature.HostName => hostName; + + /// + public override string ToString() => "DNS"; + + protected override async Task ResolveAsyncCore() + { + var endpoints = new List(); + var ttl = DefaultRefreshPeriod; + Log.AddressQuery(logger, ServiceName, hostName); + + var now = _timeProvider.GetUtcNow().DateTime; + var addresses = await resolver.ResolveIPAddressesAsync(hostName, ShutdownToken).ConfigureAwait(false); + + foreach (var address in addresses) + { + ttl = MinTtl(now, address.ExpiresAt, ttl); + endpoints.Add(CreateEndpoint(new IPEndPoint(address.Address, port: 0))); + } + + if (endpoints.Count == 0) + { + throw new InvalidOperationException($"No DNS records were found for service '{ServiceName}' (DNS name: '{hostName}')."); + } + + SetResult(endpoints, ttl); + + static TimeSpan MinTtl(DateTime now, DateTime expiresAt, TimeSpan existing) + { + var candidate = expiresAt - now; + return candidate < existing ? candidate : existing; + } + + ServiceEndpoint CreateEndpoint(EndPoint endPoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endPoint); + serviceEndpoint.Features.Set(this); + if (options.CurrentValue.ShouldApplyHostNameMetadata(serviceEndpoint)) + { + serviceEndpoint.Features.Set(this); + } + + return serviceEndpoint; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.Log.cs new file mode 100644 index 00000000000..29aaaf8e930 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.Log.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +partial class DnsServiceEndpointProviderBase +{ + internal static partial class Log + { + [LoggerMessage(1, LogLevel.Trace, "Resolving endpoints for service '{ServiceName}' using DNS SRV lookup for name '{RecordName}'.", EventName = "SrvQuery")] + public static partial void SrvQuery(ILogger logger, string serviceName, string recordName); + + [LoggerMessage(2, LogLevel.Trace, "Resolving endpoints for service '{ServiceName}' using host lookup for name '{RecordName}'.", EventName = "AddressQuery")] + public static partial void AddressQuery(ILogger logger, string serviceName, string recordName); + + [LoggerMessage(3, LogLevel.Debug, "Skipping endpoint resolution for service '{ServiceName}': '{Reason}'.", EventName = "SkippedResolution")] + public static partial void SkippedResolution(ILogger logger, string serviceName, string reason); + + [LoggerMessage(4, LogLevel.Debug, "Service name '{ServiceName}' is not a valid URI or DNS name.", EventName = "ServiceNameIsNotUriOrDnsName")] + public static partial void ServiceNameIsNotUriOrDnsName(ILogger logger, string serviceName); + + [LoggerMessage(5, LogLevel.Debug, "DNS SRV query cannot be constructed for service name '{ServiceName}' because no DNS namespace was configured or detected.", EventName = "NoDnsSuffixFound")] + public static partial void NoDnsSuffixFound(ILogger logger, string serviceName); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.cs new file mode 100644 index 00000000000..311c06f631a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +/// +/// A service end point provider that uses DNS to resolve the service end points. +/// +internal abstract partial class DnsServiceEndpointProviderBase : IServiceEndpointProvider +{ + private readonly object _lock = new(); + private readonly ILogger _logger; + private readonly CancellationTokenSource _disposeCancellation = new(); + protected readonly TimeProvider _timeProvider; + private long _lastRefreshTimeStamp; + private Task _resolveTask = Task.CompletedTask; + private bool _hasEndpoints; + private CancellationChangeToken _lastChangeToken; + private CancellationTokenSource _lastCollectionCancellation; + private List? _lastEndpointCollection; + private TimeSpan _nextRefreshPeriod; + + /// + /// Initializes a new instance. + /// + /// The service name. + /// The logger. + /// The time provider. + protected DnsServiceEndpointProviderBase( + ServiceEndpointQuery query, + ILogger logger, + TimeProvider timeProvider) + { + ServiceName = query.ToString()!; + _logger = logger; + _lastEndpointCollection = null; + _timeProvider = timeProvider; + _lastRefreshTimeStamp = _timeProvider.GetTimestamp(); + var cancellation = _lastCollectionCancellation = new CancellationTokenSource(); + _lastChangeToken = new CancellationChangeToken(cancellation.Token); + } + + private TimeSpan ElapsedSinceRefresh => _timeProvider.GetElapsedTime(_lastRefreshTimeStamp); + + protected string ServiceName { get; } + + protected abstract double RetryBackOffFactor { get; } + + protected abstract TimeSpan MinRetryPeriod { get; } + + protected abstract TimeSpan MaxRetryPeriod { get; } + + protected abstract TimeSpan DefaultRefreshPeriod { get; } + + protected CancellationToken ShutdownToken => _disposeCancellation.Token; + + /// + public async ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) + { + // Only add endpoints to the collection if a previous provider (eg, a configuration override) did not add them. + if (endpoints.Endpoints.Count != 0) + { + Log.SkippedResolution(_logger, ServiceName, "Collection has existing endpoints"); + return; + } + + if (ShouldRefresh()) + { + Task resolveTask; + lock (_lock) + { + if (_resolveTask.IsCompleted && ShouldRefresh()) + { + _resolveTask = ResolveAsyncCore(); + } + + resolveTask = _resolveTask; + } + + await resolveTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + lock (_lock) + { + if (_lastEndpointCollection is { Count: > 0 } eps) + { + foreach (var ep in eps) + { + endpoints.Endpoints.Add(ep); + } + } + + endpoints.AddChangeToken(_lastChangeToken); + return; + } + } + + private bool ShouldRefresh() => _lastEndpointCollection is null || _lastChangeToken is { HasChanged: true } || ElapsedSinceRefresh >= _nextRefreshPeriod; + + protected abstract Task ResolveAsyncCore(); + + protected void SetResult(List endpoints, TimeSpan validityPeriod) + { + lock (_lock) + { + if (endpoints is { Count: > 0 }) + { + _lastRefreshTimeStamp = _timeProvider.GetTimestamp(); + _nextRefreshPeriod = DefaultRefreshPeriod; + _hasEndpoints = true; + } + else + { + _nextRefreshPeriod = GetRefreshPeriod(); + validityPeriod = TimeSpan.Zero; + _hasEndpoints = false; + } + + if (validityPeriod <= TimeSpan.Zero) + { + validityPeriod = _nextRefreshPeriod; + } + else if (validityPeriod > _nextRefreshPeriod) + { + validityPeriod = _nextRefreshPeriod; + } + + _lastCollectionCancellation.Cancel(); + var cancellation = _lastCollectionCancellation = new CancellationTokenSource(validityPeriod, _timeProvider); + _lastChangeToken = new CancellationChangeToken(cancellation.Token); + _lastEndpointCollection = endpoints; + } + + TimeSpan GetRefreshPeriod() + { + if (_hasEndpoints) + { + return MinRetryPeriod; + } + + var nextTicks = (long)(_nextRefreshPeriod.Ticks * RetryBackOffFactor); + if (nextTicks <= 0 || nextTicks > MaxRetryPeriod.Ticks) + { + return MaxRetryPeriod; + } + + return TimeSpan.FromTicks(nextTicks); + } + } + + /// + public async ValueTask DisposeAsync() + { + _disposeCancellation.Cancel(); + + if (_resolveTask is { } task) + { + await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..1da21411e64 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderFactory.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsServiceEndpointProviderFactory( + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : IServiceEndpointProviderFactory +{ + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + provider = new DnsServiceEndpointProvider(query, hostName: query.ServiceName, options, logger, resolver, timeProvider); + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderOptions.cs new file mode 100644 index 00000000000..b163afc76ff --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderOptions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +/// +/// Options for configuring . +/// +public class DnsServiceEndpointProviderOptions +{ + /// + /// Gets or sets the default refresh period for endpoints resolved from DNS. + /// + public TimeSpan DefaultRefreshPeriod { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the initial period between retries. + /// + public TimeSpan MinRetryPeriod { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum period between retries. + /// + public TimeSpan MaxRetryPeriod { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the retry period growth factor. + /// + public double RetryBackOffFactor { get; set; } = 2; + + /// + /// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to false. + /// + public Func ShouldApplyHostNameMetadata { get; set; } = _ => false; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProvider.cs new file mode 100644 index 00000000000..6d5ade5059e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProvider.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsSrvServiceEndpointProvider( + ServiceEndpointQuery query, + string srvQuery, + string hostName, + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : DnsServiceEndpointProviderBase(query, logger, timeProvider), IHostNameFeature +{ + protected override double RetryBackOffFactor => options.CurrentValue.RetryBackOffFactor; + + protected override TimeSpan MinRetryPeriod => options.CurrentValue.MinRetryPeriod; + + protected override TimeSpan MaxRetryPeriod => options.CurrentValue.MaxRetryPeriod; + + protected override TimeSpan DefaultRefreshPeriod => options.CurrentValue.DefaultRefreshPeriod; + + public override string ToString() => "DNS SRV"; + + string IHostNameFeature.HostName => hostName; + + protected override async Task ResolveAsyncCore() + { + var endpoints = new List(); + var ttl = DefaultRefreshPeriod; + Log.SrvQuery(logger, ServiceName, srvQuery); + + var now = _timeProvider.GetUtcNow().DateTime; + var result = await resolver.ResolveServiceAsync(srvQuery, cancellationToken: ShutdownToken).ConfigureAwait(false); + + foreach (var record in result) + { + ttl = MinTtl(now, record.ExpiresAt, ttl); + + if (record.Addresses.Length > 0) + { + foreach (var address in record.Addresses) + { + ttl = MinTtl(now, address.ExpiresAt, ttl); + endpoints.Add(CreateEndpoint(new IPEndPoint(address.Address, record.Port))); + } + } + else + { + endpoints.Add(CreateEndpoint(new DnsEndPoint(record.Target.TrimEnd('.'), record.Port))); + } + } + + SetResult(endpoints, ttl); + + static TimeSpan MinTtl(DateTime now, DateTime expiresAt, TimeSpan existing) + { + var candidate = expiresAt - now; + return candidate < existing ? candidate : existing; + } + + ServiceEndpoint CreateEndpoint(EndPoint endPoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endPoint); + serviceEndpoint.Features.Set(this); + if (options.CurrentValue.ShouldApplyHostNameMetadata(serviceEndpoint)) + { + serviceEndpoint.Features.Set(this); + } + + return serviceEndpoint; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..ef593a7340c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsSrvServiceEndpointProviderFactory( + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : IServiceEndpointProviderFactory +{ + private static readonly string s_serviceAccountPath = Path.Combine($"{Path.DirectorySeparatorChar}var", "run", "secrets", "kubernetes.io", "serviceaccount"); + private static readonly string s_serviceAccountNamespacePath = Path.Combine($"{Path.DirectorySeparatorChar}var", "run", "secrets", "kubernetes.io", "serviceaccount", "namespace"); + private static readonly string s_resolveConfPath = Path.Combine($"{Path.DirectorySeparatorChar}etc", "resolv.conf"); + private readonly string? _querySuffix = options.CurrentValue.QuerySuffix?.TrimStart('.') ?? GetKubernetesHostDomain(); + + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + var optionsValue = options.CurrentValue; + + // If a default namespace is not specified, then this provider will attempt to infer the namespace from the service name, but only when running inside Kubernetes. + // Kubernetes DNS spec: https://github.com/kubernetes/dns/blob/master/docs/specification.md + // SRV records are available for headless services with named ports. + // They take the form $"_{portName}._{protocol}.{serviceName}.{namespace}.{suffix}" + // The suffix (after the service name) can be parsed from /etc/resolv.conf + // Otherwise, the namespace can be read from /var/run/secrets/kubernetes.io/serviceaccount/namespace and combined with an assumed suffix of "svc.cluster.local". + // The protocol is assumed to be "tcp". + // The portName is the name of the port in the service definition. If the serviceName parses as a URI, we use the scheme as the port name, otherwise "default". + if (optionsValue.ServiceDomainNameCallback == null && string.IsNullOrWhiteSpace(_querySuffix)) + { + DnsServiceEndpointProviderBase.Log.NoDnsSuffixFound(logger, query.ToString()!); + provider = default; + return false; + } + + var srvQuery = optionsValue.ServiceDomainNameCallback != null + ? optionsValue.ServiceDomainNameCallback(query) + : DefaultServiceDomainNameCallback(query, optionsValue); + provider = new DnsSrvServiceEndpointProvider(query, srvQuery, hostName: query.ServiceName, options, logger, resolver, timeProvider); + return true; + } + + private static string DefaultServiceDomainNameCallback(ServiceEndpointQuery query, DnsSrvServiceEndpointProviderOptions options) + { + var portName = query.EndpointName ?? "default"; + return $"_{portName}._tcp.{query.ServiceName}.{options.QuerySuffix}"; + } + + private static string? GetKubernetesHostDomain() + { + // Check that we are running in Kubernetes first. + if (!IsInKubernetesCluster()) + { + return null; + } + + if (!OperatingSystem.IsLinux()) + { + return null; + } + + var qualifiedNamespace = ReadQualifiedNamespaceFromResolvConf(); + if (!string.IsNullOrWhiteSpace(qualifiedNamespace)) + { + return qualifiedNamespace; + } + + var serviceAccountNamespace = ReadNamespaceFromKubernetesServiceAccount(); + if (!string.IsNullOrWhiteSpace(serviceAccountNamespace)) + { + // The zone is assumed to be "cluster.local" + return $"{serviceAccountNamespace}.svc.cluster.local"; + } + + return null; + } + + private static string? ReadNamespaceFromKubernetesServiceAccount() + { + // Read the namespace from the Kubernetes pod's service account. + if (File.Exists(s_serviceAccountNamespacePath)) + { + return File.ReadAllText(s_serviceAccountNamespacePath).Trim(); + } + + return null; + } + + private static string? ReadQualifiedNamespaceFromResolvConf() + { + if (!File.Exists(s_resolveConfPath)) + { + return default; + } + + // See https://manpages.debian.org/bookworm/manpages/resolv.conf.5.en.html#search for the format of /etc/resolv.conf's search option. + // In our case, we are interested in determining the domain name. + var lines = File.ReadAllLines(s_resolveConfPath); + foreach (var line in lines) + { + if (!line.StartsWith("search ", StringComparison.Ordinal)) + { + continue; + } + + var components = line.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (components.Length > 1) + { + return components[1]; + } + } + + return default; + } + + private static bool IsInKubernetesCluster() + { + // This logic is based on the Kubernetes C# client logic found here: + // https://github.com/kubernetes-client/csharp/blob/52c3c00d4c55b28bdb491a219f4967823a83df2d/src/KubernetesClient/KubernetesClientConfiguration.InCluster.cs#L21 + var host = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST"); + var port = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_PORT"); + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(port)) + { + return false; + } + + var tokenPath = Path.Combine(s_serviceAccountPath, "token"); + if (!File.Exists(tokenPath)) + { + return false; + } + + var certPath = Path.Combine(s_serviceAccountPath, "ca.crt"); + return File.Exists(certPath); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs new file mode 100644 index 00000000000..c1d64136cc9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +/// +/// Options for configuring . +/// +public class DnsSrvServiceEndpointProviderOptions +{ + /// + /// Gets or sets the default refresh period for endpoints resolved from DNS. + /// + public TimeSpan DefaultRefreshPeriod { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the initial period between retries. + /// + public TimeSpan MinRetryPeriod { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum period between retries. + /// + public TimeSpan MaxRetryPeriod { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the retry period growth factor. + /// + public double RetryBackOffFactor { get; set; } = 2; + + /// + /// Gets or sets the default DNS query suffix for services resolved via this provider. + /// + /// + /// If not specified, the provider will attempt to infer the namespace. + /// + public string? QuerySuffix { get; set; } + + /// + /// Gets or sets a delegate that generates a DNS SRV query from a specified instance. + /// + public Func? ServiceDomainNameCallback { get; set; } + + /// + /// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to false. + /// + public Func ShouldApplyHostNameMetadata { get; set; } = _ => false; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/FallbackDnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/FallbackDnsResolver.cs new file mode 100644 index 00000000000..1cdcab2f05d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/FallbackDnsResolver.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using DnsClient; +using DnsClient.Protocol; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed class FallbackDnsResolver : IDnsResolver +{ + private readonly LookupClient _lookupClient; + private readonly IOptionsMonitor _options; + private readonly TimeProvider _timeProvider; + + public FallbackDnsResolver(LookupClient lookupClient, IOptionsMonitor options, TimeProvider timeProvider) + { + _lookupClient = lookupClient; + _options = options; + _timeProvider = timeProvider; + } + + private TimeSpan DefaultRefreshPeriod => _options.CurrentValue.DefaultRefreshPeriod; + + public async ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) + { + DateTime expiresAt = _timeProvider.GetUtcNow().DateTime.Add(DefaultRefreshPeriod); + var addresses = await System.Net.Dns.GetHostAddressesAsync(name, cancellationToken).ConfigureAwait(false); + + var results = new AddressResult[addresses.Length]; + + for (int i = 0; i < addresses.Length; i++) + { + results[i] = new AddressResult + { + Address = addresses[i], + ExpiresAt = expiresAt + }; + } + + return results; + } + + public async ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) + { + DateTime now = _timeProvider.GetUtcNow().DateTime; + var queryResult = await _lookupClient.QueryAsync(name, DnsClient.QueryType.SRV, cancellationToken: cancellationToken).ConfigureAwait(false); + if (queryResult.HasError) + { + throw CreateException(name, queryResult.ErrorMessage); + } + + var lookupMapping = new Dictionary>(); + foreach (var record in queryResult.Additionals.OfType()) + { + if (!lookupMapping.TryGetValue(record.DomainName, out var addresses)) + { + addresses = new List(); + lookupMapping[record.DomainName] = addresses; + } + + addresses.Add(new AddressResult + { + Address = record.Address, + ExpiresAt = now.Add(TimeSpan.FromSeconds(record.TimeToLive)) + }); + } + + var srvRecords = queryResult.Answers.OfType().ToList(); + + var results = new ServiceResult[srvRecords.Count]; + for (int i = 0; i < srvRecords.Count; i++) + { + var record = srvRecords[i]; + + results[i] = new ServiceResult + { + ExpiresAt = now.Add(TimeSpan.FromSeconds(record.TimeToLive)), + Priority = record.Priority, + Weight = record.Weight, + Port = record.Port, + Target = record.Target, + Addresses = lookupMapping.TryGetValue(record.Target, out var addresses) + ? addresses.ToArray() + : Array.Empty() + }; + } + + return results; + } + + private static InvalidOperationException CreateException(string dnsName, string errorMessage) + { + var msg = errorMessage switch + { + { Length: > 0 } => $"No DNS SRV records were found for DNS name '{dnsName}': {errorMessage}.", + _ => $"No DNS SRV records were found for DNS name '{dnsName}'", + }; + return new InvalidOperationException(msg); + } +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.csproj new file mode 100644 index 00000000000..6424c4b5c5e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.csproj @@ -0,0 +1,42 @@ + + + + $(NetCoreTargetFrameworks) + true + Provides extensions to HttpClient to resolve well-known hostnames to concrete endpoints based on DNS records. Useful for service resolution in orchestrators such as Kubernetes. + Open + + $(NoWarn);IDE0018;IDE0025;IDE0032;IDE0040;IDE0058;IDE0250;IDE0251;IDE1006;CA1304;CA1307;CA1309;CA1310;CA1849;CA2000;CA2213;CA2217;S125;S1135;S1226;S2344;S3626;S4022;SA1108;SA1120;SA1128;SA1129;SA1204;SA1205;SA1214;SA1400;SA1405;SA1408;SA1515;SA1600;SA1629;SA1642;SA1649;EA0001;EA0009;EA0014;LA0001;LA0003;LA0008;VSTHRD200 + enable + false + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/README.md new file mode 100644 index 00000000000..8be4560870b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/README.md @@ -0,0 +1,65 @@ +# Microsoft.Extensions.ServiceDiscovery.Dns + +This library provides support for resolving service endpoints using DNS (Domain Name System). It provides two service endpoint providers: + +- _DNS_, which resolves endpoints using DNS `A/AAAA` record queries. This means that it can resolve names to IP addresses, but cannot resolve port numbers endpoints. As such, port numbers are assumed to be the default for the protocol (for example, 80 for HTTP and 433 for HTTPS). The benefit of using the DNS provider is that for cases where these default ports are appropriate, clients can spread their requests across hosts. For more information, see _Load-balancing with endpoint selectors_. + +- _DNS SRV_, which resolves service names using DNS SRV record queries. This allows it to resolve both IP addresses and port numbers. This is useful for environments which support DNS SRV queries, such as Kubernetes (when configured accordingly). + +## Resolving service endpoints with DNS + +The _DNS_ service endpoint provider resolves endpoints using DNS `A/AAAA` record queries. This means that it can resolve names to IP addresses, but cannot resolve port numbers endpoints. As such, port numbers are assumed to be the default for the protocol (for example, 80 for HTTP and 433 for HTTPS). The benefit of using the DNS service endpoint provider is that for cases where these default ports are appropriate, clients can spread their requests across hosts. For more information, see _Load-balancing with endpoint selectors_. + +To configure the DNS service endpoint provider in your application, add the DNS service endpoint provider to your host builder's service collection using the `AddDnsServiceEndpointProvider` method. service discovery as follows: + +```csharp +builder.Services.AddServiceDiscoveryCore(); +builder.Services.AddDnsServiceEndpointProvider(); +``` + +## Resolving service endpoints in Kubernetes with DNS SRV + +When deploying to Kubernetes, the DNS SRV service endpoint provider can be used to resolve endpoints. For example, the following resource definition will result in a DNS SRV record being created for an endpoint named "default" and an endpoint named "dashboard", both on the service named "basket". + +```yml +apiVersion: v1 +kind: Service +metadata: + name: basket +spec: + selector: + name: basket-service + clusterIP: None + ports: + - name: default + port: 8080 + - name: dashboard + port: 8888 +``` + +To configure a service to resolve the "dashboard" endpoint on the "basket" service, add the DNS SRV service endpoint provider to the host builder as follows: + +```csharp +builder.Services.AddServiceDiscoveryCore(); +builder.Services.AddDnsSrvServiceEndpointProvider(); +``` + +The special port name "default" is used to specify the default endpoint, resolved using the URI `http://basket`. + +As in the previous example, add service discovery to an `HttpClient` for the basket service: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("http://basket")); +``` + +Similarly, the "dashboard" endpoint can be targeted as follows: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("http://_dashboard.basket")); +``` + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataReader.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataReader.cs new file mode 100644 index 00000000000..094df3040d1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataReader.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct DnsDataReader : IDisposable +{ + public ArraySegment MessageBuffer { get; private set; } + bool _returnToPool; + private int _position; + + public DnsDataReader(ArraySegment buffer, bool returnToPool = false) + { + MessageBuffer = buffer; + _position = 0; + _returnToPool = returnToPool; + } + + public bool TryReadHeader(out DnsMessageHeader header) + { + Debug.Assert(_position == 0); + + if (!DnsPrimitives.TryReadMessageHeader(MessageBuffer.AsSpan(), out header, out int bytesRead)) + { + header = default; + return false; + } + + _position += bytesRead; + return true; + } + + internal bool TryReadQuestion(out EncodedDomainName name, out QueryType type, out QueryClass @class) + { + if (!TryReadDomainName(out name) || + !TryReadUInt16(out ushort typeAsInt) || + !TryReadUInt16(out ushort classAsInt)) + { + type = 0; + @class = 0; + return false; + } + + type = (QueryType)typeAsInt; + @class = (QueryClass)classAsInt; + return true; + } + + public bool TryReadUInt16(out ushort value) + { + if (MessageBuffer.Count - _position < 2) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadUInt16BigEndian(MessageBuffer.AsSpan(_position)); + _position += 2; + return true; + } + + public bool TryReadUInt32(out uint value) + { + if (MessageBuffer.Count - _position < 4) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadUInt32BigEndian(MessageBuffer.AsSpan(_position)); + _position += 4; + return true; + } + + public bool TryReadResourceRecord(out DnsResourceRecord record) + { + if (!TryReadDomainName(out EncodedDomainName name) || + !TryReadUInt16(out ushort type) || + !TryReadUInt16(out ushort @class) || + !TryReadUInt32(out uint ttl) || + !TryReadUInt16(out ushort dataLength) || + MessageBuffer.Count - _position < dataLength) + { + record = default; + return false; + } + + ReadOnlyMemory data = MessageBuffer.AsMemory(_position, dataLength); + _position += dataLength; + + record = new DnsResourceRecord(name, (QueryType)type, (QueryClass)@class, (int)ttl, data); + return true; + } + + public bool TryReadDomainName(out EncodedDomainName name) + { + if (DnsPrimitives.TryReadQName(MessageBuffer, _position, out name, out int bytesRead)) + { + _position += bytesRead; + return true; + } + + return false; + } + + public bool TryReadSpan(int length, out ReadOnlySpan name) + { + if (MessageBuffer.Count - _position < length) + { + name = default; + return false; + } + + name = MessageBuffer.AsSpan(_position, length); + _position += length; + return true; + } + + public void Dispose() + { + if (_returnToPool && MessageBuffer.Array != null) + { + ArrayPool.Shared.Return(MessageBuffer.Array); + } + + _returnToPool = false; + MessageBuffer = default; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataWriter.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataWriter.cs new file mode 100644 index 00000000000..a0a11f0b808 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataWriter.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Diagnostics; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal sealed class DnsDataWriter +{ + private readonly Memory _buffer; + private int _position; + + internal DnsDataWriter(Memory buffer) + { + _buffer = buffer; + _position = 0; + } + + public int Position => _position; + + internal bool TryWriteHeader(in DnsMessageHeader header) + { + if (!DnsPrimitives.TryWriteMessageHeader(_buffer.Span.Slice(_position), header, out int written)) + { + return false; + } + + _position += written; + return true; + } + + internal bool TryWriteQuestion(EncodedDomainName name, QueryType type, QueryClass @class) + { + if (!TryWriteDomainName(name) || + !TryWriteUInt16((ushort)type) || + !TryWriteUInt16((ushort)@class)) + { + return false; + } + + return true; + } + + private bool TryWriteDomainName(EncodedDomainName name) + { + foreach (var label in name.Labels) + { + // this should be already validated by the caller + Debug.Assert(label.Length <= 63, "Label length must not exceed 63 bytes."); + + if (!TryWriteByte((byte)label.Length) || + !TryWriteRawData(label.Span)) + { + return false; + } + } + + // root label + return TryWriteByte(0); + } + + internal bool TryWriteDomainName(string name) + { + if (DnsPrimitives.TryWriteQName(_buffer.Span.Slice(_position), name, out int written)) + { + _position += written; + return true; + } + + return false; + } + + internal bool TryWriteByte(byte value) + { + if (_buffer.Length - _position < 1) + { + return false; + } + + _buffer.Span[_position] = value; + _position += 1; + return true; + } + + internal bool TryWriteUInt16(ushort value) + { + if (_buffer.Length - _position < 2) + { + return false; + } + + BinaryPrimitives.WriteUInt16BigEndian(_buffer.Span.Slice(_position), value); + _position += 2; + return true; + } + + internal bool TryWriteUInt32(uint value) + { + if (_buffer.Length - _position < 4) + { + return false; + } + + BinaryPrimitives.WriteUInt32BigEndian(_buffer.Span.Slice(_position), value); + _position += 4; + return true; + } + + internal bool TryWriteRawData(ReadOnlySpan value) + { + if (_buffer.Length - _position < value.Length) + { + return false; + } + + value.CopyTo(_buffer.Span.Slice(_position)); + _position += value.Length; + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsMessageHeader.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsMessageHeader.cs new file mode 100644 index 00000000000..b22273a04f2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsMessageHeader.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +// RFC 1035 4.1.1. Header section format +internal struct DnsMessageHeader +{ + internal const int HeaderLength = 12; + public ushort TransactionId { get; set; } + + internal QueryFlags QueryFlags { get; set; } + + public ushort QueryCount { get; set; } + + public ushort AnswerCount { get; set; } + + public ushort AuthorityCount { get; set; } + + public ushort AdditionalRecordCount { get; set; } + + public QueryResponseCode ResponseCode + { + get => (QueryResponseCode)(QueryFlags & QueryFlags.ResponseCodeMask); + } + + public bool IsResultTruncated + { + get => (QueryFlags & QueryFlags.ResultTruncated) != 0; + } + + public bool IsResponse + { + get => (QueryFlags & QueryFlags.HasResponse) != 0; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsPrimitives.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsPrimitives.cs new file mode 100644 index 00000000000..e549abe2576 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsPrimitives.cs @@ -0,0 +1,318 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Buffers.Binary; +using System.Globalization; +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal static class DnsPrimitives +{ + // Maximum length of a domain name in ASCII (excluding trailing dot) + internal const int MaxDomainNameLength = 253; + + internal static bool TryReadMessageHeader(ReadOnlySpan buffer, out DnsMessageHeader header, out int bytesRead) + { + // RFC 1035 4.1.1. Header section format + if (buffer.Length < DnsMessageHeader.HeaderLength) + { + header = default; + bytesRead = 0; + return false; + } + + header = new DnsMessageHeader + { + TransactionId = BinaryPrimitives.ReadUInt16BigEndian(buffer), + QueryFlags = (QueryFlags)BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2)), + QueryCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(4)), + AnswerCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(6)), + AuthorityCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(8)), + AdditionalRecordCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(10)) + }; + + bytesRead = DnsMessageHeader.HeaderLength; + return true; + } + + internal static bool TryWriteMessageHeader(Span buffer, DnsMessageHeader header, out int bytesWritten) + { + // RFC 1035 4.1.1. Header section format + if (buffer.Length < DnsMessageHeader.HeaderLength) + { + bytesWritten = 0; + return false; + } + + BinaryPrimitives.WriteUInt16BigEndian(buffer, header.TransactionId); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)header.QueryFlags); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(4), header.QueryCount); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(6), header.AnswerCount); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(8), header.AuthorityCount); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(10), header.AdditionalRecordCount); + + bytesWritten = DnsMessageHeader.HeaderLength; + return true; + } + + // https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4 + // labels 63 octets or less + // name 255 octets or less + + private static readonly SearchValues s_domainNameValidChars = SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."); + private static readonly IdnMapping s_idnMapping = new IdnMapping(); + internal static bool TryWriteQName(Span destination, string name, out int written) + { + written = 0; + + // + // RFC 1035 4.1.2. + // + // a domain name represented as a sequence of labels, where + // each label consists of a length octet followed by that + // number of octets. The domain name terminates with the + // zero length octet for the null label of the root. Note + // that this field may be an odd number of octets; no + // padding is used. + // + if (!Ascii.IsValid(name)) + { + // IDN name, apply punycode + try + { + // IdnMapping performs some validation internally (such as label + // and domain name lengths), but is more relaxed than RFC + // 1035 (e.g. allows ~ chars), so even if this conversion does + // not throw, we still need to perform additional validation + name = s_idnMapping.GetAscii(name); + } + catch + { + return false; + } + } + + if (name.Length > MaxDomainNameLength || + name.AsSpan().ContainsAnyExcept(s_domainNameValidChars) || + destination.IsEmpty || + !Encoding.ASCII.TryGetBytes(name, destination.Slice(1), out int length) || + destination.Length < length + 2) + { + // buffer too small + return false; + } + + Span nameBuffer = destination.Slice(0, 1 + length); + Span label; + while (true) + { + // figure out the next label and prepend the length + int index = nameBuffer.Slice(1).IndexOf((byte)'.'); + label = index == -1 ? nameBuffer.Slice(1) : nameBuffer.Slice(1, index); + + if (label.Length == 0) + { + // empty label (explicit root) is only allowed at the end + if (index != -1) + { + written = 0; + return false; + } + } + // Label restrictions: + // - maximum 63 octets long + // - must start with a letter or digit (digit is allowed by RFC 1123) + // - may start with an underscore (underscore may be present only + // at the start of the label to support SRV records) + // - must end with a letter or digit + else if (label.Length > 63 || + !char.IsAsciiLetterOrDigit((char)label[0]) && label[0] != '_' || + label.Slice(1).Contains((byte)'_') || + !char.IsAsciiLetterOrDigit((char)label[^1])) + { + written = 0; + return false; + } + + nameBuffer[0] = (byte)label.Length; + written += label.Length + 1; + + if (index == -1) + { + // this was the last label + break; + } + + nameBuffer = nameBuffer.Slice(index + 1); + } + + // Add root label if wasn't explicitly specified + if (label.Length != 0) + { + destination[written] = 0; + written++; + } + + return true; + } + + private static bool TryReadQNameCore(List> labels, int totalLength, ReadOnlyMemory messageBuffer, int offset, out int bytesRead, bool canStartWithPointer = true) + { + // + // domain name can be either + // - a sequence of labels, where each label consists of a length octet + // followed by that number of octets, terminated by a zero length octet + // (root label) + // - a pointer, where the first two bits are set to 1, and the remaining + // 14 bits are an offset (from the start of the message) to the true + // label + // + // It is not specified by the RFC if pointers must be backwards only, + // the code below prohibits forward (and self) pointers to avoid + // infinite loops. It also allows pointers only to point to a + // label, not to another pointer. + // + + bytesRead = 0; + bool allowPointer = canStartWithPointer; + + if (offset < 0 || offset >= messageBuffer.Length) + { + return false; + } + + int currentOffset = offset; + + while (true) + { + byte length = messageBuffer.Span[currentOffset]; + + if ((length & 0xC0) == 0x00) + { + // length followed by the label + if (length == 0) + { + // end of name + bytesRead = currentOffset - offset + 1; + return true; + } + + if (currentOffset + 1 + length >= messageBuffer.Length) + { + // too many labels or truncated data + break; + } + + // read next label/segment + labels.Add(messageBuffer.Slice(currentOffset + 1, length)); + totalLength += 1 + length; + + // subtract one for the length prefix of the first label + if (totalLength - 1 > MaxDomainNameLength) + { + // domain name is too long + return false; + } + + currentOffset += 1 + length; + bytesRead += 1 + length; + + // we read a label, they can be followed by pointer. + allowPointer = true; + } + else if ((length & 0xC0) == 0xC0) + { + // pointer, together with next byte gives the offset of the true label + if (!allowPointer || currentOffset + 1 >= messageBuffer.Length) + { + // pointer to pointer or truncated data + break; + } + + bytesRead += 2; + int pointer = ((length & 0x3F) << 8) | messageBuffer.Span[currentOffset + 1]; + + // we prohibit self-references and forward pointers to avoid + // infinite loops, we do this by truncating the + // messageBuffer at the offset where we started reading the + // name. We also ignore the bytesRead from the recursive + // call, as we are only interested on how many bytes we read + // from the initial start of the name. + return TryReadQNameCore(labels, totalLength, messageBuffer.Slice(0, offset), pointer, out int _, false); + } + else + { + // top two bits are reserved, this means invalid data + break; + } + } + + return false; + + } + + internal static bool TryReadQName(ReadOnlyMemory messageBuffer, int offset, out EncodedDomainName name, out int bytesRead) + { + List> labels = new List>(); + + if (TryReadQNameCore(labels, 0, messageBuffer, offset, out bytesRead)) + { + name = new EncodedDomainName(labels); + return true; + } + else + { + bytesRead = 0; + name = default; + return false; + } + } + + internal static bool TryReadService(ReadOnlyMemory buffer, out ushort priority, out ushort weight, out ushort port, out EncodedDomainName target, out int bytesRead) + { + // https://www.rfc-editor.org/rfc/rfc2782 + if (!BinaryPrimitives.TryReadUInt16BigEndian(buffer.Span, out priority) || + !BinaryPrimitives.TryReadUInt16BigEndian(buffer.Span.Slice(2), out weight) || + !BinaryPrimitives.TryReadUInt16BigEndian(buffer.Span.Slice(4), out port) || + !TryReadQName(buffer.Slice(6), 0, out target, out bytesRead)) + { + target = default; + priority = 0; + weight = 0; + port = 0; + bytesRead = 0; + return false; + } + + bytesRead += 6; + return true; + } + + internal static bool TryReadSoa(ReadOnlyMemory buffer, out EncodedDomainName primaryNameServer, out EncodedDomainName responsibleMailAddress, out uint serial, out uint refresh, out uint retry, out uint expire, out uint minimum, out int bytesRead) + { + // https://www.rfc-editor.org/rfc/rfc1035#section-3.3.13 + if (!TryReadQName(buffer, 0, out primaryNameServer, out int w1) || + !TryReadQName(buffer.Slice(w1), 0, out responsibleMailAddress, out int w2) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2), out serial) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 4), out refresh) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 8), out retry) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 12), out expire) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 16), out minimum)) + { + primaryNameServer = default; + responsibleMailAddress = default; + serial = 0; + refresh = 0; + retry = 0; + expire = 0; + minimum = 0; + bytesRead = 0; + return false; + } + + bytesRead = w1 + w2 + 20; + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Log.cs new file mode 100644 index 00000000000..adab9161737 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Log.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal partial class DnsResolver : IDnsResolver, IDisposable +{ + internal static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Resolving {QueryType} {QueryName} on {Server} attempt {Attempt}", EventName = "Query")] + public static partial void Query(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(2, LogLevel.Debug, "Result truncated for {QueryType} {QueryName} from {Server} attempt {Attempt}. Restarting over TCP", EventName = "ResultTruncated")] + public static partial void ResultTruncated(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(3, LogLevel.Error, "Server {Server} replied with {ResponseCode} when querying {QueryType} {QueryName}", EventName = "ErrorResponseCode")] + public static partial void ErrorResponseCode(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, QueryResponseCode responseCode); + + [LoggerMessage(4, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} timed out.", EventName = "Timeout")] + public static partial void Timeout(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(5, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt}: no data matching given query type.", EventName = "NoData")] + public static partial void NoData(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(6, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt}: server indicates given name does not exist.", EventName = "NameError")] + public static partial void NameError(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(7, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} failed to return a valid DNS response.", EventName = "MalformedResponse")] + public static partial void MalformedResponse(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(8, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} failed due to a network error.", EventName = "NetworkError")] + public static partial void NetworkError(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt, Exception exception); + + [LoggerMessage(9, LogLevel.Error, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} failed.", EventName = "QueryError")] + public static partial void QueryError(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt, Exception exception); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Telemetry.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Telemetry.cs new file mode 100644 index 00000000000..4be956cede9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Telemetry.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal partial class DnsResolver +{ + internal static class Telemetry + { + private static readonly Meter s_meter = new Meter("Microsoft.Extensions.ServiceDiscovery.Dns.Resolver"); + private static readonly Histogram s_queryDuration = s_meter.CreateHistogram("query.duration", "ms", "DNS query duration"); + + private static bool IsEnabled() => s_queryDuration.Enabled; + + public static NameResolutionActivity StartNameResolution(string hostName, QueryType queryType, long startingTimestamp) + { + if (IsEnabled()) + { + return new NameResolutionActivity(hostName, queryType, startingTimestamp); + } + + return default; + } + + public static void StopNameResolution(string hostName, QueryType queryType, in NameResolutionActivity activity, object? answers, SendQueryError error, long endingTimestamp) + { + activity.Stop(answers, error, endingTimestamp, out TimeSpan duration); + + if (!IsEnabled()) + { + return; + } + + var hostNameTag = KeyValuePair.Create("dns.question.name", (object?)hostName); + var queryTypeTag = KeyValuePair.Create("dns.question.type", (object?)queryType); + + if (answers is not null) + { + s_queryDuration.Record(duration.TotalSeconds, hostNameTag, queryTypeTag); + } + else + { + var errorTypeTag = KeyValuePair.Create("error.type", (object?)error.ToString()); + s_queryDuration.Record(duration.TotalSeconds, hostNameTag, queryTypeTag, errorTypeTag); + } + } + } + + internal readonly struct NameResolutionActivity + { + private const string ActivitySourceName = "Microsoft.Extensions.ServiceDiscovery.Dns.Resolver"; + private const string ActivityName = ActivitySourceName + ".Resolve"; + private static readonly ActivitySource s_activitySource = new ActivitySource(ActivitySourceName); + + private readonly long _startingTimestamp; + private readonly Activity? _activity; // null if activity is not started + + public NameResolutionActivity(string hostName, QueryType queryType, long startingTimestamp) + { + _startingTimestamp = startingTimestamp; + _activity = s_activitySource.StartActivity(ActivityName, ActivityKind.Client); + if (_activity is not null) + { + _activity.DisplayName = $"Resolving {hostName}"; + if (_activity.IsAllDataRequested) + { + _activity.SetTag("dns.question.name", hostName); + _activity.SetTag("dns.question.type", queryType.ToString()); + } + } + } + + public void Stop(object? answers, SendQueryError error, long endingTimestamp, out TimeSpan duration) + { + duration = Stopwatch.GetElapsedTime(_startingTimestamp, endingTimestamp); + + if (_activity is null) + { + return; + } + + if (_activity.IsAllDataRequested) + { + if (answers is not null) + { + static string[] ToStringHelper(T[] array) => array.Select(a => a!.ToString()!).ToArray(); + + string[]? answersArray = answers switch + { + ServiceResult[] serviceResults => ToStringHelper(serviceResults), + AddressResult[] addressResults => ToStringHelper(addressResults), + _ => null + }; + + Debug.Assert(answersArray is not null); + _activity.SetTag("dns.answers", answersArray); + } + else + { + _activity.SetTag("error.type", error.ToString()); + } + } + + if (answers is null) + { + _activity.SetStatus(ActivityStatusCode.Error); + } + + _activity.Stop(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs new file mode 100644 index 00000000000..511e8fdb1c9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs @@ -0,0 +1,929 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal sealed partial class DnsResolver : IDnsResolver, IDisposable +{ + private const int IPv4Length = 4; + private const int IPv6Length = 16; + + private bool _disposed; + private readonly DnsResolverOptions _options; + private readonly CancellationTokenSource _pendingRequestsCts = new(); + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public DnsResolver(TimeProvider timeProvider, ILogger logger, IOptions options) + { + _timeProvider = timeProvider; + _logger = logger; + _options = options.Value; + + if (_options.Servers.Count == 0) + { + foreach (var server in OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() + ? ResolvConf.GetServers() + : NetworkInfo.GetServers()) + { + _options.Servers.Add(server); + } + + if (_options.Servers.Count == 0) + { + throw new ArgumentException("At least one DNS server is required.", nameof(options)); + } + } + } + + // This constructor is for unit testing only. Does not auto-add system DNS servers. + internal DnsResolver(DnsResolverOptions options) + { + _timeProvider = TimeProvider.System; + _logger = NullLogger.Instance; + _options = options; + } + + public ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + // dnsSafeName is Disposed by SendQueryWithTelemetry + EncodedDomainName dnsSafeName = GetNormalizedHostName(name); + return SendQueryWithTelemetry(name, dnsSafeName, QueryType.SRV, ProcessResponse, cancellationToken); + + static (SendQueryError, ServiceResult[]) ProcessResponse(EncodedDomainName dnsSafeName, QueryType queryType, DnsResponse response) + { + var results = new List(response.Answers.Count); + + foreach (var answer in response.Answers) + { + if (answer.Type == QueryType.SRV) + { + if (!DnsPrimitives.TryReadService(answer.Data, out ushort priority, out ushort weight, out ushort port, out EncodedDomainName target, out int bytesRead) || bytesRead != answer.Data.Length) + { + return (SendQueryError.MalformedResponse, []); + } + + List addresses = new List(); + foreach (var additional in response.Additionals) + { + // From RFC 2782: + // + // Target + // The domain name of the target host. There MUST be one or more + // address records for this name, the name MUST NOT be an alias (in + // the sense of RFC 1034 or RFC 2181). Implementors are urged, but + // not required, to return the address record(s) in the Additional + // Data section. Unless and until permitted by future standards + // action, name compression is not to be used for this field. + // + // A Target of "." means that the service is decidedly not + // available at this domain. + if (additional.Name.Equals(target) && (additional.Type == QueryType.A || additional.Type == QueryType.AAAA)) + { + addresses.Add(new AddressResult(response.CreatedAt.AddSeconds(additional.Ttl), new IPAddress(additional.Data.Span))); + } + } + + results.Add(new ServiceResult(response.CreatedAt.AddSeconds(answer.Ttl), priority, weight, port, target.ToString(), addresses.ToArray())); + } + } + + return (SendQueryError.NoError, results.ToArray()); + } + } + + public async ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) + { + if (string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase)) + { + // name localhost exists outside of DNS and can't be resolved by a DNS server + int len = (Socket.OSSupportsIPv4 ? 1 : 0) + (Socket.OSSupportsIPv6 ? 1 : 0); + AddressResult[] res = new AddressResult[len]; + + int index = 0; + if (Socket.OSSupportsIPv6) // prefer IPv6 + { + res[index] = new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback); + index++; + } + if (Socket.OSSupportsIPv4) + { + res[index] = new AddressResult(DateTime.MaxValue, IPAddress.Loopback); + } + + return res; + } + + var ipv4AddressesTask = ResolveIPAddressesAsync(name, AddressFamily.InterNetwork, cancellationToken); + var ipv6AddressesTask = ResolveIPAddressesAsync(name, AddressFamily.InterNetworkV6, cancellationToken); + + AddressResult[] ipv4Addresses = await ipv4AddressesTask.ConfigureAwait(false); + AddressResult[] ipv6Addresses = await ipv6AddressesTask.ConfigureAwait(false); + + AddressResult[] results = new AddressResult[ipv4Addresses.Length + ipv6Addresses.Length]; + ipv6Addresses.CopyTo(results, 0); + ipv4Addresses.CopyTo(results, ipv6Addresses.Length); + return results; + } + + internal ValueTask ResolveIPAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + if (addressFamily != AddressFamily.InterNetwork && addressFamily != AddressFamily.InterNetworkV6) + { + throw new ArgumentOutOfRangeException(nameof(addressFamily), addressFamily, "Invalid address family"); + } + + if (string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase)) + { + // name localhost exists outside of DNS and can't be resolved by a DNS server + if (addressFamily == AddressFamily.InterNetwork && Socket.OSSupportsIPv4) + { + return ValueTask.FromResult([new AddressResult(DateTime.MaxValue, IPAddress.Loopback)]); + } + else if (addressFamily == AddressFamily.InterNetworkV6 && Socket.OSSupportsIPv6) + { + return ValueTask.FromResult([new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback)]); + } + + return ValueTask.FromResult([]); + } + + // dnsSafeName is Disposed by SendQueryWithTelemetry + EncodedDomainName dnsSafeName = GetNormalizedHostName(name); + var queryType = addressFamily == AddressFamily.InterNetwork ? QueryType.A : QueryType.AAAA; + return SendQueryWithTelemetry(name, dnsSafeName, queryType, ProcessResponse, cancellationToken); + + static (SendQueryError error, AddressResult[] result) ProcessResponse(EncodedDomainName dnsSafeName, QueryType queryType, DnsResponse response) + { + List results = new List(response.Answers.Count); + + // Servers send back CNAME records together with associated A/AAAA records. Servers + // send only those CNAME records relevant to the query, and if there is a CNAME record, + // there should not be other records associated with the name. Therefore, we simply follow + // the list of CNAME aliases until we get to the primary name and return the A/AAAA records + // associated. + // + // more info: https://datatracker.ietf.org/doc/html/rfc1034#section-3.6.2 + // + // Most of the servers send the CNAME records in order so that we can sequentially scan the + // answers, but nothing prevents the records from being in arbitrary order. Attempt the linear + // scan first and fallback to a slower but more robust method if necessary. + + bool success = true; + EncodedDomainName currentAlias = dnsSafeName; + + foreach (var answer in response.Answers) + { + switch (answer.Type) + { + case QueryType.CNAME: + if (!TryReadTarget(answer, response.RawMessageBytes, out EncodedDomainName target)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (answer.Name.Equals(currentAlias)) + { + currentAlias = target; + continue; + } + + break; + + case var type when type == queryType: + if (!TryReadAddress(answer, queryType, out IPAddress? address)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (answer.Name.Equals(currentAlias)) + { + results.Add(new AddressResult(response.CreatedAt.AddSeconds(answer.Ttl), address)); + continue; + } + + break; + } + + // unexpected name or record type, fall back to more robust path + results.Clear(); + success = false; + break; + } + + if (success) + { + return (SendQueryError.NoError, results.ToArray()); + } + + // more expensive path for uncommon (but valid) cases where CNAME records are out of order. Use of Dictionary + // allows us to stay within O(n) complexity for the number of answers, but we will use more memory. + Dictionary aliasMap = new(); + Dictionary> aRecordMap = new(); + foreach (var answer in response.Answers) + { + if (answer.Type == QueryType.CNAME) + { + // map the alias to the target name + if (!TryReadTarget(answer, response.RawMessageBytes, out EncodedDomainName target)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (!aliasMap.TryAdd(answer.Name, target)) + { + // Duplicate CNAME record + return (SendQueryError.MalformedResponse, []); + } + } + + if (answer.Type == queryType) + { + if (!TryReadAddress(answer, queryType, out IPAddress? address)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (!aRecordMap.TryGetValue(answer.Name, out List? addressList)) + { + addressList = new List(); + aRecordMap.Add(answer.Name, addressList); + } + + addressList.Add(new AddressResult(response.CreatedAt.AddSeconds(answer.Ttl), address)); + } + } + + // follow the CNAME chain, limit the maximum number of iterations to avoid infinite loops. + int i = 0; + currentAlias = dnsSafeName; + while (aliasMap.TryGetValue(currentAlias, out EncodedDomainName nextAlias)) + { + if (i >= aliasMap.Count) + { + // circular CNAME chain + return (SendQueryError.MalformedResponse, []); + } + + i++; + + if (aRecordMap.ContainsKey(currentAlias)) + { + // both CNAME record and A/AAAA records exist for the current alias + return (SendQueryError.MalformedResponse, []); + } + + currentAlias = nextAlias; + } + + // Now we have the final target name, check if we have any A/AAAA records for it. + aRecordMap.TryGetValue(currentAlias, out List? finalAddressList); + return (SendQueryError.NoError, finalAddressList?.ToArray() ?? []); + + static bool TryReadTarget(in DnsResourceRecord record, ArraySegment messageBytes, out EncodedDomainName target) + { + Debug.Assert(record.Type == QueryType.CNAME, "Only CNAME records should be processed here."); + + target = default; + + // some servers use domain name compression even inside CNAME records. In order to decode those + // correctly, we need to pass the entire message to TryReadQName. The Data span inside the record + // should be backed by the array containing the entire DNS message. We just need to account for the + // 2 byte offset in case of TCP fallback. + var gotArray = MemoryMarshal.TryGetArray(record.Data, out ArraySegment segment); + Debug.Assert(gotArray, "Failed to get array segment"); + Debug.Assert(segment.Array == messageBytes.Array, "record data backed by different array than the original message"); + + int messageOffset = messageBytes.Offset; + + bool result = DnsPrimitives.TryReadQName(segment.Array.AsMemory(messageOffset, segment.Offset + segment.Count - messageOffset), segment.Offset - messageOffset, out EncodedDomainName targetName, out int bytesRead) && bytesRead == record.Data.Length; + if (result) + { + target = targetName; + } + + return result; + } + + static bool TryReadAddress(in DnsResourceRecord record, QueryType type, [NotNullWhen(true)] out IPAddress? target) + { + Debug.Assert(record.Type is QueryType.A or QueryType.AAAA, "Only CNAME records should be processed here."); + + target = null; + if (record.Type == QueryType.A && record.Data.Length != IPv4Length || + record.Type == QueryType.AAAA && record.Data.Length != IPv6Length) + { + return false; + } + + target = new IPAddress(record.Data.Span); + return true; + } + } + } + + private async ValueTask SendQueryWithTelemetry(string name, EncodedDomainName dnsSafeName, QueryType queryType, Func processResponseFunc, CancellationToken cancellationToken) + { + NameResolutionActivity activity = Telemetry.StartNameResolution(name, queryType, _timeProvider.GetTimestamp()); + (SendQueryError error, TResult[] result) = await SendQueryWithRetriesAsync(name, dnsSafeName, queryType, processResponseFunc, cancellationToken).ConfigureAwait(false); + Telemetry.StopNameResolution(name, queryType, activity, null, error, _timeProvider.GetTimestamp()); + dnsSafeName.Dispose(); + + return result; + } + + internal struct SendQueryResult + { + public DnsResponse Response; + public SendQueryError Error; + } + + async ValueTask<(SendQueryError error, TResult[] result)> SendQueryWithRetriesAsync(string name, EncodedDomainName dnsSafeName, QueryType queryType, Func processResponseFunc, CancellationToken cancellationToken) + { + SendQueryError lastError = SendQueryError.InternalError; // will be overwritten by the first attempt + for (int index = 0; index < _options.Servers.Count; index++) + { + IPEndPoint serverEndPoint = _options.Servers[index]; + + for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++) + { + DnsResponse response = default; + try + { + TResult[] results = Array.Empty(); + + try + { + SendQueryResult queryResult = await SendQueryToServerWithTimeoutAsync(serverEndPoint, name, dnsSafeName, queryType, attempt, cancellationToken).ConfigureAwait(false); + lastError = queryResult.Error; + response = queryResult.Response; + + if (lastError == SendQueryError.NoError) + { + // Given that result.Error is NoError, there should be at least one answer. + Debug.Assert(response.Answers.Count > 0); + (lastError, results) = processResponseFunc(dnsSafeName, queryType, queryResult.Response); + } + } + catch (SocketException ex) + { + Log.NetworkError(_logger, queryType, name, serverEndPoint, attempt, ex); + lastError = SendQueryError.NetworkError; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + // internal error, propagate + Log.QueryError(_logger, queryType, name, serverEndPoint, attempt, ex); + throw; + } + + switch (lastError) + { + // + // Definitive answers, no point retrying + // + case SendQueryError.NoError: + return (lastError, results); + + case SendQueryError.NameError: + // authoritative answer that the name does not exist, no point in retrying + Log.NameError(_logger, queryType, name, serverEndPoint, attempt); + return (lastError, results); + + case SendQueryError.NoData: + // no data available for the name from authoritative server + Log.NoData(_logger, queryType, name, serverEndPoint, attempt); + return (lastError, results); + + // + // Transient errors, retry on the same server + // + case SendQueryError.Timeout: + Log.Timeout(_logger, queryType, name, serverEndPoint, attempt); + continue; + + case SendQueryError.NetworkError: + // TODO: retry with exponential backoff? + continue; + + case SendQueryError.ServerError when response.Header.ResponseCode == QueryResponseCode.ServerFailure: + // ServerFailure may indicate transient failure with upstream DNS servers, retry on the same server + Log.ErrorResponseCode(_logger, queryType, name, serverEndPoint, response.Header.ResponseCode); + continue; + + // + // Persistent errors, skip to the next server + // + case SendQueryError.ServerError: + // this should cover all response codes except NoError, NameError which are definite and handled above, and + // ServerFailure which is a transient error and handled above. + Log.ErrorResponseCode(_logger, queryType, name, serverEndPoint, response.Header.ResponseCode); + break; + + case SendQueryError.MalformedResponse: + Log.MalformedResponse(_logger, queryType, name, serverEndPoint, attempt); + break; + + case SendQueryError.InternalError: + // exception logged above. + break; + } + + // actual break that causes skipping to the next server + break; + } + finally + { + response.Dispose(); + } + } + } + + // if we get here, we exhausted all servers and all attempts + return (lastError, []); + } + + internal async ValueTask SendQueryToServerWithTimeoutAsync(IPEndPoint serverEndPoint, string name, EncodedDomainName dnsSafeName, QueryType queryType, int attempt, CancellationToken cancellationToken) + { + (CancellationTokenSource cts, bool disposeTokenSource, CancellationTokenSource pendingRequestsCts) = PrepareCancellationTokenSource(cancellationToken); + + try + { + return await SendQueryToServerAsync(serverEndPoint, name, dnsSafeName, queryType, attempt, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + !cancellationToken.IsCancellationRequested && // not cancelled by the caller + !pendingRequestsCts.IsCancellationRequested) // not cancelled by the global token (dispose) + // the only remaining token that could cancel this is the linked cts from the timeout. + { + Debug.Assert(cts.Token.IsCancellationRequested); + return new SendQueryResult { Error = SendQueryError.Timeout }; + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested && ex.CancellationToken != cancellationToken) + { + // cancellation was initiated by the caller, but exception was triggered by a linked token, + // rethrow the exception with the caller's token. + cancellationToken.ThrowIfCancellationRequested(); + throw new UnreachableException(); + } + finally + { + if (disposeTokenSource) + { + cts.Dispose(); + } + } + } + + private async ValueTask SendQueryToServerAsync(IPEndPoint serverEndPoint, string name, EncodedDomainName dnsSafeName, QueryType queryType, int attempt, CancellationToken cancellationToken) + { + Log.Query(_logger, queryType, name, serverEndPoint, attempt); + + SendQueryError sendError = SendQueryError.NoError; + DateTime queryStartedTime = _timeProvider.GetUtcNow().DateTime; + DnsDataReader responseReader = default; + DnsMessageHeader header; + + try + { + // use transport override if provided + if (_options._transportOverride != null) + { + (responseReader, header, sendError) = SendDnsQueryCustomTransport(_options._transportOverride, dnsSafeName, queryType); + } + else + { + (responseReader, header) = await SendDnsQueryCoreUdpAsync(serverEndPoint, dnsSafeName, queryType, cancellationToken).ConfigureAwait(false); + + if (header.IsResultTruncated) + { + Log.ResultTruncated(_logger, queryType, name, serverEndPoint, 0); + responseReader.Dispose(); + // TCP fallback + (responseReader, header, sendError) = await SendDnsQueryCoreTcpAsync(serverEndPoint, dnsSafeName, queryType, cancellationToken).ConfigureAwait(false); + } + } + + if (sendError != SendQueryError.NoError) + { + // we failed to get back any response + return new SendQueryResult { Error = sendError }; + } + + if ((uint)header.ResponseCode > (uint)QueryResponseCode.Refused) + { + // Response code is outside of valid range + return new SendQueryResult + { + Response = new DnsResponse(ArraySegment.Empty, header, queryStartedTime, queryStartedTime, null!, null!, null!), + Error = SendQueryError.MalformedResponse + }; + } + + // Recheck that the server echoes back the DNS question + if (header.QueryCount != 1 || + !responseReader.TryReadQuestion(out var qName, out var qType, out var qClass) || + !dnsSafeName.Equals(qName) || qType != queryType || qClass != QueryClass.Internet) + { + // DNS Question mismatch + return new SendQueryResult + { + Response = new DnsResponse(ArraySegment.Empty, header, queryStartedTime, queryStartedTime, null!, null!, null!), + Error = SendQueryError.MalformedResponse + }; + } + + // Structurally separate the resource records, this will validate only the + // "outside structure" of the resource record, it will not validate the content. + int ttl = int.MaxValue; + if (!TryReadRecords(header.AnswerCount, ref ttl, ref responseReader, out List? answers) || + !TryReadRecords(header.AuthorityCount, ref ttl, ref responseReader, out List? authorities) || + !TryReadRecords(header.AdditionalRecordCount, ref ttl, ref responseReader, out List? additionals)) + { + return new SendQueryResult + { + Response = new DnsResponse(ArraySegment.Empty, header, queryStartedTime, queryStartedTime, null!, null!, null!), + Error = SendQueryError.MalformedResponse + }; + } + + DateTime expirationTime = + (answers.Count + authorities.Count + additionals.Count) > 0 ? queryStartedTime.AddSeconds(ttl) : queryStartedTime; + + SendQueryError validationError = ValidateResponse(header.ResponseCode, queryStartedTime, answers, authorities, ref expirationTime); + + // we transfer ownership of RawData to the response + DnsResponse response = new DnsResponse(responseReader.MessageBuffer, header, queryStartedTime, expirationTime, answers, authorities, additionals); + responseReader = default; // avoid disposing (and returning RawData to the pool) + + return new SendQueryResult { Response = response, Error = validationError }; + } + finally + { + responseReader.Dispose(); + } + + static bool TryReadRecords(int count, ref int ttl, ref DnsDataReader reader, out List records) + { + // Since `count` is attacker controlled, limit the initial capacity + // to 32 items to avoid excessive memory allocation. More than 32 + // records are unusual so we don't need to optimize for them. + records = new(Math.Min(count, 32)); + + for (int i = 0; i < count; i++) + { + if (!reader.TryReadResourceRecord(out var record)) + { + return false; + } + + ttl = Math.Min(ttl, record.Ttl); + records.Add(new DnsResourceRecord(record.Name, record.Type, record.Class, record.Ttl, record.Data)); + } + + return true; + } + } + + internal static bool GetNegativeCacheExpiration(DateTime createdAt, List authorities, out DateTime expiration) + { + // + // RFC 2308 Section 5 - Caching Negative Answers + // + // Like normal answers negative answers have a time to live (TTL). As + // there is no record in the answer section to which this TTL can be + // applied, the TTL must be carried by another method. This is done by + // including the SOA record from the zone in the authority section of + // the reply. When the authoritative server creates this record its TTL + // is taken from the minimum of the SOA.MINIMUM field and SOA's TTL. + // This TTL decrements in a similar manner to a normal cached answer and + // upon reaching zero (0) indicates the cached negative answer MUST NOT + // be used again. + // + + DnsResourceRecord? soa = authorities.FirstOrDefault(r => r.Type == QueryType.SOA); + if (soa != null && DnsPrimitives.TryReadSoa(soa.Value.Data, out _, out _, out _, out _, out _, out _, out uint minimum, out _)) + { + expiration = createdAt.AddSeconds(Math.Min(minimum, soa.Value.Ttl)); + return true; + } + + expiration = default; + return false; + } + + internal static SendQueryError ValidateResponse(QueryResponseCode responseCode, DateTime createdAt, List answers, List authorities, ref DateTime expiration) + { + if (responseCode == QueryResponseCode.NoError) + { + if (answers.Count > 0) + { + return SendQueryError.NoError; + } + // + // RFC 2308 Section 2.2 - No Data + // + // NODATA is indicated by an answer with the RCODE set to NOERROR and no + // relevant answers in the answer section. The authority section will + // contain an SOA record, or there will be no NS records there. + // + // + // RFC 2308 Section 5 - Caching Negative Answers + // + // A negative answer that resulted from a no data error (NODATA) should + // be cached such that it can be retrieved and returned in response to + // another query for the same that resulted in + // the cached negative response. + // + if (!authorities.Any(r => r.Type == QueryType.NS) && GetNegativeCacheExpiration(createdAt, authorities, out DateTime newExpiration)) + { + expiration = newExpiration; + // _cache.TryAdd(name, queryType, expiration, Array.Empty()); + } + return SendQueryError.NoData; + } + + if (responseCode == QueryResponseCode.NameError) + { + // + // RFC 2308 Section 5 - Caching Negative Answers + // + // A negative answer that resulted from a name error (NXDOMAIN) should + // be cached such that it can be retrieved and returned in response to + // another query for the same that resulted in the + // cached negative response. + // + if (GetNegativeCacheExpiration(createdAt, authorities, out DateTime newExpiration)) + { + expiration = newExpiration; + // _cache.TryAddNonexistent(name, expiration); + } + + return SendQueryError.NameError; + } + + return SendQueryError.ServerError; + } + + internal static (DnsDataReader reader, DnsMessageHeader header, SendQueryError sendError) SendDnsQueryCustomTransport(Func, int, int> callback, EncodedDomainName dnsSafeName, QueryType queryType) + { + byte[] buffer = ArrayPool.Shared.Rent(2048); + try + { + (ushort transactionId, int length) = EncodeQuestion(buffer, dnsSafeName, queryType); + length = callback(buffer, length); + + DnsDataReader responseReader = new DnsDataReader(new ArraySegment(buffer, 0, length), true); + + if (!responseReader.TryReadHeader(out DnsMessageHeader header) || + header.TransactionId != transactionId || + !header.IsResponse) + { + return (default, default, SendQueryError.MalformedResponse); + } + + // transfer ownership of buffer to the caller + buffer = null!; + return (responseReader, header, SendQueryError.NoError); + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + + internal static async ValueTask<(DnsDataReader reader, DnsMessageHeader header)> SendDnsQueryCoreUdpAsync(IPEndPoint serverEndPoint, EncodedDomainName dnsSafeName, QueryType queryType, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(512); + try + { + Memory memory = buffer; + (ushort transactionId, int length) = EncodeQuestion(memory, dnsSafeName, queryType); + + using var socket = new Socket(serverEndPoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + await socket.SendToAsync(memory.Slice(0, length), SocketFlags.None, serverEndPoint, cancellationToken).ConfigureAwait(false); + + DnsDataReader responseReader; + DnsMessageHeader header; + + while (true) + { + // Because this is UDP, the response must be in a single packet, + // if the response does not fit into a single UDP packet, the server will + // set the Truncated flag in the header, and we will need to retry with TCP. + int packetLength = await socket.ReceiveAsync(memory, SocketFlags.None, cancellationToken).ConfigureAwait(false); + + if (packetLength < DnsMessageHeader.HeaderLength) + { + continue; + } + + responseReader = new DnsDataReader(new ArraySegment(buffer, 0, packetLength), true); + if (!responseReader.TryReadHeader(out header) || + header.TransactionId != transactionId || + !header.IsResponse) + { + // header mismatch, this is not a response to our query + continue; + } + + // ownership of the buffer is transferred to the reader, caller will dispose. + buffer = null!; + return (responseReader, header); + } + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + + internal static async ValueTask<(DnsDataReader reader, DnsMessageHeader header, SendQueryError error)> SendDnsQueryCoreTcpAsync(IPEndPoint serverEndPoint, EncodedDomainName dnsSafeName, QueryType queryType, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(8 * 1024); + try + { + // When sending over TCP, the message is prefixed by 2B length + (ushort transactionId, int length) = EncodeQuestion(buffer.AsMemory(2), dnsSafeName, queryType); + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)length); + + using var socket = new Socket(serverEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(serverEndPoint, cancellationToken).ConfigureAwait(false); + await socket.SendAsync(buffer.AsMemory(0, length + 2), SocketFlags.None, cancellationToken).ConfigureAwait(false); + + int responseLength = -1; + int bytesRead = 0; + while (responseLength < 0 || bytesRead < responseLength + 2) + { + int read = await socket.ReceiveAsync(buffer.AsMemory(bytesRead), SocketFlags.None, cancellationToken).ConfigureAwait(false); + bytesRead += read; + + if (read == 0) + { + // connection closed before receiving complete response message + return (default, default, SendQueryError.MalformedResponse); + } + + if (responseLength < 0 && bytesRead >= 2) + { + responseLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(0, 2)); + + if (responseLength + 2 > buffer.Length) + { + // even though this is user-controlled pre-allocation, it is limited to + // 64 kB, so it should be fine. + var largerBuffer = ArrayPool.Shared.Rent(responseLength + 2); + Array.Copy(buffer, largerBuffer, bytesRead); + ArrayPool.Shared.Return(buffer); + buffer = largerBuffer; + } + } + } + + DnsDataReader responseReader = new DnsDataReader(new ArraySegment(buffer, 2, responseLength), true); + if (!responseReader.TryReadHeader(out DnsMessageHeader header) || + header.TransactionId != transactionId || + !header.IsResponse) + { + // header mismatch on TCP fallback + return (default, default, SendQueryError.MalformedResponse); + } + + // transfer ownership of buffer to the caller + buffer = null!; + return (responseReader, header, SendQueryError.NoError); + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + + private static (ushort id, int length) EncodeQuestion(Memory buffer, EncodedDomainName dnsSafeName, QueryType queryType) + { + DnsMessageHeader header = new DnsMessageHeader + { + TransactionId = (ushort)RandomNumberGenerator.GetInt32(ushort.MaxValue + 1), + QueryFlags = QueryFlags.RecursionDesired, + QueryCount = 1 + }; + + DnsDataWriter writer = new DnsDataWriter(buffer); + if (!writer.TryWriteHeader(header) || + !writer.TryWriteQuestion(dnsSafeName, queryType, QueryClass.Internet)) + { + // should never happen since we validated the name length before + throw new InvalidOperationException("Buffer too small"); + } + return (header.TransactionId, writer.Position); + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + + // Cancel all pending requests (if any). Note that we don't call CancelPendingRequests() but cancel + // the CTS directly. The reason is that CancelPendingRequests() would cancel the current CTS and create + // a new CTS. We don't want a new CTS in this case. + _pendingRequestsCts.Cancel(); + _pendingRequestsCts.Dispose(); + } + } + + private (CancellationTokenSource TokenSource, bool DisposeTokenSource, CancellationTokenSource PendingRequestsCts) PrepareCancellationTokenSource(CancellationToken cancellationToken) + { + // We need a CancellationTokenSource to use with the request. We always have the global + // _pendingRequestsCts to use, plus we may have a token provided by the caller, and we may + // have a timeout. If we have a timeout or a caller-provided token, we need to create a new + // CTS (we can't, for example, timeout the pending requests CTS, as that could cancel other + // unrelated operations). Otherwise, we can use the pending requests CTS directly. + + // Snapshot the current pending requests cancellation source. It can change concurrently due to cancellation being requested + // and it being replaced, and we need a stable view of it: if cancellation occurs and the caller's token hasn't been canceled, + // it's either due to this source or due to the timeout, and checking whether this source is the culprit is reliable whereas + // it's more approximate checking elapsed time. + CancellationTokenSource pendingRequestsCts = _pendingRequestsCts; + TimeSpan timeout = _options.Timeout; + + bool hasTimeout = timeout != System.Threading.Timeout.InfiniteTimeSpan; + if (hasTimeout || cancellationToken.CanBeCanceled) + { + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, pendingRequestsCts.Token); + if (hasTimeout) + { + cts.CancelAfter(timeout); + } + + return (cts, DisposeTokenSource: true, pendingRequestsCts); + } + + return (pendingRequestsCts, DisposeTokenSource: false, pendingRequestsCts); + } + + private static EncodedDomainName GetNormalizedHostName(string name) + { + byte[] buffer = ArrayPool.Shared.Rent(256); + try + { + if (!DnsPrimitives.TryWriteQName(buffer, name, out _)) + { + throw new ArgumentException($"'{name}' is not a valid DNS name.", nameof(name)); + } + + List> labels = new(); + Memory memory = buffer.AsMemory(); + while (true) + { + int len = memory.Span[0]; + + if (len == 0) + { + // root label, we are finished + break; + } + + labels.Add(memory.Slice(1, len)); + memory = memory.Slice(len + 1); + } + + buffer = null!; // ownership transferred to the EncodedDomainName + return new EncodedDomainName(labels, buffer); + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResourceRecord.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResourceRecord.cs new file mode 100644 index 00000000000..914ff9aac17 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResourceRecord.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct DnsResourceRecord +{ + public EncodedDomainName Name { get; } + public QueryType Type { get; } + public QueryClass Class { get; } + public int Ttl { get; } + public ReadOnlyMemory Data { get; } + + public DnsResourceRecord(EncodedDomainName name, QueryType type, QueryClass @class, int ttl, ReadOnlyMemory data) + { + Name = name; + Type = type; + Class = @class; + Ttl = ttl; + Data = data; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResponse.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResponse.cs new file mode 100644 index 00000000000..5a7fc8a0b52 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResponse.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct DnsResponse : IDisposable +{ + public DnsMessageHeader Header { get; } + public List Answers { get; } + public List Authorities { get; } + public List Additionals { get; } + public DateTime CreatedAt { get; } + public DateTime Expiration { get; } + public ArraySegment RawMessageBytes { get; private set; } + + public DnsResponse(ArraySegment rawData, DnsMessageHeader header, DateTime createdAt, DateTime expiration, List answers, List authorities, List additionals) + { + RawMessageBytes = rawData; + + Header = header; + CreatedAt = createdAt; + Expiration = expiration; + Answers = answers; + Authorities = authorities; + Additionals = additionals; + } + + public void Dispose() + { + if (RawMessageBytes.Array != null) + { + ArrayPool.Shared.Return(RawMessageBytes.Array); + } + + RawMessageBytes = default; // prevent further access to the raw data + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/EncodedDomainName.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/EncodedDomainName.cs new file mode 100644 index 00000000000..4c258cac3ac --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/EncodedDomainName.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct EncodedDomainName : IEquatable, IDisposable +{ + public IReadOnlyList> Labels { get; } + private byte[]? _pooledBuffer; + + public EncodedDomainName(List> labels, byte[]? pooledBuffer = null) + { + Labels = labels; + _pooledBuffer = pooledBuffer; + } + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + + foreach (var label in Labels) + { + if (sb.Length > 0) + { + sb.Append('.'); + } + sb.Append(Encoding.ASCII.GetString(label.Span)); + } + + return sb.ToString(); + } + + public bool Equals(EncodedDomainName other) + { + if (Labels.Count != other.Labels.Count) + { + return false; + } + + for (int i = 0; i < Labels.Count; i++) + { + if (!Ascii.EqualsIgnoreCase(Labels[i].Span, other.Labels[i].Span)) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) + { + return obj is EncodedDomainName other && Equals(other); + } + + public override int GetHashCode() + { + HashCode hash = new HashCode(); + + foreach (var label in Labels) + { + foreach (byte b in label.Span) + { + hash.Add((byte)char.ToLower((char)b)); + } + } + + return hash.ToHashCode(); + } + + public void Dispose() + { + if (_pooledBuffer != null) + { + ArrayPool.Shared.Return(_pooledBuffer); + } + + _pooledBuffer = null; + } +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/IDnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/IDnsResolver.cs new file mode 100644 index 00000000000..080fe3be8de --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/IDnsResolver.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal interface IDnsResolver +{ + ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default); + ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.cs new file mode 100644 index 00000000000..24b5155a1c8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.NetworkInformation; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal static class NetworkInfo +{ + // basic option to get DNS servers via NetworkInfo. We may get it directly later via proper APIs. + public static IList GetServers() + { + List servers = new List(); + + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + IPInterfaceProperties properties = nic.GetIPProperties(); + // avoid loopback, VPN etc. Should be re-visited. + + if (nic.NetworkInterfaceType == NetworkInterfaceType.Ethernet && nic.OperationalStatus == OperationalStatus.Up) + { + foreach (IPAddress server in properties.DnsAddresses) + { + IPEndPoint ep = new IPEndPoint(server, 53); // 53 is standard DNS port + if (!servers.Contains(ep)) + { + servers.Add(ep); + } + } + } + } + + return servers; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryClass.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryClass.cs new file mode 100644 index 00000000000..732ca0216da --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryClass.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal enum QueryClass +{ + Internet = 1 +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryFlags.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryFlags.cs new file mode 100644 index 00000000000..02474b6cda1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryFlags.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +[Flags] +internal enum QueryFlags : ushort +{ + RecursionAvailable = 0x0080, + RecursionDesired = 0x0100, + ResultTruncated = 0x0200, + HasAuthorityAnswer = 0x0400, + HasResponse = 0x8000, + ResponseCodeMask = 0x000F, +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryResponseCode.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryResponseCode.cs new file mode 100644 index 00000000000..dd51c712112 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryResponseCode.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +/// +/// The response code (RCODE) in a DNS query response. +/// +internal enum QueryResponseCode : byte +{ + /// + /// No error condition + /// + NoError = 0, + + /// + /// The name server was unable to interpret the query. + /// + FormatError = 1, + + /// + /// The name server was unable to process this query due to a problem with the name server. + /// + ServerFailure = 2, + + /// + /// Meaningful only for responses from an authoritative name server, this + /// code signifies that the domain name referenced in the query does not + /// exist. + /// + NameError = 3, + + /// + /// The name server does not support the requested kind of query. + /// + NotImplemented = 4, + + /// + /// The name server refuses to perform the specified operation for policy reasons. + /// + Refused = 5, +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryType.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryType.cs new file mode 100644 index 00000000000..2ccc898a5b7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryType.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +/// +/// DNS Query Types. +/// +internal enum QueryType +{ + /// + /// A host address. + /// + A = 1, + + /// + /// An authoritative name server. + /// + NS = 2, + + /// + /// The canonical name for an alias. + /// + CNAME = 5, + + /// + /// Marks the start of a zone of authority. + /// + SOA = 6, + + /// + /// Mail exchange. + /// + MX = 15, + + /// + /// Text strings. + /// + TXT = 16, + + /// + /// IPv6 host address. (RFC 3596) + /// + AAAA = 28, + + /// + /// Location information. (RFC 2782) + /// + SRV = 33, + + /// + /// Wildcard match. + /// + All = 255 +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs new file mode 100644 index 00000000000..de68e88c18d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Runtime.Versioning; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal static class ResolvConf +{ + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("osx")] + public static IList GetServers() + { + return GetServers(new StreamReader("/etc/resolv.conf")); + } + + public static IList GetServers(TextReader reader) + { + List serverList = new(); + + while (reader.ReadLine() is string line) + { + string[] tokens = line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (line.StartsWith("nameserver")) + { + if (tokens.Length >= 2 && IPAddress.TryParse(tokens[1], out IPAddress? address)) + { + serverList.Add(new IPEndPoint(address, 53)); // 53 is standard DNS port + + if (serverList.Count == 3) + { + break; // resolv.conf manpage allow max 3 nameservers anyway + } + } + } + } + + if (serverList.Count == 0) + { + // If no nameservers are configured, fall back to the default behavior of using the system resolver configuration. + return NetworkInfo.GetServers(); + } + + return serverList; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResultTypes.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResultTypes.cs new file mode 100644 index 00000000000..aed799ac8d6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResultTypes.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal record struct AddressResult(DateTime ExpiresAt, IPAddress Address); + +internal record struct ServiceResult(DateTime ExpiresAt, int Priority, int Weight, int Port, string Target, AddressResult[] Addresses); diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/SendQueryError.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/SendQueryError.cs new file mode 100644 index 00000000000..3ba5632e207 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/SendQueryError.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal enum SendQueryError +{ + /// + /// DNS query was successful and returned response message with answers. + /// + NoError, + + /// + /// Server failed to respond to the query withing specified timeout. + /// + Timeout, + + /// + /// Server returned a response with an error code. + /// + ServerError, + + /// + /// Server returned a malformed response. + /// + MalformedResponse, + + /// + /// Server returned a response indicating that the name exists, but no data are available. + /// + NoData, + + /// + /// Server returned a response indicating the name does not exist. + /// + NameError, + + /// + /// Network-level error occurred during the query. + /// + NetworkError, + + /// + /// Internal error on part of the implementation. + /// + InternalError, +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs new file mode 100644 index 00000000000..313e68b3b8a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery; +using Microsoft.Extensions.ServiceDiscovery.Dns; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extensions for to add service discovery. +/// +public static class ServiceDiscoveryDnsServiceCollectionExtensions +{ + /// + /// Adds DNS SRV service discovery to the . + /// + /// The service collection. + /// The provided . + /// + /// DNS SRV queries are able to provide port numbers for endpoints and can support multiple named endpoints per service. + /// However, not all environment support DNS SRV queries, and in some environments, additional configuration may be required. + /// + public static IServiceCollection AddDnsSrvServiceEndpointProvider(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddDnsSrvServiceEndpointProvider(_ => { }); + } + + /// + /// Adds DNS SRV service discovery to the . + /// + /// The service collection. + /// The DNS SRV service discovery configuration options. + /// The provided . + /// + /// DNS SRV queries are able to provide port numbers for endpoints and can support multiple named endpoints per service. + /// However, not all environment support DNS SRV queries, and in some environments, additional configuration may be required. + /// + public static IServiceCollection AddDnsSrvServiceEndpointProvider(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddServiceDiscoveryCore(); + + if (!GetDnsClientFallbackFlag()) + { + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + services.TryAddSingleton(); + } + + services.AddSingleton(); + var options = services.AddOptions(); + options.Configure(configureOptions); + + return services; + + } + + /// + /// Adds DNS service discovery to the . + /// + /// The service collection. + /// The provided . + /// + /// DNS A/AAAA queries are widely available but are not able to provide port numbers for endpoints and cannot support multiple named endpoints per service. + /// + public static IServiceCollection AddDnsServiceEndpointProvider(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddDnsServiceEndpointProvider(_ => { }); + } + + /// + /// Adds DNS service discovery to the . + /// + /// The service collection. + /// The DNS SRV service discovery configuration options. + /// The provided . + /// + /// DNS A/AAAA queries are widely available but are not able to provide port numbers for endpoints and cannot support multiple named endpoints per service. + /// + public static IServiceCollection AddDnsServiceEndpointProvider(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddServiceDiscoveryCore(); + + if (!GetDnsClientFallbackFlag()) + { + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + services.TryAddSingleton(); + } + + services.AddSingleton(); + var options = services.AddOptions(); + options.Configure(configureOptions); + + return services; + } + + /// + /// Configures the DNS resolver used for service discovery. + /// + /// The service collection. + /// The DNS resolver options. + /// The provided . + public static IServiceCollection ConfigureDnsResolver(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = services.AddOptions(); + options.Configure(configureOptions); + services.AddTransient, DnsResolverOptionsValidator>(); + return services; + } + + private static bool GetDnsClientFallbackFlag() + { + if (AppContext.TryGetSwitch("Microsoft.Extensions.ServiceDiscovery.Dns.UseDnsClientFallback", out var value)) + { + return value; + } + + var envVar = Environment.GetEnvironmentVariable("MICROSOFT_EXTENSIONS_SERVICE_DISCOVERY_DNS_USE_DNSCLIENT_FALLBACK"); + if (envVar is not null && (envVar.Equals("true", StringComparison.OrdinalIgnoreCase) || envVar.Equals("1"))) + { + return true; + } + + return false; + } + +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.csproj new file mode 100644 index 00000000000..2b4530077cd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.csproj @@ -0,0 +1,36 @@ + + + + $(NetCoreTargetFrameworks) + enable + enable + true + Provides extensions for service discovery for the YARP reverse proxy. + Open + + $(NoWarn);IDE0018;IDE0025;IDE0032;IDE0040;IDE0058;IDE0250;IDE0251;IDE1006;CA1304;CA1307;CA1309;CA1310;CA1849;CA2000;CA2213;CA2217;S125;S1135;S1226;S2344;S2692;S3626;S4022;SA1108;SA1120;SA1128;SA1129;SA1204;SA1205;SA1214;SA1400;SA1405;SA1408;SA1414;SA1515;SA1600;SA1615;SA1629;SA1642;SA1649;EA0001;EA0009;EA0014;LA0001;LA0003;LA0008;VSTHRD200 + enable + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/README.md new file mode 100644 index 00000000000..a7175f0382c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/README.md @@ -0,0 +1,42 @@ +# Microsoft.Extensions.ServiceDiscovery.Yarp + +The `Microsoft.Extensions.ServiceDiscovery.Yarp` library adds support for resolving endpoints for YARP clusters, by implementing a [YARP destination resolver](https://github.com/microsoft/reverse-proxy/blob/main/docs/docfx/articles/destination-resolvers.md). + +## Usage + +### Resolving YARP cluster destinations using Service Discovery + +The `IReverseProxyBuilder.AddServiceDiscoveryDestinationResolver()` extension method configures a [YARP destination resolver](https://github.com/microsoft/reverse-proxy/blob/main/docs/docfx/articles/destination-resolvers.md). To use this method, you must also configure YARP itself as described in the YARP documentation, and you must configure .NET Service Discovery via the _Microsoft.Extensions.ServiceDiscovery_ library. + +### Direct HTTP forwarding using Service Discovery Forwarding HTTP requests using `IHttpForwarder` + +YARP supports _direct forwarding_ of specific requests using the `IHttpForwarder` interface. This, too, can benefit from service discovery using the _Microsoft.Extensions.ServiceDiscovery_ library. To take advantage of service discovery when using YARP Direct Forwarding, use the `IServiceCollection.AddHttpForwarderWithServiceDiscovery` method. + +For example, consider the following .NET Aspire application: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Configure service discovery +builder.Services.AddServiceDiscovery(); + +// Add YARP Direct Forwarding with Service Discovery support +builder.Services.AddHttpForwarderWithServiceDiscovery(); + +// ... other configuration ... + +var app = builder.Build(); + +// ... other configuration ... + +// Map a Direct Forwarder which forwards requests to the resolved "catalogservice" endpoints +app.MapForwarder("/catalog/images/{id}", "http://catalogservice", "/api/v1/catalog/items/{id}/image"); + +app.Run(); +``` + +In the above example, the YARP Direct Forwarder will resolve the _catalogservice_ using service discovery, forwarding request sent to the `/catalog/images/{id}` endpoint to the destination path on the resolved endpoints. + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryDestinationResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryDestinationResolver.cs new file mode 100644 index 00000000000..2ca456ec911 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryDestinationResolver.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.ServiceDiscovery; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp; + +/// +/// Implementation of which resolves destinations using service discovery. +/// +/// +/// Initializes a new instance. +/// +/// The endpoint resolver registry. +/// The service discovery options. +internal sealed class ServiceDiscoveryDestinationResolver(ServiceEndpointResolver resolver, IOptions options) : IDestinationResolver +{ + private readonly ServiceDiscoveryOptions _options = options.Value; + + /// + public async ValueTask ResolveDestinationsAsync(IReadOnlyDictionary destinations, CancellationToken cancellationToken) + { + Dictionary results = new(); + var tasks = new List, IChangeToken ChangeToken)>>(destinations.Count); + foreach (var (destinationId, destinationConfig) in destinations) + { + tasks.Add(ResolveHostAsync(destinationId, destinationConfig, cancellationToken)); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + var changeTokens = new List(); + foreach (var task in tasks) + { + var (configs, changeToken) = await task.ConfigureAwait(false); + if (changeToken is not null) + { + changeTokens.Add(changeToken); + } + + foreach (var (name, config) in configs) + { + results[name] = config; + } + } + + return new ResolvedDestinationCollection(results, new CompositeChangeToken(changeTokens)); + } + + private async Task<(List<(string Name, DestinationConfig Config)>, IChangeToken ChangeToken)> ResolveHostAsync( + string originalName, + DestinationConfig originalConfig, + CancellationToken cancellationToken) + { + var originalUri = new Uri(originalConfig.Address); + var serviceName = originalUri.GetLeftPart(UriPartial.Authority); + + var result = await resolver.GetEndpointsAsync(serviceName, cancellationToken).ConfigureAwait(false); + var results = new List<(string Name, DestinationConfig Config)>(result.Endpoints.Count); + var uriBuilder = new UriBuilder(originalUri); + var healthUri = originalConfig.Health is { Length: > 0 } health ? new Uri(health) : null; + var healthUriBuilder = healthUri is { } ? new UriBuilder(healthUri) : null; + foreach (var endpoint in result.Endpoints) + { + var addressString = endpoint.ToString()!; + Uri uri; + if (!addressString.Contains("://")) + { + var scheme = GetDefaultScheme(originalUri); + uri = new Uri($"{scheme}://{addressString}"); + } + else + { + uri = new Uri(addressString); + } + + uriBuilder.Scheme = uri.Scheme; + uriBuilder.Host = uri.Host; + uriBuilder.Port = uri.Port; + var resolvedAddress = uriBuilder.Uri.ToString(); + var healthAddress = originalConfig.Health; + if (healthUriBuilder is not null) + { + healthUriBuilder.Host = uri.Host; + healthUriBuilder.Port = uri.Port; + healthAddress = healthUriBuilder.Uri.ToString(); + } + + var name = $"{originalName}[{addressString}]"; + string? resolvedHost = null; + + // Use the configured 'Host' value if it is provided. + if (!string.IsNullOrEmpty(originalConfig.Host)) + { + resolvedHost = originalConfig.Host; + } + + var config = originalConfig with { Host = resolvedHost, Address = resolvedAddress, Health = healthAddress }; + results.Add((name, config)); + } + + return (results, result.ChangeToken); + } + + private string GetDefaultScheme(Uri originalUri) + { + if (originalUri.Scheme.IndexOf('+') > 0) + { + // Use the first allowed scheme. + var specifiedSchemes = originalUri.Scheme.Split('+'); + foreach (var scheme in specifiedSchemes) + { + if (_options.AllowAllSchemes || _options.AllowedSchemes.Contains(scheme, StringComparer.OrdinalIgnoreCase)) + { + return scheme; + } + } + + throw new InvalidOperationException($"None of the specified schemes ('{string.Join(", ", specifiedSchemes)}') are allowed by configuration."); + } + else + { + return originalUri.Scheme; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryForwarderHttpClientFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryForwarderHttpClientFactory.cs new file mode 100644 index 00000000000..84aafe2a67e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryForwarderHttpClientFactory.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.ServiceDiscovery.Http; +using Yarp.ReverseProxy.Forwarder; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp; + +internal sealed class ServiceDiscoveryForwarderHttpClientFactory(IServiceDiscoveryHttpMessageHandlerFactory handlerFactory) + : ForwarderHttpClientFactory +{ + protected override HttpMessageHandler WrapHandler(ForwarderHttpClientContext context, HttpMessageHandler handler) + { + return handlerFactory.CreateHandler(handler); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryReverseProxyServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryReverseProxyServiceCollectionExtensions.cs new file mode 100644 index 00000000000..de74dc0fc24 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryReverseProxyServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.ServiceDiscovery.Yarp; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.ServiceDiscovery; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for used to register the ReverseProxy's components. +/// +public static class ServiceDiscoveryReverseProxyServiceCollectionExtensions +{ + /// + /// Provides a implementation which uses service discovery to resolve destinations. + /// + public static IReverseProxyBuilder AddServiceDiscoveryDestinationResolver(this IReverseProxyBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddServiceDiscoveryCore(); + builder.Services.AddSingleton(); + return builder; + } + + /// + /// Adds the with service discovery support. + /// + public static IServiceCollection AddHttpForwarderWithServiceDiscovery(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddHttpForwarder().AddServiceDiscoveryForwarderFactory(); + } + + /// + /// Provides a implementation which uses service discovery to resolve service names. + /// + public static IServiceCollection AddServiceDiscoveryForwarderFactory(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddServiceDiscoveryCore(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.Log.cs new file mode 100644 index 00000000000..b27c5ea9190 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.Log.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +internal sealed partial class ConfigurationServiceEndpointProvider +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Skipping endpoint resolution for service '{ServiceName}': '{Reason}'.", EventName = "SkippedResolution")] + public static partial void SkippedResolution(ILogger logger, string serviceName, string reason); + + [LoggerMessage(2, LogLevel.Debug, "Using configuration from path '{Path}' to resolve endpoint '{EndpointName}' for service '{ServiceName}'.", EventName = "UsingConfigurationPath")] + public static partial void UsingConfigurationPath(ILogger logger, string path, string endpointName, string serviceName); + + [LoggerMessage(3, LogLevel.Debug, "No valid endpoint configuration was found for service '{ServiceName}' from path '{Path}'.", EventName = "ServiceConfigurationNotFound")] + internal static partial void ServiceConfigurationNotFound(ILogger logger, string serviceName, string path); + + [LoggerMessage(4, LogLevel.Debug, "Endpoints configured for service '{ServiceName}' from path '{Path}': {ConfiguredEndpoints}.", EventName = "ConfiguredEndpoints")] + internal static partial void ConfiguredEndpoints(ILogger logger, string serviceName, string path, string configuredEndpoints); + + internal static void ConfiguredEndpoints(ILogger logger, string serviceName, string path, IList endpoints, int added) + { + if (!logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + StringBuilder endpointValues = new(); + for (var i = endpoints.Count - added; i < endpoints.Count; i++) + { + if (endpointValues.Length > 0) + { + endpointValues.Append(", "); + } + + endpointValues.Append(endpoints[i].ToString()); + } + + var configuredEndpoints = endpointValues.ToString(); + ConfiguredEndpoints(logger, serviceName, path, configuredEndpoints); + } + + [LoggerMessage(5, LogLevel.Debug, "No valid endpoint configuration was found for endpoint '{EndpointName}' on service '{ServiceName}' from path '{Path}'.", EventName = "EndpointConfigurationNotFound")] + internal static partial void EndpointConfigurationNotFound(ILogger logger, string endpointName, string serviceName, string path); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.cs new file mode 100644 index 00000000000..72d6e7b6b81 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +/// +/// A service endpoint provider that uses configuration to resolve resolved. +/// +internal sealed partial class ConfigurationServiceEndpointProvider : IServiceEndpointProvider, IHostNameFeature +{ + private const string DefaultEndpointName = "default"; + private readonly string _serviceName; + private readonly string? _endpointName; + private readonly bool _includeAllSchemes; + private readonly string[] _schemes; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly IOptions _options; + + /// + /// Initializes a new instance. + /// + /// The query. + /// The configuration. + /// The logger. + /// Configuration provider options. + /// Service discovery options. + public ConfigurationServiceEndpointProvider( + ServiceEndpointQuery query, + IConfiguration configuration, + ILogger logger, + IOptions options, + IOptions serviceDiscoveryOptions) + { + _serviceName = query.ServiceName; + _endpointName = query.EndpointName; + _includeAllSchemes = serviceDiscoveryOptions.Value.AllowAllSchemes && query.IncludedSchemes.Count == 0; + var allowedSchemes = serviceDiscoveryOptions.Value.ApplyAllowedSchemes(query.IncludedSchemes); + _schemes = allowedSchemes as string[] ?? allowedSchemes.ToArray(); + _configuration = configuration; + _logger = logger; + _options = options; + } + + /// + public ValueTask DisposeAsync() => default; + + /// + public ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) + { + // Only add resolved to the collection if a previous provider (eg, an override) did not add them. + if (endpoints.Endpoints.Count != 0) + { + Log.SkippedResolution(_logger, _serviceName, "Collection has existing endpoints"); + return default; + } + + // Get the corresponding config section. + var section = _configuration.GetSection(_options.Value.SectionName).GetSection(_serviceName); + if (!section.Exists()) + { + endpoints.AddChangeToken(_configuration.GetReloadToken()); + Log.ServiceConfigurationNotFound(_logger, _serviceName, $"{_options.Value.SectionName}:{_serviceName}"); + return default; + } + + endpoints.AddChangeToken(section.GetReloadToken()); + + // Find an appropriate configuration section based on the input. + IConfigurationSection? namedSection = null; + string endpointName; + if (string.IsNullOrWhiteSpace(_endpointName)) + { + // Treat the scheme as the endpoint name and use the first section with a matching endpoint name which exists + endpointName = DefaultEndpointName; + ReadOnlySpan candidateNames = [DefaultEndpointName, .. _schemes]; + foreach (var scheme in candidateNames) + { + var candidate = section.GetSection(scheme); + if (candidate.Exists()) + { + endpointName = scheme; + namedSection = candidate; + break; + } + } + } + else + { + // Use the section corresponding to the endpoint name. + endpointName = _endpointName; + namedSection = section.GetSection(_endpointName); + } + + var configPath = $"{_options.Value.SectionName}:{_serviceName}:{endpointName}"; + if (!namedSection.Exists()) + { + Log.EndpointConfigurationNotFound(_logger, endpointName, _serviceName, configPath); + return default; + } + + List resolved = []; + Log.UsingConfigurationPath(_logger, configPath, endpointName, _serviceName); + + // Account for both the single and multi-value cases. + if (!string.IsNullOrWhiteSpace(namedSection.Value)) + { + // Single value case. + AddEndpoint(resolved, namedSection, endpointName); + } + else + { + // Multiple value case. + foreach (var child in namedSection.GetChildren()) + { + if (!int.TryParse(child.Key, out _)) + { + throw new KeyNotFoundException($"The endpoint configuration section for service '{_serviceName}' endpoint '{endpointName}' has non-numeric keys."); + } + + AddEndpoint(resolved, child, endpointName); + } + } + + int resolvedEndpointCount; + if (_includeAllSchemes) + { + // Include all endpoints. + foreach (var ep in resolved) + { + endpoints.Endpoints.Add(ep); + } + + resolvedEndpointCount = resolved.Count; + } + else + { + // Filter the resolved endpoints to only include those which match the specified, allowed schemes. + resolvedEndpointCount = 0; + var minIndex = _schemes.Length; + foreach (var ep in resolved) + { + if (ep.EndPoint is UriEndPoint uri && uri.Uri.Scheme is { } scheme) + { + var index = Array.IndexOf(_schemes, scheme); + if (index >= 0 && index < minIndex) + { + minIndex = index; + } + } + } + + foreach (var ep in resolved) + { + if (ep.EndPoint is UriEndPoint uri && uri.Uri.Scheme is { } scheme) + { + var index = Array.IndexOf(_schemes, scheme); + if (index >= 0 && index <= minIndex) + { + ++resolvedEndpointCount; + endpoints.Endpoints.Add(ep); + } + } + else + { + ++resolvedEndpointCount; + endpoints.Endpoints.Add(ep); + } + } + } + + if (resolvedEndpointCount == 0) + { + Log.ServiceConfigurationNotFound(_logger, _serviceName, configPath); + } + else + { + Log.ConfiguredEndpoints(_logger, _serviceName, configPath, endpoints.Endpoints, resolvedEndpointCount); + } + + return default; + } + + string IHostNameFeature.HostName => _serviceName; + + private void AddEndpoint(List endpoints, IConfigurationSection section, string endpointName) + { + if (!ServiceEndpoint.TryParse(section.Value, out var serviceEndpoint)) + { + throw new KeyNotFoundException($"The endpoint configuration section for service '{_serviceName}' endpoint '{endpointName}' has an invalid value with key '{section.Key}'."); + } + + serviceEndpoint.Features.Set(this); + if (_options.Value.ShouldApplyHostNameMetadata(serviceEndpoint)) + { + serviceEndpoint.Features.Set(this); + } + + endpoints.Add(serviceEndpoint); + } + + public override string ToString() => "Configuration"; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..a966cd44794 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderFactory.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +/// +/// implementation that resolves services using . +/// +internal sealed class ConfigurationServiceEndpointProviderFactory( + IConfiguration configuration, + IOptions options, + IOptions serviceDiscoveryOptions, + ILogger logger) : IServiceEndpointProviderFactory +{ + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + provider = new ConfigurationServiceEndpointProvider(query, configuration, logger, options, serviceDiscoveryOptions); + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderOptionsValidator.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderOptionsValidator.cs new file mode 100644 index 00000000000..f8092c4dd51 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderOptionsValidator.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +internal sealed class ConfigurationServiceEndpointProviderOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ConfigurationServiceEndpointProviderOptions options) + { + if (string.IsNullOrWhiteSpace(options.SectionName)) + { + return ValidateOptionsResult.Fail($"{nameof(options.SectionName)} must not be null or empty."); + } + + if (options.ShouldApplyHostNameMetadata is null) + { + return ValidateOptionsResult.Fail($"{nameof(options.ShouldApplyHostNameMetadata)} must not be null."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ConfigurationServiceEndpointProviderOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ConfigurationServiceEndpointProviderOptions.cs new file mode 100644 index 00000000000..29f28e359f7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ConfigurationServiceEndpointProviderOptions.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.ServiceDiscovery.Configuration; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Options for . +/// +public sealed class ConfigurationServiceEndpointProviderOptions +{ + /// + /// The name of the configuration section which contains service endpoints. Defaults to "Services". + /// + public string SectionName { get; set; } = "Services"; + + /// + /// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to a delegate which returns false. + /// + public Func ShouldApplyHostNameMetadata { get; set; } = _ => false; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/HttpServiceEndpointResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/HttpServiceEndpointResolver.cs new file mode 100644 index 00000000000..e547ab14138 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/HttpServiceEndpointResolver.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.LoadBalancing; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// Resolves endpoints for HTTP requests. +/// +internal sealed class HttpServiceEndpointResolver(ServiceEndpointWatcherFactory watcherFactory, IServiceProvider serviceProvider, TimeProvider timeProvider) : IAsyncDisposable +{ + private static readonly TimerCallback s_cleanupCallback = s => ((HttpServiceEndpointResolver)s!).CleanupResolvers(); + private static readonly TimeSpan s_cleanupPeriod = TimeSpan.FromSeconds(10); + + private readonly object _lock = new(); + private readonly ServiceEndpointWatcherFactory _watcherFactory = watcherFactory; + private readonly ConcurrentDictionary _resolvers = new(); + private ITimer? _cleanupTimer; + private Task? _cleanupTask; + + /// + /// Resolves and returns a service endpoint for the specified request. + /// + /// The request message. + /// A . + /// The resolved service endpoint. + /// The request had no set or a suitable endpoint could not be found. + public async ValueTask GetEndpointAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (request.RequestUri is null) + { + throw new InvalidOperationException("Cannot resolve an endpoint for a request which has no RequestUri"); + } + + EnsureCleanupTimerStarted(); + + var key = request.RequestUri.GetLeftPart(UriPartial.Authority); + while (true) + { + var resolver = _resolvers.GetOrAdd( + key, + static (name, self) => self.CreateResolver(name), + this); + + var (valid, endpoint) = await resolver.TryGetEndpointAsync(request, cancellationToken).ConfigureAwait(false); + if (valid) + { + if (endpoint is null) + { + throw new InvalidOperationException($"Unable to resolve endpoint for service {resolver.ServiceName}"); + } + + return endpoint; + } + else + { + _resolvers.TryRemove(KeyValuePair.Create(resolver.ServiceName, resolver)); + } + } + } + + private void EnsureCleanupTimerStarted() + { + if (_cleanupTimer is not null) + { + return; + } + + lock (_lock) + { + if (_cleanupTimer is not null) + { + return; + } + + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + var restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _cleanupTimer = timeProvider.CreateTimer(s_cleanupCallback, this, s_cleanupPeriod, s_cleanupPeriod); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } + + /// + public async ValueTask DisposeAsync() + { + lock (_lock) + { + _cleanupTimer?.Dispose(); + _cleanupTimer = null; + } + + foreach (var resolver in _resolvers) + { + await resolver.Value.DisposeAsync().ConfigureAwait(false); + } + + _resolvers.Clear(); + if (_cleanupTask is not null) + { + await _cleanupTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + private void CleanupResolvers() + { + lock (_lock) + { + if (_cleanupTask is null or { IsCompleted: true }) + { + _cleanupTask = CleanupResolversAsyncCore(); + } + } + } + + private async Task CleanupResolversAsyncCore() + { + List? cleanupTasks = null; + foreach (var (name, resolver) in _resolvers) + { + if (resolver.CanExpire() && _resolvers.TryRemove(name, out var _)) + { + cleanupTasks ??= new(); + cleanupTasks.Add(resolver.DisposeAsync().AsTask()); + } + } + + if (cleanupTasks is not null) + { + await Task.WhenAll(cleanupTasks).ConfigureAwait(false); + } + } + + private ResolverEntry CreateResolver(string serviceName) + { + var watcher = _watcherFactory.CreateWatcher(serviceName); + var selector = serviceProvider.GetService() ?? new RoundRobinServiceEndpointSelector(); + var result = new ResolverEntry(watcher, selector); + watcher.Start(); + return result; + } + + private sealed class ResolverEntry : IAsyncDisposable + { + private readonly ServiceEndpointWatcher _watcher; + private readonly IServiceEndpointSelector _selector; + private const ulong CountMask = ~(RecentUseFlag | DisposingFlag); + private const ulong RecentUseFlag = 1UL << 62; + private const ulong DisposingFlag = 1UL << 63; + private ulong _status; + private TaskCompletionSource? _onDisposed; + + public ResolverEntry(ServiceEndpointWatcher watcher, IServiceEndpointSelector selector) + { + _watcher = watcher; + _selector = selector; + _watcher.OnEndpointsUpdated += result => + { + if (result.ResolvedSuccessfully) + { + _selector.SetEndpoints(result.EndpointSource); + } + }; + } + + public string ServiceName => _watcher.ServiceName; + + public bool CanExpire() + { + // Read the status, clearing the recent use flag in the process. + var status = Interlocked.And(ref _status, ~RecentUseFlag); + + // The instance can be expired if there are no concurrent callers and the recent use flag was not set. + return (status & (CountMask | RecentUseFlag)) == 0; + } + + public async ValueTask<(bool Valid, ServiceEndpoint? Endpoint)> TryGetEndpointAsync(object? context, CancellationToken cancellationToken) + { + try + { + var status = Interlocked.Increment(ref _status); + if ((status & DisposingFlag) == 0) + { + // If the watcher is valid, resolve. + // We ensure that it will not be disposed while we are resolving. + await _watcher.GetEndpointsAsync(cancellationToken).ConfigureAwait(false); + var result = _selector.GetEndpoint(context); + return (true, result); + } + else + { + return (false, default); + } + } + finally + { + // Set the recent use flag to prevent the instance from being disposed. + Interlocked.Or(ref _status, RecentUseFlag); + + // If we are the last concurrent request to complete and the Disposing flag has been set, + // dispose the resolver now. DisposeAsync was prevented by concurrent requests. + var status = Interlocked.Decrement(ref _status); + if ((status & DisposingFlag) == DisposingFlag && (status & CountMask) == 0) + { + await DisposeAsyncCore().ConfigureAwait(false); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_onDisposed is null) + { + Interlocked.CompareExchange(ref _onDisposed, new(TaskCreationOptions.RunContinuationsAsynchronously), null); + } + + var status = Interlocked.Or(ref _status, DisposingFlag); + if ((status & DisposingFlag) != DisposingFlag && (status & CountMask) == 0) + { + // If we are the one who flipped the Disposing flag and there are no concurrent requests, + // dispose the instance now. Concurrent requests are prevented from starting by the Disposing flag. + await DisposeAsyncCore().ConfigureAwait(false); + } + else + { + await _onDisposed.Task.ConfigureAwait(false); + } + } + + private async Task DisposeAsyncCore() + { + try + { + await _watcher.DisposeAsync().ConfigureAwait(false); + } + finally + { + Debug.Assert(_onDisposed is not null); + _onDisposed.SetResult(); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/IServiceDiscoveryHttpMessageHandlerFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/IServiceDiscoveryHttpMessageHandlerFactory.cs new file mode 100644 index 00000000000..0c5bd02d10d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/IServiceDiscoveryHttpMessageHandlerFactory.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// Factory which creates instances which resolve endpoints using service discovery +/// before delegating to a provided handler. +/// +public interface IServiceDiscoveryHttpMessageHandlerFactory +{ + /// + /// Creates an instance which resolve endpoints using service discovery before + /// delegating to a provided handler. + /// + /// The handler to delegate to. + /// The new . + HttpMessageHandler CreateHandler(HttpMessageHandler handler); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpClientHandler.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpClientHandler.cs new file mode 100644 index 00000000000..a0063ae476b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpClientHandler.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// which resolves endpoints using service discovery. +/// +internal sealed class ResolvingHttpClientHandler(HttpServiceEndpointResolver resolver, IOptions options) : HttpClientHandler +{ + private readonly HttpServiceEndpointResolver _resolver = resolver; + private readonly ServiceDiscoveryOptions _options = options.Value; + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var originalUri = request.RequestUri; + + try + { + if (originalUri?.Host is not null) + { + var result = await _resolver.GetEndpointAsync(request, cancellationToken).ConfigureAwait(false); + request.RequestUri = ResolvingHttpDelegatingHandler.GetUriWithEndpoint(originalUri, result, _options); + request.Headers.Host ??= result.Features.Get()?.HostName; + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + request.RequestUri = originalUri; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpDelegatingHandler.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpDelegatingHandler.cs new file mode 100644 index 00000000000..8f13bb60ab5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpDelegatingHandler.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// HTTP message handler which resolves endpoints using service discovery. +/// +internal sealed class ResolvingHttpDelegatingHandler : DelegatingHandler +{ + private readonly HttpServiceEndpointResolver _resolver; + private readonly ServiceDiscoveryOptions _options; + + /// + /// Initializes a new instance. + /// + /// The endpoint resolver. + /// The service discovery options. + public ResolvingHttpDelegatingHandler(HttpServiceEndpointResolver resolver, IOptions options) + { + _resolver = resolver; + _options = options.Value; + } + + /// + /// Initializes a new instance. + /// + /// The endpoint resolver. + /// The service discovery options. + /// The inner handler. + public ResolvingHttpDelegatingHandler(HttpServiceEndpointResolver resolver, IOptions options, HttpMessageHandler innerHandler) : base(innerHandler) + { + _resolver = resolver; + _options = options.Value; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var originalUri = request.RequestUri; + if (originalUri?.Host is not null) + { + var result = await _resolver.GetEndpointAsync(request, cancellationToken).ConfigureAwait(false); + request.RequestUri = GetUriWithEndpoint(originalUri, result, _options); + request.Headers.Host ??= result.Features.Get()?.HostName; + } + + try + { + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + request.RequestUri = originalUri; + } + } + + internal static Uri GetUriWithEndpoint(Uri uri, ServiceEndpoint serviceEndpoint, ServiceDiscoveryOptions options) + { + var endPoint = serviceEndpoint.EndPoint; + UriBuilder result; + if (endPoint is UriEndPoint { Uri: { } ep }) + { + result = new UriBuilder(uri) + { + Scheme = ep.Scheme, + Host = ep.Host, + }; + + if (ep.Port > 0) + { + result.Port = ep.Port; + } + + if (ep.AbsolutePath.Length > 1) + { + result.Path = $"{ep.AbsolutePath.TrimEnd('/')}/{uri.AbsolutePath.TrimStart('/')}"; + } + } + else + { + string host; + int port; + switch (endPoint) + { + case IPEndPoint ip: + host = ip.Address.ToString(); + port = ip.Port; + break; + case DnsEndPoint dns: + host = dns.Host; + port = dns.Port; + break; + default: + throw new InvalidOperationException($"Endpoints of type {endPoint.GetType()} are not supported"); + } + + result = new UriBuilder(uri) + { + Host = host, + }; + + // Default to the default port for the scheme. + if (port > 0) + { + result.Port = port; + } + + if (uri.Scheme.IndexOf('+') > 0) + { + var scheme = uri.Scheme.Split('+')[0]; + if (options.AllowAllSchemes || options.AllowedSchemes.Contains(scheme, StringComparer.OrdinalIgnoreCase)) + { + result.Scheme = scheme; + } + else + { + throw new InvalidOperationException($"The scheme '{scheme}' is not allowed."); + } + } + } + + return result.Uri; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs new file mode 100644 index 00000000000..e5e7f7587bb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +internal sealed class ServiceDiscoveryHttpMessageHandlerFactory( + TimeProvider timeProvider, + IServiceProvider serviceProvider, + ServiceEndpointWatcherFactory factory, + IOptions options) : IServiceDiscoveryHttpMessageHandlerFactory +{ + public HttpMessageHandler CreateHandler(HttpMessageHandler handler) + { + var registry = new HttpServiceEndpointResolver(factory, serviceProvider, timeProvider); + return new ResolvingHttpDelegatingHandler(registry, options, handler); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceDiscoveryOptionsValidator.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceDiscoveryOptionsValidator.cs new file mode 100644 index 00000000000..fae7bd6f4fc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceDiscoveryOptionsValidator.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Internal; + +internal sealed class ServiceDiscoveryOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ServiceDiscoveryOptions options) + { + if (options.AllowedSchemes is null) + { + return ValidateOptionsResult.Fail("At least one allowed scheme must be specified."); + } + + return ValidateOptionsResult.Success; + } +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceEndpointResolverResult.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceEndpointResolverResult.cs new file mode 100644 index 00000000000..675941bb955 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceEndpointResolverResult.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.ServiceDiscovery.Internal; + +/// +/// Represents the result of service endpoint resolution. +/// +/// The endpoint collection. +/// The exception which occurred during resolution. +internal sealed class ServiceEndpointResolverResult(ServiceEndpointSource? endpointSource, Exception? exception) +{ + /// + /// Gets the exception which occurred during resolution. + /// + public Exception? Exception { get; } = exception; + + /// + /// Gets a value indicating whether resolution completed successfully. + /// + [MemberNotNullWhen(true, nameof(EndpointSource))] + public bool ResolvedSuccessfully => Exception is null; + + /// + /// Gets the endpoints. + /// + public ServiceEndpointSource? EndpointSource { get; } = endpointSource; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/IServiceEndpointSelector.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/IServiceEndpointSelector.cs new file mode 100644 index 00000000000..2d81ff38601 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/IServiceEndpointSelector.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.LoadBalancing; + +/// +/// Selects endpoints from a collection of endpoints. +/// +internal interface IServiceEndpointSelector +{ + /// + /// Sets the collection of endpoints which this instance will select from. + /// + /// The collection of endpoints to select from. + void SetEndpoints(ServiceEndpointSource endpoints); + + /// + /// Selects an endpoints from the collection provided by the most recent call to . + /// + /// The context. + /// An endpoint. + ServiceEndpoint GetEndpoint(object? context); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/RoundRobinServiceEndpointSelector.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/RoundRobinServiceEndpointSelector.cs new file mode 100644 index 00000000000..e7e51bc6021 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/RoundRobinServiceEndpointSelector.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.LoadBalancing; + +/// +/// Selects endpoints by iterating through the list of endpoints in a round-robin fashion. +/// +internal sealed class RoundRobinServiceEndpointSelector : IServiceEndpointSelector +{ + private uint _next; + private IReadOnlyList? _endpoints; + + /// + public void SetEndpoints(ServiceEndpointSource endpoints) + { + _endpoints = endpoints.Endpoints; + } + + /// + public ServiceEndpoint GetEndpoint(object? context) + { + if (_endpoints is not { Count: > 0 } collection) + { + throw new InvalidOperationException("The endpoint collection contains no endpoints"); + } + + return collection[(int)(Interlocked.Increment(ref _next) % collection.Count)]; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.csproj new file mode 100644 index 00000000000..51631d328c0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.csproj @@ -0,0 +1,39 @@ + + + + $(TargetFrameworks);netstandard2.0 + true + Provides extensions to HttpClient that enable service discovery based on configuration. + Open + true + + $(NoWarn);CS8600;CS8602;CS8604;IDE0040;IDE0055;IDE0058;IDE1006;CA1307;CA1310;CA1849;CA2007;CA2213;SA1204;SA1128;SA1205;SA1405;SA1612;SA1623;SA1625;SA1642;S1144;S1449;S2302;S2692;S3872;S4457;EA0000;EA0009;EA0014;LA0001;LA0003;LA0008;VSTHRD200 + enable + false + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.Log.cs new file mode 100644 index 00000000000..9f6e9ce0ccb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.Log.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.PassThrough; + +internal sealed partial class PassThroughServiceEndpointProvider +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Using pass-through service endpoint provider for service '{ServiceName}'.", EventName = "UsingPassThrough")] + internal static partial void UsingPassThrough(ILogger logger, string serviceName); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.cs new file mode 100644 index 00000000000..478d81d42dc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.PassThrough; + +/// +/// Service endpoint provider which passes through the provided value. +/// +internal sealed partial class PassThroughServiceEndpointProvider(ILogger logger, string serviceName, EndPoint endPoint) : IServiceEndpointProvider +{ + public ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) + { + if (endpoints.Endpoints.Count == 0) + { + Log.UsingPassThrough(logger, serviceName); + var ep = ServiceEndpoint.Create(endPoint); + ep.Features.Set(this); + endpoints.Endpoints.Add(ep); + } + + return default; + } + + public ValueTask DisposeAsync() => default; + + public override string ToString() => "Pass-through"; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..2bf8c0cb481 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProviderFactory.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.PassThrough; + +/// +/// Service endpoint provider factory which creates pass-through providers. +/// +internal sealed class PassThroughServiceEndpointProviderFactory(ILogger logger) : IServiceEndpointProviderFactory +{ + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + var serviceName = query.ToString()!; + if (!TryCreateEndPoint(serviceName, out var endPoint)) + { + // Propagate the value through regardless, leaving it to the caller to interpret it. + endPoint = new DnsEndPoint(serviceName, 0); + } + + provider = new PassThroughServiceEndpointProvider(logger, serviceName, endPoint); + return true; + } + + private static bool TryCreateEndPoint(string serviceName, [NotNullWhen(true)] out EndPoint? endPoint) + { + if ((serviceName.Contains("://", StringComparison.Ordinal) || !Uri.TryCreate($"fakescheme://{serviceName}", default, out var uri)) && !Uri.TryCreate(serviceName, default, out uri)) + { + endPoint = null; + return false; + } + + var uriHost = uri.Host; + var segmentSeparatorIndex = uriHost.IndexOf('.'); + string host; + if (uriHost.StartsWith('_') && segmentSeparatorIndex > 1 && uriHost[^1] != '.') + { + // Skip the endpoint name, including its prefix ('_') and suffix ('.'). + host = uriHost[(segmentSeparatorIndex + 1)..]; + } + else + { + host = uriHost; + } + + var port = uri.Port > 0 ? uri.Port : 0; + if (IPAddress.TryParse(host, out var ip)) + { + endPoint = new IPEndPoint(ip, port); + } + else if (!string.IsNullOrEmpty(host)) + { + endPoint = new DnsEndPoint(host, port); + } + else + { + endPoint = null; + return false; + } + + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/README.md new file mode 100644 index 00000000000..b767bb41e83 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/README.md @@ -0,0 +1,276 @@ +# Microsoft.Extensions.ServiceDiscovery + +The `Microsoft.Extensions.ServiceDiscovery` library is designed to simplify the integration of service discovery patterns in .NET applications. Service discovery is a key component of most distributed systems and microservices architectures. This library provides a straightforward way to resolve service names to endpoint addresses. + +In typical systems, service configuration changes over time. Service discovery accounts for by monitoring endpoint configuration using push-based notifications where supported, falling back to polling in other cases. When endpoints are refreshed, callers are notified so that they can observe the refreshed results. + +## How it works + +Service discovery uses configured _providers_ to resolve service endpoints. When service endpoints are resolved, each registered provider is called in the order of registration to contribute to a collection of service endpoints (an instance of `ServiceEndpointSource`). + +Providers implement the `IServiceEndpointProvider` interface. They are created by an instance of `IServiceEndpointProviderProvider`, which are registered with the [.NET dependency injection](https://learn.microsoft.com/dotnet/core/extensions/dependency-injection) system. + +Developers typically add service discovery to their [`HttpClient`](https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient) using the [`IHttpClientFactory`](https://learn.microsoft.com/dotnet/core/extensions/httpclient-factory) with the `AddServiceDiscovery` extension method. + +Services can be resolved directly by calling `ServiceEndpointResolver`'s `GetEndpointsAsync` method, which returns a collection of resolved endpoints. + +### Change notifications + +Service configuration can change over time. Service discovery accounts for by monitoring endpoint configuration using push-based notifications where supported, falling back to polling in other cases. When endpoints are refreshed, callers are notified so that they can observe the refreshed results. To subscribe to notifications, callers use the `ChangeToken` property of `ServiceEndpointCollection`. For more information on change tokens, see [Detect changes with change tokens in ASP.NET Core](https://learn.microsoft.com/aspnet/core/fundamentals/change-tokens?view=aspnetcore-7.0). + +### Extensibility using features + +Service endpoints (`ServiceEndpoint` instances) and collections of service endpoints (`ServiceEndpointCollection` instances) expose an extensible [`IFeatureCollection`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.features.ifeaturecollection) via their `Features` property. Features are exposed as interfaces accessible on the feature collection. These interfaces can be added, modified, wrapped, replaced or even removed at resolution time by providers. Features which may be available on a `ServiceEndpoint` include: + +* `IHostNameFeature`: exposes the host name of the resolved endpoint, intended for use with [Server Name Identification (SNI)](https://en.wikipedia.org/wiki/Server_Name_Indication) and [Transport Layer Security (TLS)](https://en.wikipedia.org/wiki/Transport_Layer_Security). + +### Resolution order + +The providers included in the `Microsoft.Extensions.ServiceDiscovery` series of packages skip resolution if there are existing endpoints in the collection when they are called. For example, consider a case where the following providers are registered: _Configuration_, _DNS SRV_, _Pass-through_. When resolution occurs, the providers will be called in-order. If the _Configuration_ providers discovers no endpoints, the _DNS SRV_ provider will perform resolution and may add one or more endpoints. If the _DNS SRV_ provider adds an endpoint to the collection, the _Pass-through_ provider will skip its resolution and will return immediately instead. + +## Getting Started + +### Installation + +To install the library, use the following NuGet command: + +```dotnetcli +dotnet add package Microsoft.Extensions.ServiceDiscovery +``` + +### Usage example + +In the _AppHost.cs_ file of your project, call the `AddServiceDiscovery` extension method to add service discovery to the host, configuring default service endpoint providers. + +```csharp +builder.Services.AddServiceDiscovery(); +``` + +Add service discovery to an individual `IHttpClientBuilder` by calling the `AddServiceDiscovery` extension method: + +```csharp +builder.Services.AddHttpClient(c => +{ + c.BaseAddress = new("https://catalog")); +}).AddServiceDiscovery(); +``` + +Alternatively, you can add service discovery to all `HttpClient` instances by default: + +```csharp +builder.Services.ConfigureHttpClientDefaults(http => +{ + // Turn on service discovery by default + http.AddServiceDiscovery(); +}); +``` + +### Resolving service endpoints from configuration + +The `AddServiceDiscovery` extension method adds a configuration-based endpoint provider by default. +This provider reads endpoints from the [.NET Configuration system](https://learn.microsoft.com/dotnet/core/extensions/configuration). +The library supports configuration through `appsettings.json`, environment variables, or any other `IConfiguration` source. + +Here is an example demonstrating how to configure endpoints for the service named _catalog_ via `appsettings.json`: + +```json +{ + "Services": { + "catalog": { + "https": [ + "https://localhost:8443", + "https://10.46.24.90:443" + ] + } + } +} +``` + +The above example adds two endpoints for the service named _catalog_: `https://localhost:8443`, and `"https://10.46.24.90:443"`. +Each time the _catalog_ is resolved, one of these endpoints will be selected. + +If service discovery was added to the host using the `AddServiceDiscoveryCore` extension method on `IServiceCollection`, the configuration-based endpoint provider can be added by calling the `AddConfigurationServiceEndpointProvider` extension method on `IServiceCollection`. + +### Configuration + +The configuration provider is configured using the `ConfigurationServiceEndpointProviderOptions` class, which offers these configuration options: + +* **`SectionName`**: The name of the configuration section that contains service endpoints. It defaults to `"Services"`. + +* **`ShouldApplyHostNameMetadata`**: A delegate used to determine if host name metadata should be applied to resolved endpoints. It defaults to a function that returns `false`. + +To configure these options, you can use the `Configure` extension method on the `IServiceCollection` within your application's startup class or main program file: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(options => +{ + options.SectionName = "MyServiceEndpoints"; + + // Configure the logic for applying host name metadata + options.ShouldApplyHostNameMetadata = endpoint => + { + // Your custom logic here. For example: + return endpoint.Endpoint is DnsEndPoint dnsEp && dnsEp.Host.StartsWith("internal"); + }; +}); +``` + +This example demonstrates setting a custom section name for your service endpoints and providing a custom logic for applying host name metadata based on a condition. + +## Scheme selection when resolving HTTP(S) endpoints + +It is common to use HTTP while developing and testing a service locally and HTTPS when the service is deployed. Service Discovery supports this by allowing for a priority list of URI schemes to be specified in the input string given to Service Discovery. Service Discovery will attempt to resolve the services for the schemes in order and will stop after an endpoint is found. URI schemes are separated by a `+` character, for example: `"https+http://basket"`. Service Discovery will first try to find HTTPS endpoints for the `"basket"` service and will then fall back to HTTP endpoints. If any HTTPS endpoint is found, Service Discovery will not include HTTP endpoints. +Schemes can be filtered by configuring the `AllowedSchemes` and `AllowAllSchemes` properties on `ServiceDiscoveryOptions`. The `AllowAllSchemes` property is used to indicate that all schemes are allowed. By default, `AllowAllSchemes` is `true` and all schemes are allowed. Schemes can be restricted by setting `AllowAllSchemes` to `false` and adding allowed schemes to the `AllowedSchemes` property. For example, to allow only HTTPS: + +```csharp +services.Configure(options => +{ + options.AllowAllSchemes = false; + options.AllowedSchemes = ["https"]; +}); +``` + +To explicitly allow all schemes, set the `ServiceDiscoveryOptions.AllowAllSchemes` property to `true`: + +```csharp +services.Configure(options => options.AllowAllSchemes = true); +``` + +## Resolving service endpoints using platform-provided service discovery + +Some platforms, such as Azure Container Apps and Kubernetes (if configured), provide functionality for service discovery without the need for a service discovery client library. When an application is deployed to one of these environments, it may be preferable to use the platform's existing functionality instead. The pass-through provider exists to support this scenario while still allowing other provider (such as configuration) to be used in other environments, such as on the developer's machine, without requiring a code change or conditional guards. + +The pass-through provider performs no external resolution and instead resolves endpoints by returning the input service name represented as a `DnsEndPoint`. + +The pass-through provider is configured by-default when adding service discovery via the `AddServiceDiscovery` extension method. + +If service discovery was added to the host using the `AddServiceDiscoveryCore` extension method on `IServiceCollection`, the pass-through provider can be added by calling the `AddPassThroughServiceEndpointProvider` extension method on `IServiceCollection`. + +In the case of Azure Container Apps, the service name should match the app name. For example, if you have a service named "basket", then you should have a corresponding Azure Container App named "basket". + +## Service discovery in .NET Aspire + +.NET Aspire includes functionality for configuring the service discovery at development and testing time. This functionality works by providing configuration in the format expected by the _configuration-based endpoint provider_ described above from the .NET Aspire AppHost project to the individual service projects added to the application model. + +Configuration for service discovery is only added for services which are referenced by a given project. For example, consider the following AppHost program: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var catalog = builder.AddProject("catalog"); +var basket = builder.AddProject("basket"); + +var frontend = builder.AddProject("frontend") + .WithReference(basket) + .WithReference(catalog); +``` + +In the above example, the _frontend_ project references the _catalog_ project and the _basket_ project. The two `WithReference` calls instruct the .NET Aspire application to pass service discovery information for the referenced projects (_catalog_, and _basket_) into the _frontend_ project. + +## Named endpoints + +Some services expose multiple, named endpoints. Named endpoints can be resolved by specifying the endpoint name in the host portion of the HTTP request URI, following the format `scheme://_endpointName.serviceName`. For example, if a service named "basket" exposes an endpoint named "dashboard", then the URI `https+http://_dashboard.basket` can be used to specify this endpoint, for example: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https+http://basket")); +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https+http://_dashboard.basket")); +``` + +In the above example, two `HttpClient`s are added: one for the core basket service and one for the basket service's dashboard. + +### Named endpoints using configuration + +With the configuration-based endpoint provider, named endpoints can be specified in configuration by prefixing the endpoint value with `_endpointName.`, where `endpointName` is the endpoint name. For example, consider this _appsettings.json_ configuration which defined a default endpoint (with no name) and an endpoint named "dashboard": + +```json +{ + "Services": { + "basket": { + "https": "https://10.2.3.4:8080", /* the https endpoint, requested via https://basket */ + "dashboard": "https://10.2.3.4:9999" /* the "dashboard" endpoint, requested via https://_dashboard.basket */ + } + } +} +``` + +### Named endpoints in .NET Aspire + +.NET Aspire uses the configuration-based provider at development and testing time, providing convenient APIs for configuring named endpoints which are then translated into configuration for the target services. For example: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var basket = builder.AddProject("basket") + .WithEndpoint(hostPort: 9999, scheme: "https", name: "admin"); + +var adminDashboard = builder.AddProject("admin-dashboard") + .WithReference(basket.GetEndpoint("admin")); + +var frontend = builder.AddProject("frontend") + .WithReference(basket); +``` + +In the above example, the "basket" service exposes an "admin" endpoint in addition to the default "http" endpoint which it exposes. This endpoint is consumed by the "admin-dashboard" project, while the "frontend" project consumes all endpoints from "basket". Alternatively, the "frontend" project could be made to consume only the default "http" endpoint from "basket" by using the `GetEndpoint(string name)` method, as in the following example: + +```csharp + +// The preceding code is the same as in the above sample + +var frontend = builder.AddProject("frontend") + .WithReference(basket.GetEndpoint("https")); +``` + +### Named endpoints in Kubernetes using DNS SRV + +When deploying to Kubernetes, the DNS SRV service endpoint provider can be used to resolve named endpoints. For example, the following resource definition will result in a DNS SRV record being created for an endpoint named "default" and an endpoint named "dashboard", both on the service named "basket". + +```yml +apiVersion: v1 +kind: Service +metadata: + name: basket +spec: + selector: + name: basket-service + clusterIP: None + ports: + - name: default + port: 8080 + - name: dashboard + port: 8888 +``` + +To configure a service to resolve the "dashboard" endpoint on the "basket" service, add the DNS SRV service endpoint provider to the host builder as follows: + +```csharp +builder.Services.AddServiceDiscoveryCore(); +builder.Services.AddDnsSrvServiceEndpointProvider(); +``` + +The special port name "default" is used to specify the default endpoint, resolved using the URI `https://basket`. + +As in the previous example, add service discovery to an `HttpClient` for the basket service: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https://basket")); +``` + +Similarly, the "dashboard" endpoint can be targeted as follows: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https://_dashboard.basket")); +``` + +### Named endpoints in Azure Container Apps + +Named endpoints are not currently supported for services deployed to Azure Container Apps. + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs new file mode 100644 index 00000000000..d2890ae8c8d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery; +using Microsoft.Extensions.ServiceDiscovery.Http; + +#if NET +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +#endif + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for configuring with service discovery. +/// +public static class ServiceDiscoveryHttpClientBuilderExtensions +{ + /// + /// Adds service discovery to the . + /// + /// The builder. + /// The builder. + public static IHttpClientBuilder AddServiceDiscovery(this IHttpClientBuilder httpClientBuilder) + { + ArgumentNullException.ThrowIfNull(httpClientBuilder); + + var services = httpClientBuilder.Services; + services.AddServiceDiscoveryCore(); + httpClientBuilder.AddHttpMessageHandler(services => + { + var timeProvider = services.GetService() ?? TimeProvider.System; + var watcherFactory = services.GetRequiredService(); + var registry = new HttpServiceEndpointResolver(watcherFactory, services, timeProvider); + var options = services.GetRequiredService>(); + return new ResolvingHttpDelegatingHandler(registry, options); + }); + +#if NET + // Configure the HttpClient to disable gRPC load balancing. + // This is done on all HttpClient instances but only impacts gRPC clients. + AddDisableGrpcLoadBalancingFilter(httpClientBuilder.Services, httpClientBuilder.Name); +#endif + return httpClientBuilder; + } + +#if NET + private static void AddDisableGrpcLoadBalancingFilter(IServiceCollection services, string? name) + { + // A filter is used because it will always run last. This is important because the disable + // property needs to be added to all SocketsHttpHandler instances, including those specified + // with ConfigurePrimaryHttpMessageHandler. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.Configure(o => o.ClientNames.Add(name)); + } + + private sealed class DisableGrpcLoadBalancingFilterOptions + { + // Names of clients. A null value means it is applied globally to all clients. + public HashSet ClientNames { get; } = new HashSet(); + } + + private sealed class DisableGrpcLoadBalancingFilter : IHttpMessageHandlerBuilderFilter + { + private readonly DisableGrpcLoadBalancingFilterOptions _options; + private readonly bool _global; + + public DisableGrpcLoadBalancingFilter(IOptions options) + { + _options = options.Value; + _global = _options.ClientNames.Contains(null); + } + + public Action Configure(Action next) + { + return (builder) => + { + // Run other configuration first, we want to decorate. + next(builder); + if (_global || _options.ClientNames.Contains(builder.Name)) + { + if (builder.PrimaryHandler is SocketsHttpHandler socketsHttpHandler) + { + // gRPC knows about this property and uses it to check whether + // load balancing is disabled when the GrpcChannel is created. + // see https://github.com/grpc/grpc-dotnet/blob/1625f8942791c82d700802fc7278c543025f0fd3/src/Grpc.Net.Client/GrpcChannel.cs#L286 + socketsHttpHandler.Properties["__GrpcLoadBalancingDisabled"] = true; + } + } + }; + } + } +#endif +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryOptions.cs new file mode 100644 index 00000000000..a6fd7123aa7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryOptions.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Options for service endpoint resolution. +/// +public sealed class ServiceDiscoveryOptions +{ + /// + /// Gets or sets a value indicating whether all URI schemes for URIs resolved by the service discovery system are allowed. + /// If this value is , all URI schemes are allowed. + /// If this value is , only the schemes specified in are allowed. + /// + public bool AllowAllSchemes { get; set; } = true; + + /// + /// Gets or sets the period between polling attempts for providers which do not support refresh notifications via . + /// + public TimeSpan RefreshPeriod { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Gets or sets the collection of allowed URI schemes for URIs resolved by the service discovery system when multiple schemes are specified, for example "https+http://_endpoint.service". + /// + /// + /// When is , this property is ignored. + /// + public IList AllowedSchemes { get; set; } = new List(); + + /// + /// Filters the specified URI schemes to include only those that are applicable, based on the current settings. + /// + /// The URI schemes to be evaluated against the allowed schemes. + /// + /// The URI schemes that are applicable. If no schemes are requested, all allowed schemes are returned. + /// If all schemes are allowed, only the requested schemes are returned. + /// Otherwise, the intersection of requested and allowed schemes is returned. + /// + public IReadOnlyList ApplyAllowedSchemes(IReadOnlyList requestedSchemes) + { + ArgumentNullException.ThrowIfNull(requestedSchemes); + + if (requestedSchemes.Count > 0) + { + if (AllowAllSchemes) + { + return requestedSchemes; + } + + List result = []; + foreach (var s in requestedSchemes) + { + foreach (var allowed in AllowedSchemes) + { + if (string.Equals(s, allowed, StringComparison.OrdinalIgnoreCase)) + { + result.Add(s); + break; + } + } + } + + return result.AsReadOnly(); + } + + // If no schemes were specified, but a set of allowed schemes were specified, allow those. + return new ReadOnlyCollection(AllowedSchemes); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs new file mode 100644 index 00000000000..8de759af1f6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery; +using Microsoft.Extensions.ServiceDiscovery.Configuration; +using Microsoft.Extensions.ServiceDiscovery.Http; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Microsoft.Extensions.ServiceDiscovery.LoadBalancing; +using Microsoft.Extensions.ServiceDiscovery.PassThrough; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for configuring service discovery. +/// +public static class ServiceDiscoveryServiceCollectionExtensions +{ + /// + /// Adds the core service discovery services and configures defaults. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddServiceDiscovery(this IServiceCollection services) + => AddServiceDiscoveryCore(services) + .AddConfigurationServiceEndpointProvider() + .AddPassThroughServiceEndpointProvider(); + + /// + /// Adds the core service discovery services and configures defaults. + /// + /// The service collection. + /// The delegate used to configure service discovery options. + /// The service collection. + public static IServiceCollection AddServiceDiscovery(this IServiceCollection services, Action configureOptions) + => AddServiceDiscoveryCore(services, configureOptions: configureOptions) + .AddConfigurationServiceEndpointProvider() + .AddPassThroughServiceEndpointProvider(); + + /// + /// Adds the core service discovery services. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddServiceDiscoveryCore(this IServiceCollection services) => AddServiceDiscoveryCore(services, configureOptions: _ => { }); + + /// + /// Adds the core service discovery services. + /// + /// The service collection. + /// The delegate used to configure service discovery options. + /// The service collection. + public static IServiceCollection AddServiceDiscoveryCore(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddOptions(); + services.AddLogging(); + services.TryAddTransient, ServiceDiscoveryOptionsValidator>(); + services.TryAddSingleton(_ => TimeProvider.System); + services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => new ServiceEndpointResolver(sp.GetRequiredService(), sp.GetRequiredService())); + if (configureOptions is not null) + { + services.Configure(configureOptions); + } + + return services; + } + + /// + /// Configures a service discovery endpoint provider which uses to resolve endpoints. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddConfigurationServiceEndpointProvider(this IServiceCollection services) + => AddConfigurationServiceEndpointProvider(services, configureOptions: _ => { }); + + /// + /// Configures a service discovery endpoint provider which uses to resolve endpoints. + /// + /// The delegate used to configure the provider. + /// The service collection. + /// The service collection. + public static IServiceCollection AddConfigurationServiceEndpointProvider(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddServiceDiscoveryCore(); + services.AddSingleton(); + services.AddTransient, ConfigurationServiceEndpointProviderOptionsValidator>(); + if (configureOptions is not null) + { + services.Configure(configureOptions); + } + + return services; + } + + /// + /// Configures a service discovery endpoint provider which passes through the input without performing resolution. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddPassThroughServiceEndpointProvider(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddServiceDiscoveryCore(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointBuilder.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointBuilder.cs new file mode 100644 index 00000000000..947f24b2f81 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointBuilder.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// A mutable collection of service endpoints. +/// +internal sealed class ServiceEndpointBuilder : IServiceEndpointBuilder +{ + private readonly List _endpoints = new(); + private readonly List _changeTokens = new(); + private readonly FeatureCollection _features = new FeatureCollection(); + + /// + /// Adds a change token. + /// + /// The change token. + public void AddChangeToken(IChangeToken changeToken) + { + _changeTokens.Add(changeToken); + } + + /// + /// Gets the feature collection. + /// + public IFeatureCollection Features => _features; + + /// + /// Gets the endpoints. + /// + public IList Endpoints => _endpoints; + + /// + /// Creates a from the provided instance. + /// + /// The service endpoint source. + public ServiceEndpointSource Build() + { + return new ServiceEndpointSource(_endpoints, new CompositeChangeToken(_changeTokens), _features); + } +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointResolver.cs new file mode 100644 index 00000000000..e928980700c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointResolver.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Resolves service names to collections of endpoints. +/// +public sealed class ServiceEndpointResolver : IAsyncDisposable +{ + private static readonly TimerCallback s_cleanupCallback = s => ((ServiceEndpointResolver)s!).CleanupResolvers(); + private static readonly TimeSpan s_cleanupPeriod = TimeSpan.FromSeconds(10); + + private readonly object _lock = new(); + private readonly ServiceEndpointWatcherFactory _watcherFactory; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _resolvers = new(); + private ITimer? _cleanupTimer; + private Task? _cleanupTask; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The watcher factory. + /// The time provider. + internal ServiceEndpointResolver(ServiceEndpointWatcherFactory watcherFactory, TimeProvider timeProvider) + { + _watcherFactory = watcherFactory; + _timeProvider = timeProvider; + } + + /// + /// Resolves and returns service endpoints for the specified service. + /// + /// The service name. + /// A . + /// The resolved service endpoints. + public async ValueTask GetEndpointsAsync(string serviceName, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(serviceName); + ObjectDisposedException.ThrowIf(_disposed, this); + + EnsureCleanupTimerStarted(); + + while (true) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + var resolver = _resolvers.GetOrAdd( + serviceName, + static (name, self) => self.CreateResolver(name), + this); + + var (valid, result) = await resolver.GetEndpointsAsync(cancellationToken).ConfigureAwait(false); + if (valid) + { + if (result is null) + { + throw new InvalidOperationException($"Unable to resolve endpoints for service {resolver.ServiceName}"); + } + + return result; + } + else + { + _resolvers.TryRemove(KeyValuePair.Create(resolver.ServiceName, resolver)); + } + } + } + + private void EnsureCleanupTimerStarted() + { + if (_cleanupTimer is not null) + { + return; + } + + lock (_lock) + { + if (_cleanupTimer is not null) + { + return; + } + + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + var restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _cleanupTimer = _timeProvider.CreateTimer(s_cleanupCallback, this, s_cleanupPeriod, s_cleanupPeriod); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } + + /// + public async ValueTask DisposeAsync() + { + lock (_lock) + { + _disposed = true; + _cleanupTimer?.Dispose(); + _cleanupTimer = null; + } + + foreach (var resolver in _resolvers) + { + await resolver.Value.DisposeAsync().ConfigureAwait(false); + } + + _resolvers.Clear(); + if (_cleanupTask is not null) + { + await _cleanupTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + private void CleanupResolvers() + { + lock (_lock) + { + if (_cleanupTask is null or { IsCompleted: true }) + { + _cleanupTask = CleanupResolversAsyncCore(); + } + } + } + + private async Task CleanupResolversAsyncCore() + { + List? cleanupTasks = null; + foreach (var (name, resolver) in _resolvers) + { + if (resolver.CanExpire() && _resolvers.TryRemove(name, out var _)) + { + cleanupTasks ??= new(); + cleanupTasks.Add(resolver.DisposeAsync().AsTask()); + } + } + + if (cleanupTasks is not null) + { + await Task.WhenAll(cleanupTasks).ConfigureAwait(false); + } + } + + private ResolverEntry CreateResolver(string serviceName) + { + var resolver = _watcherFactory.CreateWatcher(serviceName); + resolver.Start(); + return new ResolverEntry(resolver); + } + + private sealed class ResolverEntry(ServiceEndpointWatcher watcher) : IAsyncDisposable + { + private readonly ServiceEndpointWatcher _watcher = watcher; + private const ulong CountMask = ~(RecentUseFlag | DisposingFlag); + private const ulong RecentUseFlag = 1UL << 62; + private const ulong DisposingFlag = 1UL << 63; + private ulong _status; + private TaskCompletionSource? _onDisposed; + + public string ServiceName => _watcher.ServiceName; + + public bool CanExpire() + { + // Read the status, clearing the recent use flag in the process. + var status = Interlocked.And(ref _status, ~RecentUseFlag); + + // The instance can be expired if there are no concurrent callers and the recent use flag was not set. + return (status & (CountMask | RecentUseFlag)) == 0; + } + + public async ValueTask<(bool Valid, ServiceEndpointSource? Endpoints)> GetEndpointsAsync(CancellationToken cancellationToken) + { + try + { + var status = Interlocked.Increment(ref _status); + if ((status & DisposingFlag) == 0) + { + // If the watcher is valid, resolve. + // We ensure that it will not be disposed while we are resolving. + var endpoints = await _watcher.GetEndpointsAsync(cancellationToken).ConfigureAwait(false); + return (true, endpoints); + } + else + { + return (false, default); + } + } + finally + { + // Set the recent use flag to prevent the instance from being disposed. + Interlocked.Or(ref _status, RecentUseFlag); + + // If we are the last concurrent request to complete and the Disposing flag has been set, + // dispose the resolver now. DisposeAsync was prevented by concurrent requests. + var status = Interlocked.Decrement(ref _status); + if ((status & DisposingFlag) == DisposingFlag && (status & CountMask) == 0) + { + await DisposeAsyncCore().ConfigureAwait(false); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_onDisposed is null) + { + Interlocked.CompareExchange(ref _onDisposed, new(TaskCreationOptions.RunContinuationsAsynchronously), null); + } + + var status = Interlocked.Or(ref _status, DisposingFlag); + if ((status & DisposingFlag) != DisposingFlag && (status & CountMask) == 0) + { + // If we are the one who flipped the Disposing flag and there are no concurrent requests, + // dispose the instance now. Concurrent requests are prevented from starting by the Disposing flag. + await DisposeAsyncCore().ConfigureAwait(false); + } + else + { + await _onDisposed.Task.ConfigureAwait(false); + } + } + + private async Task DisposeAsyncCore() + { + try + { + await _watcher.DisposeAsync().ConfigureAwait(false); + } + finally + { + Debug.Assert(_onDisposed is not null); + _onDisposed.SetResult(); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.Log.cs new file mode 100644 index 00000000000..8acaa55ee73 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.Log.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery; + +partial class ServiceEndpointWatcher +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Resolving endpoints for service '{ServiceName}'.", EventName = "ResolvingEndpoints")] + public static partial void ResolvingEndpoints(ILogger logger, string serviceName); + + [LoggerMessage(2, LogLevel.Debug, "Endpoint resolution is pending for service '{ServiceName}'.", EventName = "ResolutionPending")] + public static partial void ResolutionPending(ILogger logger, string serviceName); + + [LoggerMessage(3, LogLevel.Debug, "Resolved {Count} endpoints for service '{ServiceName}': {Endpoints}.", EventName = "ResolutionSucceeded")] + public static partial void ResolutionSucceededCore(ILogger logger, int count, string serviceName, string endpoints); + + public static void ResolutionSucceeded(ILogger logger, string serviceName, ServiceEndpointSource endpointSource) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ResolutionSucceededCore(logger, endpointSource.Endpoints.Count, serviceName, string.Join(", ", endpointSource.Endpoints.Select(GetEndpointString))); + } + + static string GetEndpointString(ServiceEndpoint ep) + { + if (ep.Features.Get() is { } provider) + { + return $"{ep} ({provider})"; + } + + return ep.ToString()!; + } + } + + [LoggerMessage(4, LogLevel.Error, "Error resolving endpoints for service '{ServiceName}'.", EventName = "ResolutionFailed")] + public static partial void ResolutionFailed(ILogger logger, Exception exception, string serviceName); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.cs new file mode 100644 index 00000000000..a94b7b7a3c1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.cs @@ -0,0 +1,302 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Internal; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Watches for updates to the collection of resolved endpoints for a specified service. +/// +internal sealed partial class ServiceEndpointWatcher( + IServiceEndpointProvider[] providers, + ILogger logger, + string serviceName, + TimeProvider timeProvider, + IOptions options) : IAsyncDisposable +{ + private static readonly TimerCallback s_pollingAction = static state => _ = ((ServiceEndpointWatcher)state!).RefreshAsync(force: true); + + private readonly object _lock = new(); + private readonly ILogger _logger = logger; + private readonly TimeProvider _timeProvider = timeProvider; + private readonly ServiceDiscoveryOptions _options = options.Value; + private readonly IServiceEndpointProvider[] _providers = providers; + private readonly CancellationTokenSource _disposalCancellation = new(); + private ITimer? _pollingTimer; + private ServiceEndpointSource? _cachedEndpoints; + private Task _refreshTask = Task.CompletedTask; + private volatile CacheStatus _cacheState; + private IDisposable? _changeTokenRegistration; + + /// + /// Gets the service name. + /// + public string ServiceName { get; } = serviceName; + + /// + /// Gets or sets the action called when endpoints are updated. + /// + public Action? OnEndpointsUpdated { get; set; } + + /// + /// Starts the endpoint resolver. + /// + public void Start() + { + ThrowIfNoProviders(); + _ = RefreshAsync(force: false); + } + + /// + /// Returns a collection of resolved endpoints for the service. + /// + /// A . + /// A collection of resolved endpoints for the service. + public ValueTask GetEndpointsAsync(CancellationToken cancellationToken = default) + { + ThrowIfNoProviders(); + ObjectDisposedException.ThrowIf(_disposalCancellation.IsCancellationRequested, this); + cancellationToken.ThrowIfCancellationRequested(); + + // If the cache is valid, return the cached value. + if (_cachedEndpoints is { ChangeToken.HasChanged: false } cached) + { + return new ValueTask(cached); + } + + // Otherwise, ensure the cache is being refreshed + // Wait for the cache refresh to complete and return the cached value. + return GetEndpointsInternal(cancellationToken); + + async ValueTask GetEndpointsInternal(CancellationToken cancellationToken) + { + ServiceEndpointSource? result; + var disposalToken = _disposalCancellation.Token; + do + { + disposalToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); + await RefreshAsync(force: false).WaitAsync(cancellationToken).ConfigureAwait(false); + result = _cachedEndpoints; + } while (result is null); + + return result; + } + } + + // Ensures that there is a refresh operation running, if needed, and returns the task which represents the completion of the operation + private Task RefreshAsync(bool force) + { + lock (_lock) + { + // If the cache is invalid or needs invalidation, refresh the cache. + if (!_disposalCancellation.IsCancellationRequested && _refreshTask.IsCompleted && (_cacheState == CacheStatus.Invalid || _cachedEndpoints is null or { ChangeToken.HasChanged: true } || force)) + { + // Indicate that the cache is being updated and start a new refresh task. + _cacheState = CacheStatus.Refreshing; + + // Don't capture the current ExecutionContext and its AsyncLocals onto the callback. + var restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _refreshTask = RefreshAsyncInternal(); + } + finally + { + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + + return _refreshTask; + } + } + + private async Task RefreshAsyncInternal() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + var cancellationToken = _disposalCancellation.Token; + Exception? error = null; + ServiceEndpointSource? newEndpoints = null; + CacheStatus newCacheState; + try + { + lock (_lock) + { + // Dispose the existing change token registration, if any. + _changeTokenRegistration?.Dispose(); + _changeTokenRegistration = null; + } + + Log.ResolvingEndpoints(_logger, ServiceName); + var builder = new ServiceEndpointBuilder(); + foreach (var provider in _providers) + { + cancellationToken.ThrowIfCancellationRequested(); + await provider.PopulateAsync(builder, cancellationToken).ConfigureAwait(false); + } + + var endpoints = builder.Build(); + newCacheState = CacheStatus.Valid; + + lock (_lock) + { + // Check if we need to poll for updates or if we can register for change notification callbacks. + if (endpoints.ChangeToken.ActiveChangeCallbacks) + { + // Initiate a background refresh when the change token fires. + _changeTokenRegistration = endpoints.ChangeToken.RegisterChangeCallback(static state => _ = ((ServiceEndpointWatcher)state!).RefreshAsync(force: false), this); + + // Dispose the existing timer, if any, since we are reliant on change tokens for updates. + _pollingTimer?.Dispose(); + _pollingTimer = null; + } + else + { + SchedulePollingTimer(); + } + + // The cache is valid + newEndpoints = endpoints; + newCacheState = CacheStatus.Valid; + } + } + catch (Exception exception) + { + error = exception; + newCacheState = CacheStatus.Invalid; + SchedulePollingTimer(); + } + + // If there was an error, the cache must be invalid. + Debug.Assert(error is null || newCacheState is CacheStatus.Invalid); + + // To ensure coherence between the value returned by calls made to GetEndpointsAsync and value passed to the callback, + // we invalidate the cache before invoking the callback. This causes callers to wait on the refresh task + // before receiving the updated value. An alternative approach is to lock access to _cachedEndpoints, but + // that will have more overhead in the common case. + if (newCacheState is CacheStatus.Valid) + { + Interlocked.Exchange(ref _cachedEndpoints, null); + } + + if (OnEndpointsUpdated is { } callback) + { + try + { + callback(new(newEndpoints, error)); + } + catch (Exception exception) + { + _logger.LogError(exception, "Error notifying observers of updated endpoints."); + } + } + + lock (_lock) + { + if (newCacheState is CacheStatus.Valid) + { + Debug.Assert(newEndpoints is not null); + _cachedEndpoints = newEndpoints; + } + + _cacheState = newCacheState; + } + + if (error is not null) + { + Log.ResolutionFailed(_logger, error, ServiceName); + ExceptionDispatchInfo.Throw(error); + } + else if (newEndpoints is not null) + { + Log.ResolutionSucceeded(_logger, ServiceName, newEndpoints); + } + } + + private void SchedulePollingTimer() + { + lock (_lock) + { + if (_disposalCancellation.IsCancellationRequested) + { + _pollingTimer?.Dispose(); + _pollingTimer = null; + return; + } + + if (_pollingTimer is null) + { + _pollingTimer = _timeProvider.CreateTimer(s_pollingAction, this, _options.RefreshPeriod, TimeSpan.Zero); + } + else + { + _pollingTimer.Change(_options.RefreshPeriod, TimeSpan.Zero); + } + } + } + + /// + public async ValueTask DisposeAsync() + { + try + { + _disposalCancellation.Cancel(); + } + catch (Exception exception) + { + _logger.LogError(exception, "Error cancelling disposal cancellation token."); + } + + lock (_lock) + { + _changeTokenRegistration?.Dispose(); + _changeTokenRegistration = null; + + _pollingTimer?.Dispose(); + _pollingTimer = null; + } + + if (_refreshTask is { } task) + { + await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + foreach (var provider in _providers) + { + await provider.DisposeAsync().ConfigureAwait(false); + } + } + + private enum CacheStatus + { + Invalid, + Refreshing, + Valid + } + + private void ThrowIfNoProviders() + { + if (_providers.Length == 0) + { + ThrowNoProvidersConfigured(); + } + } + + [DoesNotReturn] + private static void ThrowNoProvidersConfigured() => throw new InvalidOperationException("No service endpoint providers are configured."); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.Log.cs new file mode 100644 index 00000000000..449ee6920de --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.Log.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery; + +partial class ServiceEndpointWatcherFactory +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Creating endpoint resolver for service '{ServiceName}' with {Count} providers: {Providers}.", EventName = "CreatingResolver")] + public static partial void ServiceEndpointProviderListCore(ILogger logger, string serviceName, int count, string providers); + + public static void CreatingResolver(ILogger logger, string serviceName, List providers) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ServiceEndpointProviderListCore(logger, serviceName, providers.Count, string.Join(", ", providers.Select(static r => r.ToString()))); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.cs new file mode 100644 index 00000000000..6cc7cb2cbc5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.PassThrough; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Creates service endpoint watchers. +/// +internal sealed partial class ServiceEndpointWatcherFactory( + IEnumerable providerFactories, + ILogger logger, + IOptions options, + TimeProvider timeProvider) +{ + private readonly IServiceEndpointProviderFactory[] _providerFactories = providerFactories + .Where(r => r is not PassThroughServiceEndpointProviderFactory) + .Concat(providerFactories.Where(static r => r is PassThroughServiceEndpointProviderFactory)).ToArray(); + private readonly ILogger _logger = logger; + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IOptions _options = options; + + /// + /// Creates a service endpoint watcher for the provided service name. + /// + public ServiceEndpointWatcher CreateWatcher(string serviceName) + { + ArgumentNullException.ThrowIfNull(serviceName); + + if (!ServiceEndpointQuery.TryParse(serviceName, out var query)) + { + throw new ArgumentException("The provided input was not in a valid format. It must be a valid URI.", nameof(serviceName)); + } + + List? providers = null; + foreach (var factory in _providerFactories) + { + if (factory.TryCreateProvider(query, out var provider)) + { + providers ??= []; + providers.Add(provider); + } + } + + if (providers is not { Count: > 0 }) + { + throw new InvalidOperationException($"No provider which supports the provided service name, '{serviceName}', has been configured."); + } + + Log.CreatingResolver(_logger, serviceName, providers); + return new ServiceEndpointWatcher( + providers: [.. providers], + logger: _logger, + serviceName: serviceName, + timeProvider: _timeProvider, + options: _options); + } +} diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs index 4853615c228..a05c0baca0e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs @@ -1,20 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #if NET9_0_OR_GREATER - -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Diagnostics.Buffering; /// /// Buffers logs into circular buffers and drops them after some time if not flushed. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class LogBuffer -#pragma warning restore S1694 // An abstract class should have both abstract and concrete methods { /// /// Flushes the buffer and emits all buffered logs. diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs index daef766c1f2..bed838f31e7 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs @@ -13,15 +13,15 @@ public interface IDownstreamDependencyMetadata /// /// Gets the name of the dependent service. /// - public string DependencyName { get; } + string DependencyName { get; } /// /// Gets the list of host name suffixes that can uniquely identify a host as this dependency. /// - public ISet UniqueHostNameSuffixes { get; } + ISet UniqueHostNameSuffixes { get; } /// /// Gets the list of all metadata for all routes to the dependency service. /// - public ISet RequestMetadata { get; } + ISet RequestMetadata { get; } } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs index c2d6a65cd38..d724480dd3b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Logging; @@ -14,7 +12,6 @@ namespace Microsoft.Extensions.Logging; /// /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -[Conditional("CODE_GENERATION_ATTRIBUTES")] public sealed class LogPropertiesAttribute : Attribute { /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs index 954fcdeddb3..af36d87afe0 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging; @@ -12,7 +10,6 @@ namespace Microsoft.Extensions.Logging; /// /// . [AttributeUsage(AttributeTargets.Property)] -[Conditional("CODE_GENERATION_ATTRIBUTES")] public sealed class LogPropertyIgnoreAttribute : Attribute { } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs index bb3631909dc..34d9df008ad 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs @@ -18,26 +18,11 @@ namespace Microsoft.Extensions.Logging; [EditorBrowsable(EditorBrowsableState.Never)] public static class LoggerMessageHelper { - [ThreadStatic] - private static LoggerMessageState? _state; - /// /// Gets a thread-local instance of this type. /// - public static LoggerMessageState ThreadLocalState - { - get - { - var result = _state; - if (result == null) - { - result = new(); - _state = result; - } - - return result; - } - } + [field: ThreadStatic] + public static LoggerMessageState ThreadLocalState => field ??= new(); /// /// Enumerates an enumerable into a string. diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs index dcb38190d80..17b5825ded4 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs @@ -12,7 +12,6 @@ public partial class LoggerMessageState /// /// Represents a captured tag that needs redaction. /// - [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Not for customer use and hidden from docs")] [SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Not needed")] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct ClassifiedTag diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs index 30c79dedcd4..276e4f1845d 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.ComponentModel; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Logging; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj index 08a379be0e6..a461593894e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj @@ -1,8 +1,11 @@  Microsoft.Extensions.Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 Common abstractions for high-level telemetry primitives. Telemetry + + $(NoWarn);LA0006 diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.json b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.json index 8f52b86fcd1..1ddd511257a 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.Telemetry.Abstractions, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.Telemetry.Abstractions, Version=9.7.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "readonly struct Microsoft.Extensions.Diagnostics.Latency.Checkpoint : System.IEquatable", @@ -179,6 +179,16 @@ } ] }, + { + "Type": "abstract class Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBuffer : Microsoft.Extensions.Diagnostics.Buffering.LogBuffer", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBuffer.GlobalLogBuffer();", + "Stage": "Stable" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.Diagnostics.Metrics.HistogramAttribute : System.Attribute", "Stage": "Stable", @@ -488,6 +498,24 @@ } ] }, + { + "Type": "abstract class Microsoft.Extensions.Diagnostics.Buffering.LogBuffer", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Buffering.LogBuffer.LogBuffer();", + "Stage": "Stable" + }, + { + "Member": "abstract void Microsoft.Extensions.Diagnostics.Buffering.LogBuffer.Flush();", + "Stage": "Stable" + }, + { + "Member": "abstract bool Microsoft.Extensions.Diagnostics.Buffering.LogBuffer.TryEnqueue(Microsoft.Extensions.Logging.Abstractions.IBufferedLogger bufferedLogger, in Microsoft.Extensions.Logging.Abstractions.LogEntry logEntry);", + "Stage": "Stable" + } + ] + }, { "Type": "static class Microsoft.Extensions.Logging.LoggerMessageHelper", "Stage": "Stable", @@ -600,6 +628,20 @@ } ] }, + { + "Type": "abstract class Microsoft.Extensions.Logging.LoggingSampler", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Logging.LoggingSampler.LoggingSampler();", + "Stage": "Stable" + }, + { + "Member": "abstract bool Microsoft.Extensions.Logging.LoggingSampler.ShouldSample(in Microsoft.Extensions.Logging.Abstractions.LogEntry logEntry);", + "Stage": "Stable" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.Logging.LogPropertiesAttribute : System.Attribute", "Stage": "Stable", @@ -617,6 +659,10 @@ { "Member": "bool Microsoft.Extensions.Logging.LogPropertiesAttribute.SkipNullProperties { get; set; }", "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.Logging.LogPropertiesAttribute.Transitive { get; set; }", + "Stage": "Experimental" } ] }, @@ -708,6 +754,16 @@ } ] }, + { + "Type": "abstract class Microsoft.Extensions.Diagnostics.Buffering.PerRequestLogBuffer : Microsoft.Extensions.Diagnostics.Buffering.LogBuffer", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Buffering.PerRequestLogBuffer.PerRequestLogBuffer();", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.Http.Diagnostics.RequestMetadata", "Stage": "Stable", diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs index 3dcd0b4cd12..4c363bd5583 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs @@ -1,19 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Logging; /// /// Controls the number of samples of log records collected and sent to the backend. /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public abstract class LoggingSampler -#pragma warning restore S1694 // An abstract class should have both abstract and concrete methods { /// /// Makes a sampling decision for the provided . diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBuffer.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBuffer.cs index 64bd8ffe439..5d0391e68e6 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBuffer.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBuffer.cs @@ -17,6 +17,8 @@ namespace Microsoft.Extensions.Diagnostics.Buffering; internal sealed class GlobalBuffer : IDisposable { + internal LogBufferingFilterRule[] LastKnownGoodFilterRules; + private const int MaxBatchSize = 256; private static readonly ObjectPool> _recordsToEmitListPool = PoolFactory.CreateListPoolWithCapacity(MaxBatchSize); @@ -34,7 +36,6 @@ internal sealed class GlobalBuffer : IDisposable private DateTimeOffset _lastFlushTimestamp; private int _activeBufferSize; - private LogBufferingFilterRule[] _lastKnownGoodFilterRules; private volatile bool _disposed; @@ -50,7 +51,7 @@ public GlobalBuffer( _bufferedLogger = bufferedLogger; _category = Throw.IfNullOrEmpty(category); _ruleSelector = Throw.IfNull(ruleSelector); - _lastKnownGoodFilterRules = LogBufferingFilterRuleSelector.SelectByCategory(_options.CurrentValue.Rules.ToArray(), _category); + LastKnownGoodFilterRules = LogBufferingFilterRuleSelector.SelectByCategory(_options.CurrentValue.Rules.ToArray(), _category); _optionsChangeTokenRegistration = options.OnChange(OnOptionsChanged); } @@ -83,7 +84,7 @@ public bool TryEnqueue(LogEntry logEntry) $"Unsupported type of log state detected: {typeof(TState)}, expected IReadOnlyList>"); } - if (_ruleSelector.Select(_lastKnownGoodFilterRules, logEntry.LogLevel, logEntry.EventId, attributes) is null) + if (_ruleSelector.Select(LastKnownGoodFilterRules, logEntry.LogLevel, logEntry.EventId, attributes) is null) { // buffering is not enabled for this log entry, // return false to indicate that the log entry should be logged normally. @@ -162,11 +163,11 @@ private void OnOptionsChanged(GlobalLogBufferingOptions? updatedOptions) { if (updatedOptions is null) { - _lastKnownGoodFilterRules = []; + LastKnownGoodFilterRules = []; } else { - _lastKnownGoodFilterRules = LogBufferingFilterRuleSelector.SelectByCategory(updatedOptions.Rules.ToArray(), _category); + LastKnownGoodFilterRules = LogBufferingFilterRuleSelector.SelectByCategory(updatedOptions.Rules.ToArray(), _category); } _ruleSelector.InvalidateCache(); diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBufferLoggingBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBufferLoggingBuilderExtensions.cs index bf06c546566..d97d8fa5f0a 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBufferLoggingBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalBufferLoggingBuilderExtensions.cs @@ -3,13 +3,11 @@ #if NET9_0_OR_GREATER using System; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.Buffering; using Microsoft.Extensions.Options; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging; @@ -17,7 +15,6 @@ namespace Microsoft.Extensions.Logging; /// /// Lets you register log buffering in a dependency injection container. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public static class GlobalBufferLoggingBuilderExtensions { /// @@ -38,7 +35,10 @@ public static ILoggingBuilder AddGlobalBuffer(this ILoggingBuilder builder, ICon _ = builder .Services.AddOptionsWithValidateOnStart() .Services.AddOptionsWithValidateOnStart() - .Services.AddSingleton>(new GlobalLogBufferingConfigureOptions(configuration)); + .Services.AddSingleton>( + new GlobalLogBufferingConfigureOptions(configuration)) + .AddSingleton>( + new ConfigurationChangeTokenSource(configuration)); return builder.AddGlobalBufferManager(); } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferManager.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferManager.cs index 8cc25fe3a7c..449b12580fb 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferManager.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferManager.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.Diagnostics.Buffering; internal sealed class GlobalLogBufferManager : GlobalLogBuffer { - private readonly ConcurrentDictionary _buffers = []; + internal readonly ConcurrentDictionary Buffers = []; private readonly IOptionsMonitor _options; private readonly TimeProvider _timeProvider; private readonly LogBufferingFilterRuleSelector _ruleSelector; @@ -35,7 +35,7 @@ internal GlobalLogBufferManager( public override void Flush() { - foreach (GlobalBuffer buffer in _buffers.Values) + foreach (GlobalBuffer buffer in Buffers.Values) { buffer.Flush(); } @@ -44,7 +44,7 @@ public override void Flush() public override bool TryEnqueue(IBufferedLogger bufferedLogger, in LogEntry logEntry) { string category = logEntry.Category; - GlobalBuffer buffer = _buffers.GetOrAdd(category, _ => new GlobalBuffer( + GlobalBuffer buffer = Buffers.GetOrAdd(category, _ => new GlobalBuffer( bufferedLogger, category, _ruleSelector, diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs index 89a0bf0aa84..24c05b425e4 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs @@ -5,16 +5,13 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Shared.Data.Validation; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Diagnostics.Buffering; /// /// The options for global log buffering. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public class GlobalLogBufferingOptions { private const int DefaultMaxBufferSizeInBytes = 500 * 1024 * 1024; // 500 MB. @@ -36,7 +33,7 @@ public class GlobalLogBufferingOptions /// /// /// Use this to temporarily suspend buffering after a flush, e.g. in case of an incident you may want all logs to be emitted immediately, - /// so the buffering will be suspended for the time. + /// so the buffering will be suspended for the time. /// [TimeSpan(MinimumAutoFlushDuration, MaximumAutoFlushDuration)] public TimeSpan AutoFlushDuration { get; set; } = _defaultAutoFlushDuration; @@ -60,7 +57,6 @@ public class GlobalLogBufferingOptions [Range(MinimumBufferSizeInBytes, MaximumBufferSizeInBytes)] public int MaxBufferSizeInBytes { get; set; } = DefaultMaxBufferSizeInBytes; -#pragma warning disable CA2227 // Collection properties should be read only - setter is necessary for options pattern. /// /// Gets or sets the collection of used for filtering log messages for the purpose of further buffering. /// @@ -72,7 +68,6 @@ public class GlobalLogBufferingOptions /// [Required] public IList Rules { get; set; } = []; -#pragma warning restore CA2227 } #endif diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRule.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRule.cs index 6aa0a0109fa..b6725141bfe 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRule.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRule.cs @@ -3,9 +3,7 @@ #if NET9_0_OR_GREATER using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Diagnostics.Buffering; @@ -17,7 +15,6 @@ namespace Microsoft.Extensions.Diagnostics.Buffering; /// If a log entry does not match any rule, it will be emitted normally. /// If the buffer size limit is reached, the oldest buffered log entries will be dropped (not emitted!) to make room for new ones. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public class LogBufferingFilterRule { /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs index 09e4b3c9025..a56a31e3ead 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs @@ -3,7 +3,6 @@ #if NET9_0_OR_GREATER #pragma warning disable CA1307 // Specify StringComparison for clarity -#pragma warning disable S1659 // Multiple variables should not be declared on the same line #pragma warning disable S2302 // "nameof" should be used using System; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs index 8866531804d..c049b112ec8 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.Extensions.AmbientMetadata; -using Microsoft.Extensions.Diagnostics.Enrichment; using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs index 0ee93b5e851..cfbe62c9685 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs @@ -6,7 +6,6 @@ using System.Text; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; -using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs index 6783785d838..98e9c627396 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; -using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs index 973d8ebb071..5dc269cb4ec 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Http.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs index 5831935f020..d7740bdea99 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Http.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs index 6031e1e662f..a45e79fb5f6 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs index ba2c1646760..5cee914c68a 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs @@ -16,12 +16,13 @@ internal sealed class CheckpointTracker : IResettable private readonly Registry _checkpointNames; private readonly int[] _checkpointAdded; private readonly Checkpoint[] _checkpoints; - - private long _timestamp; - private int _numCheckpoints; - public long Elapsed => TimeProvider.GetTimestamp() - _timestamp; + public long Elapsed + { + get => TimeProvider.GetTimestamp() - field; + private set; + } public long Frequency => TimeProvider.TimestampFrequency; @@ -36,7 +37,7 @@ public CheckpointTracker(Registry registry) _checkpointAdded = new int[keyCount]; _checkpoints = new Checkpoint[keyCount]; TimeProvider = TimeProvider.System; - _timestamp = TimeProvider.GetTimestamp(); + Elapsed = TimeProvider.GetTimestamp(); } /// @@ -44,7 +45,7 @@ public CheckpointTracker(Registry registry) /// public bool TryReset() { - _timestamp = TimeProvider.GetTimestamp(); + Elapsed = TimeProvider.GetTimestamp(); _numCheckpoints = 0; Array.Clear(_checkpointAdded, 0, _checkpointAdded.Length); return true; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs index 0766b074ae4..6b8c7b35e4d 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs @@ -6,7 +6,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Microsoft.Shared.Memoization; using Microsoft.Shared.Pools; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs index 0777d03c571..251d7cedc1c 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs @@ -22,8 +22,6 @@ internal sealed class LatencyContext : ILatencyContext, IResettable private readonly MeasureTracker _measureTracker; - private long _duration; - public LatencyContext(LatencyContextPool latencyContextPool) { var latencyInstrumentProvider = latencyContextPool.LatencyInstrumentProvider; @@ -36,7 +34,11 @@ public LatencyContext(LatencyContextPool latencyContextPool) public LatencyData LatencyData => IsDisposed ? default : new(_tagCollection.Tags, _checkpointTracker.Checkpoints, _measureTracker.Measures, Duration, _checkpointTracker.Frequency); - private long Duration => IsRunning ? _checkpointTracker.Elapsed : _duration; + private long Duration + { + get => IsRunning ? _checkpointTracker.Elapsed : field; + set; + } #region Checkpoints public void AddCheckpoint(CheckpointToken token) @@ -82,7 +84,7 @@ public void Freeze() if (IsRunning) { IsRunning = false; - _duration = _checkpointTracker.Elapsed; + Duration = _checkpointTracker.Elapsed; } } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs index fda3a70a52b..eb8d6da5c39 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Diagnostics.Latency; - namespace Microsoft.Extensions.Diagnostics.Latency.Internal; /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs index 7926b8fa06e..d6beea39292 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Diagnostics.Latency.Internal; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs index 46bf77f0816..3f75af17a34 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs @@ -21,7 +21,6 @@ internal sealed class LegacyTagJoiner : IReadOnlyList> _extraTags = new(TagCapacity); private IReadOnlyList>? _incomingTags; - private int _incomingTagCount; public LegacyTagJoiner() { @@ -34,7 +33,7 @@ public void Clear() { _extraTags.Clear(); _incomingTags = null; - _incomingTagCount = 0; + Count = 0; State = null; Formatter = null; } @@ -43,7 +42,7 @@ public void Clear() public void SetIncomingTags(IReadOnlyList> value) { _incomingTags = value; - _incomingTagCount = _incomingTags.Count; + Count = _incomingTags.Count; } public KeyValuePair this[int index] @@ -77,7 +76,11 @@ public void SetIncomingTags(IReadOnlyList> value) } } - public int Count => _incomingTagCount + _extraTags.Count + StaticTags!.Length; + public int Count + { + get => field + _extraTags.Count + StaticTags!.Length; + private set; + } public IEnumerator> GetEnumerator() { diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs index 87500e83deb..21cf296e62c 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs @@ -2,47 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging; -#pragma warning disable S2696 - internal sealed partial class ExtendedLogger : ILogger { - [ThreadStatic] - private static ModernTagJoiner? _modernJoiner; - - [ThreadStatic] - private static LegacyTagJoiner? _legacyJoiner; - - private static ModernTagJoiner ModernJoiner - { - get - { - var joiner = _modernJoiner; - if (joiner == null) - { - joiner = new(); - _modernJoiner = joiner; - } - - return joiner; - } - } - - private static LegacyTagJoiner LegacyJoiner - { - get - { - var joiner = _legacyJoiner; - if (joiner == null) - { - joiner = new(); - _legacyJoiner = joiner; - } + [field: ThreadStatic] + private static ModernTagJoiner ModernJoiner => field ??= new(); - return joiner; - } - } + [field: ThreadStatic] + private static LegacyTagJoiner LegacyJoiner => field ??= new(); } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs index 162923d055c..3487ffbcc56 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs @@ -12,8 +12,6 @@ namespace Microsoft.Extensions.Logging; -#pragma warning disable CA1031 - // NOTE: This implementation uses thread local storage. As a result, it will fail if formatter code, enricher code, or // redactor code calls recursively back into the logger. Don't do that. // @@ -90,9 +88,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } catch (Exception ex) { -#pragma warning disable CA1508 // Avoid dead conditional code exceptions ??= []; -#pragma warning restore CA1508 // Avoid dead conditional code exceptions.Add(ex); } } @@ -122,9 +118,7 @@ public bool IsEnabled(LogLevel logLevel) } catch (Exception ex) { -#pragma warning disable CA1508 // Avoid dead conditional code exceptions ??= []; -#pragma warning restore CA1508 // Avoid dead conditional code exceptions.Add(ex); } } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs index 44ec71b6f68..67c6c3fc4c5 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Diagnostics.Buffering; #endif using Microsoft.Extensions.Diagnostics.Enrichment; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; @@ -36,7 +37,6 @@ internal sealed class ExtendedLoggerFactory : ILoggerFactory private LoggerFilterOptions _filterOptions; private IExternalScopeProvider? _scopeProvider; -#pragma warning disable S107 // Methods should not have too many parameters public ExtendedLoggerFactory( IEnumerable providers, IEnumerable enrichers, @@ -53,7 +53,6 @@ public ExtendedLoggerFactory( #else IRedactorProvider? redactorProvider = null) #endif -#pragma warning restore S107 // Methods should not have too many parameters { _scopeProvider = scopeProvider; #if NET9_0_OR_GREATER @@ -129,9 +128,7 @@ public void Dispose() registration.Provider.Dispose(); } } -#pragma warning disable CA1031 catch -#pragma warning restore CA1031 { // Swallow exceptions on dispose } @@ -223,13 +220,20 @@ private void AddProviderRegistration(ILoggerProvider provider, bool dispose) private LoggerInformation[] CreateLoggers(string categoryName) { - var loggers = new LoggerInformation[_providerRegistrations.Count]; + var loggers = new List(_providerRegistrations.Count); for (int i = 0; i < _providerRegistrations.Count; i++) { - loggers[i] = new LoggerInformation(_providerRegistrations[i].Provider, categoryName); + var loggerInformation = new LoggerInformation(_providerRegistrations[i].Provider, categoryName); + + // We do not need to check for NullLogger.Instance as no provider would reasonably return it (the handling is at + // outer loggers level, not inner level loggers in Logger/LoggerProvider). + if (loggerInformation.Logger != NullLogger.Instance) + { + loggers.Add(loggerInformation); + } } - return loggers; + return loggers.ToArray(); } private (MessageLogger[] messageLoggers, ScopeLogger[] scopeLoggers) ApplyFilters(LoggerInformation[] loggers) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs index 7b13b12c7df..3ab9ee81a26 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs @@ -6,13 +6,10 @@ #pragma warning disable SA1210 // Using directives should be ordered alphabetically by namespace #pragma warning disable IDE0021 // Use block body for constructor #pragma warning disable IDE1006 // Naming Styles -#pragma warning disable SA1203 // Constants should appear before fields #pragma warning disable SA1629 // Documentation text should end with a period #pragma warning disable IDE0090 // Use 'new(...)' #pragma warning disable IDE0058 // Expression value is never used #pragma warning disable SA1505 // Opening braces should not be followed by blank line -#pragma warning disable SA1202 // Elements should be ordered by access -#pragma warning disable CA1512 // Use ArgumentOutOfRangeException throw helper #pragma warning disable CA2002 // Do not lock on objects with weak identity #pragma warning disable S2551 // Lock on a dedicated object instance instead #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -28,7 +25,6 @@ namespace Microsoft.Extensions.Logging { - /// /// Default implementation of /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs index 45338f10742..cfe9ee1482b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs @@ -4,14 +4,11 @@ // This source file was lovingly 'borrowed' from dotnet/runtime/src/libraries/Microsoft.Extensions.Logging #pragma warning disable S1128 // Unused "using" should be removed #pragma warning disable SA1649 // File name should match first type name -#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1128 // Put constructor initializers on their own line #pragma warning disable SA1127 // Generic type constraints should be on their own line #pragma warning disable CS8602 // Dereference of a possibly null reference. using System; -using System.Diagnostics; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging { diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs index 9b5ea56890a..1dfd3e9203b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs @@ -3,7 +3,6 @@ // This source file was lovingly 'borrowed' from dotnet/runtime/src/libraries/Microsoft.Extensions.Logging #pragma warning disable CA1307 // Specify StringComparison for clarity -#pragma warning disable S1659 // Multiple variables should not be declared on the same line #pragma warning disable S2302 // "nameof" should be used using System; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs index 411733e7739..15cda881716 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Reflection; namespace Microsoft.Extensions.Logging diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs index e4696e7f798..e7c02fff231 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.Logging; internal sealed class LoggerConfig { -#pragma warning disable S107 // Methods should not have too many parameters public LoggerConfig( KeyValuePair[] staticTags, Action[] enrichers, @@ -31,7 +30,6 @@ public LoggerConfig( bool addRedactionDiscriminator) #endif { -#pragma warning restore S107 // Methods should not have too many parameters StaticTags = staticTags; Enrichers = enrichers; Sampler = sampler; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj index 81d379cd381..8ff5676e349 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj @@ -1,8 +1,11 @@  Microsoft.Extensions.Diagnostics + $(NetCoreTargetFrameworks);netstandard2.0;net462 Provides canonical implementations of telemetry abstractions. Telemetry + + $(NoWarn);LA0006 diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.json b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.json index 89b08ce9676..a9e74aa0c2b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.json +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.Telemetry, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.Telemetry, Version=9.7.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "static class Microsoft.Extensions.DependencyInjection.ApplicationEnricherServiceCollectionExtensions", @@ -79,6 +79,52 @@ } ] }, + { + "Type": "static class Microsoft.Extensions.Logging.GlobalBufferLoggingBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.GlobalBufferLoggingBuilderExtensions.AddGlobalBuffer(this Microsoft.Extensions.Logging.ILoggingBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.GlobalBufferLoggingBuilderExtensions.AddGlobalBuffer(this Microsoft.Extensions.Logging.ILoggingBuilder builder, System.Action configure);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.GlobalBufferLoggingBuilderExtensions.AddGlobalBuffer(this Microsoft.Extensions.Logging.ILoggingBuilder builder, Microsoft.Extensions.Logging.LogLevel? logLevel = null);", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBufferingOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBufferingOptions.GlobalLogBufferingOptions();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.TimeSpan Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBufferingOptions.AutoFlushDuration { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBufferingOptions.MaxBufferSizeInBytes { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBufferingOptions.MaxLogRecordSizeInBytes { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.Diagnostics.Buffering.GlobalLogBufferingOptions.Rules { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "static class Microsoft.Extensions.DependencyInjection.LatencyConsoleExtensions", "Stage": "Stable", @@ -155,6 +201,38 @@ } ] }, + { + "Type": "class Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule.LogBufferingFilterRule(string? categoryName = null, Microsoft.Extensions.Logging.LogLevel? logLevel = null, int? eventId = null, string? eventName = null, System.Collections.Generic.IReadOnlyList>? attributes = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList>? Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule.Attributes { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule.CategoryName { get; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule.EventId { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule.EventName { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.Logging.LogLevel? Microsoft.Extensions.Diagnostics.Buffering.LogBufferingFilterRule.LogLevel { get; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.Logging.LoggerEnrichmentOptions", "Stage": "Stable", @@ -294,6 +372,84 @@ "Stage": "Stable" } ] + }, + { + "Type": "class Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule : Microsoft.Extensions.Diagnostics.Sampling.ILogSamplingFilterRule", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule.RandomProbabilisticSamplerFilterRule(double probability, string? categoryName = null, Microsoft.Extensions.Logging.LogLevel? logLevel = null, int? eventId = null, string? eventName = null);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule.CategoryName { get; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule.EventId { get; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule.EventName { get; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.Logging.LogLevel? Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule.LogLevel { get; }", + "Stage": "Stable" + }, + { + "Member": "double Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerFilterRule.Probability { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerOptions", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerOptions.RandomProbabilisticSamplerOptions();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList Microsoft.Extensions.Diagnostics.Sampling.RandomProbabilisticSamplerOptions.Rules { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "static class Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions", + "Stage": "Stable", + "Methods": [ + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions.AddRandomProbabilisticSampler(this Microsoft.Extensions.Logging.ILoggingBuilder builder, Microsoft.Extensions.Configuration.IConfiguration configuration);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions.AddRandomProbabilisticSampler(this Microsoft.Extensions.Logging.ILoggingBuilder builder, System.Action configure);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions.AddRandomProbabilisticSampler(this Microsoft.Extensions.Logging.ILoggingBuilder builder, double probability, Microsoft.Extensions.Logging.LogLevel? level = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions.AddSampler(this Microsoft.Extensions.Logging.ILoggingBuilder builder);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions.AddSampler(this Microsoft.Extensions.Logging.ILoggingBuilder builder, Microsoft.Extensions.Logging.LoggingSampler sampler);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.Logging.ILoggingBuilder Microsoft.Extensions.Logging.SamplingLoggerBuilderExtensions.AddTraceBasedSampler(this Microsoft.Extensions.Logging.ILoggingBuilder builder);", + "Stage": "Stable" + } + ] } ] } \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/README.md b/src/Libraries/Microsoft.Extensions.Telemetry/README.md index 07f825baadf..d9f34b42eab 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/README.md +++ b/src/Libraries/Microsoft.Extensions.Telemetry/README.md @@ -20,7 +20,7 @@ Or directly in the C# project file: ## Usage -### Logging Sampling +### Log Sampling The library provides two types of log sampling mechanisms: **Random Probabilistic Sampling** and **Trace-based Sampling**. @@ -54,8 +54,78 @@ Matches logging sampling decisions with the underlying [Distributed Tracing samp // Add trace-based sampler builder.Logging.AddTraceBasedSampler(); ``` + This comes in handy when you already use OpenTelemetry .NET Tracing and would like to see sampling decisions being consistent across both logs and their underlying [`Activity`](https://learn.microsoft.com/dotnet/core/diagnostics/distributed-tracing-concepts#sampling). +### Log Buffering + +Provides a buffering mechanism for logs, allowing you to store logs in temporary circular buffers in memory. If the buffer is full, the oldest logs will be dropped. If you want to emit the buffered logs, you can call `Flush()` on the buffer. That way, if you don't flush buffers, all buffered logs will eventually be dropped and that makes sense - if you don't flush buffers, chances are +those logs are not important. At the same time, you can trigger a flush on the buffer when certain conditions are met, such as when an exception occurs. + +This library works with all logger providers, even if they do not implement the `Microsoft.Extensions.Logging.Abstractions.IBufferedLogger` interface. In that case, the library will +be calling `ILogger.Log()` method directly on every single buffered log record when flushing the buffer. + +#### Global Buffering + +Provides application-wide log buffering with configurable rules: + +```csharp +// Simple configuration with log level +builder.Logging.AddGlobalBuffer(LogLevel.Warning); // Buffer Warning and lower level logs + +// Configuration using options +builder.Logging.AddGlobalBuffer(options => +{ + options.Rules.Add(new LogBufferingFilterRule(logLevel: LogLevel.Information)); // Buffer Information and lower level logs + options.Rules.Add(new LogBufferingFilterRule(categoryName: "Microsoft.*")); // Buffer logs from Microsoft namespaces +}); + +// Configuration using IConfiguration +builder.Logging.AddGlobalBuffer(configuration.GetSection("Logging:Buffering")); +``` + +Then, to flush the global buffer when a bad thing happens, call the `Flush()` method on the injected GlobalLogBuffer instance: + +```csharp +public class MyService +{ + private readonly GlobalLogBuffer _globalLogBuffer; + + public MyService(GlobalLogBuffer globalLogBuffer) + { + _globalLogBuffer = globalLogBuffer; + } + + public void DoSomething() + { + try + { + // ... + } + catch (Exception ex) + { + // Flush the global buffer when an exception occurs + _globalLogBuffer.Flush(); + } + } +} +``` + +The Global Log Buffer supports the `IOptionsMonitor` pattern, allowing for dynamic configuration updates. This means you can change the buffering rules at runtime without needing to restart your application. + +#### Limitations + +1. This library does not preserve the order of log records. However, original timestamps are preserved. +1. The library does not support custom configuration per each logger provider. Same configuration is applied to all logger providers. +1. Log scopes are not supported. This means that if you use `ILogger.BeginScope()` method, the buffered log records will not be associated with the scope. +1. When buffering and then flushing buffers, not all information of the original log record is preserved. This is due to serializing/deserializing limitation, but can be +revisited in future. Namely, this library uses `Microsoft.Extensions.Logging.Abstractions.BufferedLogRecord` class when converting buffered log records to actual log records, but omits following properties: + +- `Microsoft.Extensions.Logging.Abstractions.BufferedLogRecord.ActivitySpanId` +- `Microsoft.Extensions.Logging.Abstractions.BufferedLogRecord.ActivityTraceId` +- `Microsoft.Extensions.Logging.Abstractions.BufferedLogRecord.ManagedThreadId` +- `Microsoft.Extensions.Logging.Abstractions.BufferedLogRecord.MessageTemplate` + ### Service Log Enrichment Enriches logs with application-specific information based on `ApplicationMetadata` information. The bellow calls will add the service log enricher to the service collection. diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs index 724f70e07d2..3f6cc70a8af 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable CA1307 // Specify StringComparison for clarity -#pragma warning disable S1659 // Multiple variables should not be declared on the same line #pragma warning disable S2302 // "nameof" should be used using System; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs index 6ba0a376c25..d809da8a2ad 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs @@ -3,7 +3,7 @@ using System; using System.Linq; -#if !NETFRAMEWORK +#if !NETFRAMEWORK && !NETSTANDARD using System.Security.Cryptography; #endif using Microsoft.Extensions.Logging; @@ -22,7 +22,7 @@ internal sealed class RandomProbabilisticSampler : LoggingSampler, IDisposable { internal RandomProbabilisticSamplerFilterRule[] LastKnownGoodSamplerRules; -#if NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD private static readonly System.Threading.ThreadLocal _randomInstance = new(() => new Random()); #endif @@ -50,7 +50,7 @@ public override bool ShouldSample(in LogEntry logEntry) return true; } -#if NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD return _randomInstance.Value!.Next(int.MaxValue) < int.MaxValue * probability; #else return RandomNumberGenerator.GetInt32(int.MaxValue) < int.MaxValue * probability; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs index 40085e3ed72..188fb22ed60 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerFilterRule.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerFilterRule.cs index aa0454ff739..b55b34f0da5 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerFilterRule.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerFilterRule.cs @@ -2,16 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Diagnostics.Sampling; /// /// Defines a rule used to filter log messages for purposes of sampling. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public class RandomProbabilisticSamplerFilterRule : ILogSamplingFilterRule { /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs index 5c0cd89c675..c84fc1ee25e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs @@ -3,24 +3,19 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Options; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Diagnostics.Sampling; /// /// The options for the Random Probabilistic sampler. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public class RandomProbabilisticSamplerOptions { /// /// Gets or sets the collection of used for filtering log messages. /// -#pragma warning disable CA2227 // Collection properties should be read only - setter is necessary for options pattern [Required] [ValidateEnumeratedItems] public IList Rules { get; set; } = []; -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/SamplingLoggerBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/SamplingLoggerBuilderExtensions.cs index 489b4462482..dfd9ad2b997 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/SamplingLoggerBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/SamplingLoggerBuilderExtensions.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.Sampling; using Microsoft.Extensions.Options; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging; @@ -16,7 +15,6 @@ namespace Microsoft.Extensions.Logging; /// /// Extensions for configuring logging sampling. /// -[Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] public static class SamplingLoggerBuilderExtensions { /// diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs index 43b9e92b1c5..cfe601e9dfa 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs @@ -4,10 +4,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Time.Testing; @@ -21,7 +19,6 @@ public class FakeTimeProvider : TimeProvider private DateTimeOffset _now = new(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); private TimeZoneInfo _localTimeZone = TimeZoneInfo.Utc; private volatile int _wakeWaitersGate; - private TimeSpan _autoAdvanceAmount; /// /// Initializes a new instance of the class. @@ -64,11 +61,11 @@ public FakeTimeProvider(DateTimeOffset startDateTime) /// The time value is less than . public TimeSpan AutoAdvanceAmount { - get => _autoAdvanceAmount; + get; set { _ = Throw.IfLessThan(value.Ticks, 0); - _autoAdvanceAmount = value; + field = value; } } @@ -80,7 +77,7 @@ public override DateTimeOffset GetUtcNow() lock (Waiters) { result = _now; - _now += _autoAdvanceAmount; + _now += AutoAdvanceAmount; } WakeWaiters(); @@ -137,7 +134,7 @@ public void Advance(TimeSpan delta) } /// - /// Advances the date and time in the UTC time zone. + /// Sets the date and time in the UTC time zone. /// /// The date and time in the UTC time zone. /// @@ -145,7 +142,6 @@ public void Advance(TimeSpan delta) /// timers. This is similar to what happens in a real system when the system's /// time is changed. /// - [Experimental(diagnosticId: DiagnosticIds.Experiments.TimeProvider, UrlFormat = DiagnosticIds.UrlFormat)] public void AdjustTime(DateTimeOffset value) { lock (Waiters) diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj index f1987e0ad68..679d9e74854 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj @@ -9,7 +9,6 @@ Fundamentals Testing $(PackageTags);Testing;TimeProvider;FakeTimeProvider - true true diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json index b1c5d11adc2..c2417fd0d63 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.TimeProvider.Testing, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.TimeProvider.Testing, Version=9.8.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "class Microsoft.Extensions.Time.Testing.FakeTimeProvider : System.TimeProvider", @@ -13,6 +13,10 @@ "Member": "Microsoft.Extensions.Time.Testing.FakeTimeProvider.FakeTimeProvider(System.DateTimeOffset startDateTime);", "Stage": "Stable" }, + { + "Member": "void Microsoft.Extensions.Time.Testing.FakeTimeProvider.AdjustTime(System.DateTimeOffset value);", + "Stage": "Stable" + }, { "Member": "void Microsoft.Extensions.Time.Testing.FakeTimeProvider.Advance(System.TimeSpan delta);", "Stage": "Stable" diff --git a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj index aa1d5b37fbc..41e9d3b63bd 100644 --- a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj +++ b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj @@ -31,6 +31,8 @@ + + diff --git a/src/ProjectTemplates/.gitignore b/src/ProjectTemplates/.gitignore index 9262ab010fb..ceffca4abbc 100644 --- a/src/ProjectTemplates/.gitignore +++ b/src/ProjectTemplates/.gitignore @@ -9,6 +9,7 @@ package-lock.json */src/**/*.sln */src/**/NuGet.config */src/**/Directory.Build.targets +*/src/**/Directory.Build.props */src/**/ingestioncache.* # launchSettings.json files are required for the templates. diff --git a/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj b/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj index cead17cde9e..e8df485098b 100644 --- a/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj +++ b/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj @@ -18,13 +18,42 @@ IsImplicitlyDefined="true" /> + + + + + + + + <_ResolvedPackageVersionVariableReference Include="@(_ResolvedPackageVersionInfo)"> + TemplatePackageVersion_$([System.String]::Copy('%(PackageId)').Replace('.', '')) + + + + + + $(GeneratedContentProperties); + + @(_ResolvedPackageVersionVariableReference->'%(VersionVariableName)=%(PackageVersion)') + + + + + DependsOnTargets="ComputeGeneratedContentProperties;_GetPackageVersionVariables"> diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index 832d533e66a..403beadc3a0 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -10,31 +10,47 @@ <_LocalChatTemplateVariant>aspire <_ChatWithCustomDataContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\ChatWithCustomData\ + <_McpServerContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\McpServer\ + + + + + + + + + + - - 9.4.0 - 9.4.0-preview.1.25207.5 - 9.0.4 - - - false - false - - - $(TemplatePinnedRepoPackagesVersion) - $(TemplatePinnedRepoAIPackagesVersion) - $(TemplatePinnedMicrosoftEntityFrameworkCoreSqliteVersion) - - - $(Version) - $(Version) - $(MicrosoftEntityFrameworkCoreSqliteVersion) - - <_TemplateUsingJustBuiltPackages Condition="'$(TemplateRepoAIPackagesVersion)' == '$(Version)'">true + 9.5.1 + 9.5.1-preview.1.25502.11 + 1.0.0 + 1.17.0 + 11.7.0 + 9.8.1-beta.413 + 10.0.0-rc.2.25502.107 + 9.5.1 + 1.66.0 + 1.66.0-preview + 0.4.0-preview.1 + 5.4.8 + 1.13.0 + 0.1.11 + 6.0.3 + + $(GeneratedContentProperties); @@ -43,31 +59,24 @@ ArtifactsShippingPackagesDir=$(ArtifactsShippingPackagesDir); - AspireVersion=$(AspireVersion); - AspireAzureAIOpenAIVersion=$(AspireAzureAIOpenAIVersion); - AzureAIProjectsVersion=$(AzureAIProjectsVersion); - AzureAIOpenAIVersion=$(AzureAIOpenAIVersion); - AzureIdentityVersion=$(AzureIdentityVersion); - AzureSearchDocumentsVersion=$(AzureSearchDocumentsVersion); - CommunityToolkitAspireHostingOllamaVersion=$(CommunityToolkitAspireHostingOllamaVersion); - CommunityToolkitAspireHostingSqliteVersion=$(CommunityToolkitAspireHostingSqliteVersion); - CommunityToolkitAspireMicrosoftEntityFrameworkCoreSqliteVersion=$(CommunityToolkitAspireMicrosoftEntityFrameworkCoreSqliteVersion); - CommunityToolkitAspireOllamaSharpVersion=$(CommunityToolkitAspireOllamaSharpVersion); - MicrosoftEntityFrameworkCoreSqliteVersion=$(TemplateMicrosoftEntityFrameworkCoreSqliteVersion); - MicrosoftExtensionsAIVersion=$(TemplateRepoAIPackagesVersion); - MicrosoftExtensionsHttpResilienceVersion=$(TemplateRepoPackagesVersion); - MicrosoftExtensionsServiceDiscoveryVersion=$(MicrosoftExtensionsServiceDiscoveryVersion); - MicrosoftSemanticKernelConnectorsAzureAISearchVersion=$(MicrosoftSemanticKernelConnectorsAzureAISearchVersion); - MicrosoftSemanticKernelConnectorsQdrantVersion=$(MicrosoftSemanticKernelConnectorsQdrantVersion); - MicrosoftSemanticKernelCoreVersion=$(MicrosoftSemanticKernelCoreVersion); - OllamaSharpVersion=$(OllamaSharpVersion); - OpenTelemetryVersion=$(OpenTelemetryVersion); - PdfPigVersion=$(PdfPigVersion); - SystemLinqAsyncVersion=$(SystemLinqAsyncVersion); + TemplatePackageVersion_Aspire=$(TemplatePackageVersion_Aspire); + TemplatePackageVersion_Aspire_Preview=$(TemplatePackageVersion_Aspire_Preview); + TemplatePackageVersion_AzureAIProjects=$(TemplatePackageVersion_AzureAIProjects); + TemplatePackageVersion_AzureIdentity=$(TemplatePackageVersion_AzureIdentity); + TemplatePackageVersion_AzureSearchDocuments=$(TemplatePackageVersion_AzureSearchDocuments); + TemplatePackageVersion_CommunityToolkitAspire=$(TemplatePackageVersion_CommunityToolkitAspire); + TemplatePackageVersion_MicrosoftExtensionsHosting=$(TemplatePackageVersion_MicrosoftExtensionsHosting); + TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery=$(TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery); + TemplatePackageVersion_MicrosoftSemanticKernel=$(TemplatePackageVersion_MicrosoftSemanticKernel); + TemplatePackageVersion_MicrosoftSemanticKernel_Preview=$(TemplatePackageVersion_MicrosoftSemanticKernel_Preview); + TemplatePackageVersion_ModelContextProtocol=$(TemplatePackageVersion_ModelContextProtocol); + TemplatePackageVersion_OllamaSharp=$(TemplatePackageVersion_OllamaSharp); + TemplatePackageVersion_OpenTelemetry=$(TemplatePackageVersion_OpenTelemetry); + TemplatePackageVersion_PdfPig=$(TemplatePackageVersion_PdfPig); + TemplatePackageVersion_SystemLinqAsync=$(TemplatePackageVersion_SystemLinqAsync); LocalChatTemplateVariant=$(_LocalChatTemplateVariant); - UsingJustBuiltPackages=$(_TemplateUsingJustBuiltPackages); @@ -78,6 +87,9 @@ + @@ -90,18 +102,12 @@ - - - <_GeneratedContentEnablingJustBuiltPackages + + - - - diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 9997515d70c..ab5ef554a3a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -7,7 +7,7 @@ dotnet-new;templates;ai preview - 2 + 3 AI 0 0 @@ -44,7 +44,7 @@ - + + **\Directory.Build.targets; + **\Directory.Build.props;" /> + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index e895dc5db26..603ed1a2735 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/template", "author": "Microsoft", - "classifications": [ "Common", "AI", "Web", "Blazor", ".NET Aspire" ], + "classifications": [ "Common", "AI", "Web", "Blazor", "Aspire" ], "identity": "Microsoft.Extensions.AI.Templates.WebChat.CSharp", "name": "AI Chat Web App", "description": "A project template for creating an AI chat application, which uses retrieval-augmented generation (RAG) to chat with your own data.", @@ -30,7 +30,7 @@ "path": "./ChatWithCustomData-CSharp.csproj" }, { - "condition": "(IsAspire && (HostIdentifier == \"dotnetcli\" || HostIdentifier == \"dotnetcli-preview\"))", + "condition": "(IsAspire && (hostIdentifier == \"dotnetcli\" || hostIdentifier == \"dotnetcli-preview\"))", "path": "./ChatWithCustomData-CSharp.sln" }, { @@ -64,6 +64,12 @@ "*.sln" ] }, + { + "condition": "(!IsAspire || !IsOllama)", + "exclude": [ + "ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs" + ] + }, { "condition": "(IsAspire)", "exclude": [ @@ -76,7 +82,13 @@ } }, { - "condition": "(!UseLocalVectorStore)", + "condition": "(IsAspire && hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", + "exclude": [ + "*.sln" + ] + }, + { + "condition": "(!IsLocalVectorStore)", "exclude": [ "ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs" ] @@ -171,7 +183,11 @@ "displayName": "Use Aspire orchestration", "datatype": "bool", "defaultValue": "false", - "description": "Create the project as a distributed application using .NET Aspire." + "description": "Create the project as a distributed application using Aspire." + }, + "IsManagedIdentity": { + "type": "computed", + "value": "(UseManagedIdentity)" }, "IsAspire": { "type": "computed", @@ -197,21 +213,21 @@ "type": "computed", "value": "(AiServiceProvider == \"azureaifoundry\")" }, - "UseAzureAISearch": { + "IsAzureAISearch": { "type": "computed", "value": "(VectorStore == \"azureaisearch\")" }, - "UseLocalVectorStore": { + "IsLocalVectorStore": { "type": "computed", "value": "(VectorStore == \"local\")" }, - "UseQdrant": { + "IsQdrant": { "type": "computed", "value": "(VectorStore == \"qdrant\")" }, - "UseAzure": { + "IsAzure": { "type": "computed", - "value": "(IsAzureOpenAI || IsAzureAiFoundry || UseAzureAISearch)" + "value": "(IsAzureOpenAI || IsAzureAIFoundry || IsAzureAISearch)" }, "ChatModel": { "type": "parameter", @@ -491,7 +507,13 @@ "type": "derived", "valueSource": "name", "valueTransform": "vectorStoreIndexNameTransform", - "replaces": "data-ChatWithCustomData-CSharp.Web-ingestion" + "replaces": "data-ChatWithCustomData-CSharp.Web-" + }, + "aspireClassNameReplacer": { + "type": "derived", + "valueSource": "name", + "valueTransform": "aspireClassName_ReplaceInvalidChars", + "replaces": "ChatWithCustomData_CSharp_Web_AspireClassName" }, "webProjectNamespaceAdjuster": { "type": "generated", @@ -518,6 +540,12 @@ } }, "forms": { + "aspireClassName_ReplaceInvalidChars": { + "identifier": "replace", + "pattern": "(((?<=\\.)|^)(?=\\d)|\\W)", + "replacement": "_", + "description": "Insert underscore before digits at start, or after a dot, or to replace non-word characters" + }, "vectorStoreIndexNameTransform": { "identifier": "chain", "steps": [ @@ -553,12 +581,12 @@ "vectorStoreIndexName_PrefixSuffix": { "identifier": "replace", "pattern": "^(.*)$", - "replacement": "data-$1-ingested", + "replacement": "data-$1-", "description": "Produces a meaningful name parameterized by project name; ensures first, second, and last characters are valid" } }, "postActions": [{ - "condition": "(hostIdentifier != \"dotnetcli\")", + "condition": "(hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", "description": "Opens README file in the editor", "manualInstructions": [ ], "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs similarity index 57% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs index d19afaef3c3..a859ce397a1 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs @@ -1,6 +1,6 @@ var builder = DistributedApplication.CreateBuilder(args); #if (IsOllama) // ASPIRE PARAMETERS -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) // You will need to set the connection string to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: @@ -13,14 +13,27 @@ // dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" #endif var openai = builder.AddConnectionString("openai"); +#else // IsAzureOpenAI + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var openai = builder.AddAzureOpenAI("openai"); + +openai.AddDeployment( + name: "gpt-4o-mini", + modelName: "gpt-4o-mini", + modelVersion: "2024-07-18"); + +openai.AddDeployment( + name: "text-embedding-3-small", + modelName: "text-embedding-3-small", + modelVersion: "1"); #endif -#if (UseAzureAISearch) +#if (IsAzureAISearch) -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" -var azureAISearch = builder.AddConnectionString("azureAISearch"); +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var search = builder.AddAzureSearch("search"); #endif #if (IsOllama) // AI SERVICE PROVIDER CONFIGURATION @@ -29,37 +42,38 @@ var chat = ollama.AddModel("chat", "llama3.2"); var embeddings = ollama.AddModel("embeddings", "all-minilm"); #endif -#if (UseAzureAISearch) // VECTOR DATABASE CONFIGURATION -#elif (UseQdrant) +#if (IsAzureAISearch) // VECTOR DATABASE CONFIGURATION +#elif (IsQdrant) var vectorDB = builder.AddQdrant("vectordb") .WithDataVolume() .WithLifetime(ContainerLifetime.Persistent); -#else // UseLocalVectorStore +#else // IsLocalVectorStore #endif -var ingestionCache = builder.AddSqlite("ingestionCache"); - -var webApp = builder.AddProject("aichatweb-app"); +var webApp = builder.AddProject("aichatweb-app"); #if (IsOllama) // AI SERVICE PROVIDER REFERENCES webApp .WithReference(chat) .WithReference(embeddings) .WaitFor(chat) .WaitFor(embeddings); -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) webApp.WithReference(openai); +#else // IsAzureOpenAI +webApp + .WithReference(openai) + .WaitFor(openai); #endif -#if (UseAzureAISearch) // VECTOR DATABASE REFERENCES -webApp.WithReference(azureAISearch); -#elif (UseQdrant) +#if (IsAzureAISearch) // VECTOR DATABASE REFERENCES +webApp + .WithReference(search) + .WaitFor(search); +#elif (IsQdrant) webApp .WithReference(vectorDB) .WaitFor(vectorDB); -#else // UseLocalVectorStore +#else // IsLocalVectorStore #endif -webApp - .WithReference(ingestionCache) - .WaitFor(ingestionCache); builder.Build().Run(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in index fef5822aa2b..b7d8f13bdfa 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in @@ -1,24 +1,26 @@ - + Exe net9.0 enable enable - true b2f4f5e9-1083-472c-8c3b-f055ac67ba54 - - - - + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json index cff9159f816..ff3cb400c10 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json @@ -9,8 +9,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, "http": { @@ -21,8 +21,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in index 77276eab4a0..3b67ba158cd 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/ChatWithCustomData-CSharp.ServiceDefaults.csproj.in @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs index 108f1ed2a08..204f7a64164 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs @@ -10,11 +10,14 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -29,21 +32,8 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where http.RemoveAllResilienceHandlers(); #pragma warning restore EXTEXP0001 -#if (IsOllama) - // Turn on resilience by default - http.AddStandardResilienceHandler(config => - { - // Extend the HTTP Client timeout for Ollama - config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); - - // Must be at least double the AttemptTimeout to pass options validation - config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); - config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); - }); -#else // Turn on resilience by default http.AddStandardResilienceHandler(); -#endif // Turn on service discovery by default http.AddServiceDiscovery(); @@ -77,7 +67,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() @@ -124,10 +119,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in index cf6689d1738..05e9a9726de 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in @@ -9,47 +9,42 @@ - - + + - - + - - + + - - - - - - - - - - - - - + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor index 9c6a3169144..8aa0ec9fd28 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -62,30 +64,22 @@ chatSuggestions?.Clear(); await chatInput!.FocusAsync(); -@*#if (IsOllama) - // Display a new response from the IChatClient, streaming responses - // aren't supported because Ollama will not support both streaming and using Tools - currentResponseCancellation = new(); - var response = await ChatClient.GetResponseAsync(messages, chatOptions, currentResponseCancellation.Token); - - // Store responses in the conversation, and begin getting suggestions - messages.AddMessages(response); -#else*@ // Stream and display a new response from the IChatClient var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; -@*#endif*@ chatSuggestions?.Update(messages); } @@ -106,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } @@ -118,7 +114,7 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + $"{result.Text}"); } public void Dispose() diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs new file mode 100644 index 00000000000..fed9c91ca93 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatWithCustomData_CSharp.Web.Services; + +public static class OllamaResilienceHandlerExtensions +{ + public static IServiceCollection AddOllamaResilienceHandler(this IServiceCollection services) + { + services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(config => + { + // Extend the HTTP Client timeout for Ollama + config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); + + // Must be at least double the AttemptTimeout to pass options validation + config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); + config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + }); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return services; + } +} + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs index 65092881529..e7137ac6dd3 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs @@ -1,17 +1,10 @@ using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +#if (IsOpenAI || IsGHModels) +using OpenAI; +#endif using ChatWithCustomData_CSharp.Web.Components; using ChatWithCustomData_CSharp.Web.Services; using ChatWithCustomData_CSharp.Web.Services.Ingestion; -#if (IsOllama) -#else // IsAzureOpenAI || IsOpenAI || IsGHModels -using OpenAI; -#endif -#if (UseAzureAISearch) -using Microsoft.SemanticKernel.Connectors.AzureAISearch; -#elif (UseQdrant) -using Microsoft.SemanticKernel.Connectors.Qdrant; -#endif var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); @@ -25,7 +18,7 @@ c.EnableSensitiveData = builder.Environment.IsDevelopment()); builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); -#elif (IsAzureAiFoundry) +#elif (IsAzureAIFoundry) #else // (IsOpenAI || IsAzureOpenAI || IsGHModels) #if (IsOpenAI) var openai = builder.AddOpenAIClient("openai"); @@ -39,24 +32,31 @@ openai.AddEmbeddingGenerator("text-embedding-3-small"); #endif -#if (UseAzureAISearch) -builder.AddAzureSearchClient("azureAISearch"); - -builder.Services.AddSingleton(); -#elif (UseQdrant) +#if (IsAzureAISearch) +builder.AddAzureSearchClient("search"); +builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-chunks"); +builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-documents"); +#elif (IsQdrant) builder.AddQdrantClient("vectordb"); - -builder.Services.AddSingleton(); -#else // UseLocalVectorStore -var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store")); -builder.Services.AddSingleton(vectorStore); +builder.Services.AddQdrantCollection("data-ChatWithCustomData-CSharp.Web-chunks"); +builder.Services.AddQdrantCollection("data-ChatWithCustomData-CSharp.Web-documents"); +#else // IsLocalVectorStore +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteCollection("data-ChatWithCustomData-CSharp.Web-chunks", vectorStoreConnectionString); +builder.Services.AddSqliteCollection("data-ChatWithCustomData-CSharp.Web-documents", vectorStoreConnectionString); #endif builder.Services.AddScoped(); builder.Services.AddSingleton(); -builder.AddSqliteDbContext("ingestionCache"); +#if (IsOllama) +// Applies robust HTTP resilience settings for all HttpClients in the Web project, +// not across the entire solution. It's aimed at supporting Ollama scenarios due +// to its self-hosted nature and potentially slow responses. +// Remove this if you want to use the global or a different HTTP resilience policy instead. +builder.Services.AddOllamaResilienceHandler(); +#endif var app = builder.Build(); -IngestionCacheDbContext.Initialize(app.Services); app.MapDefaultEndpoints(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs index a02c911d317..3765185721a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs @@ -1,28 +1,22 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; -using ChatWithCustomData_CSharp.Web.Components; -using ChatWithCustomData_CSharp.Web.Services; -using ChatWithCustomData_CSharp.Web.Services.Ingestion; -#if(IsAzureOpenAI || UseAzureAISearch) +#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +using System.ClientModel; +#elif (IsAzureOpenAI && IsManagedIdentity) +using System.ClientModel.Primitives; +#endif +#if (IsAzureAISearch && !IsManagedIdentity) using Azure; -#if (UseManagedIdentity) +#elif (IsManagedIdentity) using Azure.Identity; #endif -#endif +using Microsoft.Extensions.AI; #if (IsOllama) using OllamaSharp; -#elif (IsOpenAI || IsGHModels) +#elif (IsGHModels || IsOpenAI || IsAzureOpenAI) using OpenAI; -using System.ClientModel; -#else -using Azure.AI.OpenAI; -using System.ClientModel; -#endif -#if (UseAzureAISearch) -using Azure.Search.Documents.Indexes; -using Microsoft.SemanticKernel.Connectors.AzureAISearch; #endif +using ChatWithCustomData_CSharp.Web.Components; +using ChatWithCustomData_CSharp.Web.Services; +using ChatWithCustomData_CSharp.Web.Services.Ingestion; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -51,62 +45,75 @@ // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set OpenAI:Key YOUR-API-KEY + var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -var chatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); + +#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning restore OPENAI001 + var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); -#elif (IsAzureAiFoundry) +#elif (IsAzureAIFoundry) -#else // IsAzureOpenAI +#elif (IsAzureOpenAI) // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set AzureOpenAI:Endpoint https://YOUR-DEPLOYMENT-NAME.openai.azure.com -#if (!UseManagedIdentity) +#if (!IsManagedIdentity) // dotnet user-secrets set AzureOpenAI:Key YOUR-API-KEY #endif -var azureOpenAi = new AzureOpenAIClient( - new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint. See the README for details.")), -#if (UseManagedIdentity) - new DefaultAzureCredential()); -#else - new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details."))); +var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint. See the README for details.")), "/openai/v1"); +#if (IsManagedIdentity) +#pragma warning disable OPENAI001 // OpenAIClient(AuthenticationPolicy, OpenAIClientOptions) and GetOpenAIResponseClient(string) are experimental and subject to change or removal in future updates. +var azureOpenAi = new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }); + +#elif (!IsManagedIdentity) +var openAIOptions = new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }; +var azureOpenAi = new OpenAIClient(new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details.")), openAIOptions); + +#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. #endif -var chatClient = azureOpenAi.GetChatClient("gpt-4o-mini").AsIChatClient(); +var chatClient = azureOpenAi.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning restore OPENAI001 + var embeddingGenerator = azureOpenAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #endif -#if (UseAzureAISearch) +#if (IsAzureAISearch) // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set AzureAISearch:Endpoint https://YOUR-DEPLOYMENT-NAME.search.windows.net -#if (!UseManagedIdentity) +#if (!IsManagedIdentity) // dotnet user-secrets set AzureAISearch:Key YOUR-API-KEY #endif -var vectorStore = new AzureAISearchVectorStore( - new SearchIndexClient( - new Uri(builder.Configuration["AzureAISearch:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Endpoint. See the README for details.")), -#if (UseManagedIdentity) - new DefaultAzureCredential())); -#else - new AzureKeyCredential(builder.Configuration["AzureAISearch:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Key. See the README for details.")))); +var azureAISearchEndpoint = new Uri(builder.Configuration["AzureAISearch:Endpoint"] + ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Endpoint. See the README for details.")); +#if (IsManagedIdentity) +var azureAISearchCredential = new DefaultAzureCredential(); +#elif (!IsManagedIdentity) +var azureAISearchCredential = new AzureKeyCredential(builder.Configuration["AzureAISearch:Key"] + ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Key. See the README for details.")); #endif -#else // UseLocalVectorStore -var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store")); +builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-chunks", azureAISearchEndpoint, azureAISearchCredential); +builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-documents", azureAISearchEndpoint, azureAISearchCredential); +#elif (IsLocalVectorStore) +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteCollection("data-ChatWithCustomData-CSharp.Web-chunks", vectorStoreConnectionString); +builder.Services.AddSqliteCollection("data-ChatWithCustomData-CSharp.Web-documents", vectorStoreConnectionString); #endif -builder.Services.AddSingleton(vectorStore); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); builder.Services.AddEmbeddingGenerator(embeddingGenerator); -builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=ingestioncache.db")); - var app = builder.Build(); -IngestionCacheDbContext.Initialize(app.Services); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md index 37f10b83ce2..88dff74d315 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md @@ -5,7 +5,7 @@ This project is an AI chat application that demonstrates how to chat with custom >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. -#### ---#if (UseAzure) +#### ---#if (IsAzure) ### Prerequisites To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). @@ -22,7 +22,7 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See #### ---#endif # Configure the AI Model Provider #### ---#if (IsGHModels) -To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). +To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information. #### ---#if (hostIdentifier == "vs") Configure your token for this project using .NET User Secrets: @@ -104,7 +104,7 @@ To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service ### 2. Deploy the Models Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). -#### ---#if (UseManagedIdentity) +#### ---#if (IsManagedIdentity) ### 3. Configure Azure OpenAI for Keyless Authentication This template is configured to use keyless authentication (also known as Managed Identity, with Entra ID). In the Azure Portal, when viewing the Azure OpenAI resource you just created, view access control settings and assign yourself the `Azure AI Developer` role. [Learn more about configuring authentication for local development](https://learn.microsoft.com/azure/developer/ai/keyless-connections?tabs=csharp%2Cazure-cli#authenticate-for-local-development). @@ -160,7 +160,7 @@ Make sure to replace `YOUR-AZURE-OPENAI-KEY` and `YOUR-AZURE-OPENAI-ENDPOINT` wi #### ---#endif #### ---#endif -#### ---#if (UseAzureAISearch) +#### ---#if (IsAzureAISearch) ## Configure Azure AI Search To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). @@ -168,9 +168,9 @@ To use Azure AI Search, you will need an Azure account and an Azure AI Search re ### 1. Create an Azure AI Search Resource Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-ChatWithCustomData-CSharp.Web-ingestion` index using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. +Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-ChatWithCustomData-CSharp.Web-chunks` and `data-ChatWithCustomData-CSharp.Web-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. -#### ---#if (UseManagedIdentity) +#### ---#if (IsManagedIdentity) ### 2. Configure Azure AI Search for Keyless Authentication This template is configured to use keyless authentication (also known as Managed Identity, with Entra ID). Before continuing, you'll need to configure your Azure AI Search resource to support this. [Learn more](https://learn.microsoft.com/azure/search/keyless-connections). After creation, ensure that you have selected Role-Based Access Control (RBAC) under Settings > Keys, as this is not the default. Assign yourself the roles called out for local development. [Learn more](https://learn.microsoft.com/azure/search/keyless-connections#roles-for-local-development). diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs new file mode 100644 index 00000000000..deff2580f52 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.VectorData; + +namespace ChatWithCustomData_CSharp.Web.Services; + +public class IngestedChunk +{ +#if (IsOllama) + private const int VectorDimensions = 384; // 384 is the default vector size for the all-minilm embedding model +#else + private const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model +#endif +#if (IsAzureAISearch || IsQdrant) + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; +#else + private const string VectorDistanceFunction = DistanceFunction.CosineDistance; +#endif + + [VectorStoreKey] +#if (IsQdrant) + public required Guid Key { get; set; } +#else + public required string Key { get; set; } +#endif + + [VectorStoreData(IsIndexed = true)] + public required string DocumentId { get; set; } + + [VectorStoreData] + public int PageNumber { get; set; } + + [VectorStoreData] + public required string Text { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public string? Vector => Text; +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs new file mode 100644 index 00000000000..27ea85df7b8 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.VectorData; + +namespace ChatWithCustomData_CSharp.Web.Services; + +public class IngestedDocument +{ + private const int VectorDimensions = 2; +#if (IsAzureAISearch || IsQdrant) + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; +#else + private const string VectorDistanceFunction = DistanceFunction.CosineDistance; +#endif + + [VectorStoreKey] +#if (IsQdrant) + public required Guid Key { get; set; } +#else + public required string Key { get; set; } +#endif + + [VectorStoreData(IsIndexed = true)] + public required string SourceId { get; set; } + + [VectorStoreData] + public required string DocumentId { get; set; } + + [VectorStoreData] + public required string DocumentVersion { get; set; } + + // The vector is not used but required for some vector databases + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public ReadOnlyMemory Vector { get; set; } = new ReadOnlyMemory([0, 0]); +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs index 4fb2f9ba370..5440772df42 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs @@ -1,14 +1,17 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace ChatWithCustomData_CSharp.Web.Services.Ingestion; public class DataIngestor( ILogger logger, - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore, - IngestionCacheDbContext ingestionCacheDb) +#if (IsQdrant) + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) +#else + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) +#endif { public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source) { @@ -19,49 +22,42 @@ public static async Task IngestDataAsync(IServiceProvider services, IIngestionSo public async Task IngestDataAsync(IIngestionSource source) { -#if (UseQdrant) - var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion"); -#else - var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion"); -#endif - await vectorCollection.CreateCollectionIfNotExistsAsync(); + await chunksCollection.EnsureCollectionExistsAsync(); + await documentsCollection.EnsureCollectionExistsAsync(); - var documentsForSource = ingestionCacheDb.Documents - .Where(d => d.SourceId == source.SourceId) - .Include(d => d.Records); + var sourceId = source.SourceId; + var documentsForSource = await documentsCollection.GetAsync(doc => doc.SourceId == sourceId, top: int.MaxValue).ToListAsync(); - var deletedFiles = await source.GetDeletedDocumentsAsync(documentsForSource); - foreach (var deletedFile in deletedFiles) + var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); + foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {file}", deletedFile.Id); - await vectorCollection.DeleteBatchAsync(deletedFile.Records.Select(r => r.Id)); - ingestionCacheDb.Documents.Remove(deletedFile); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); + await DeleteChunksForDocumentAsync(deletedDocument); + await documentsCollection.DeleteAsync(deletedDocument.Key); } - await ingestionCacheDb.SaveChangesAsync(); - var modifiedDocs = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); - foreach (var modifiedDoc in modifiedDocs) + var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); + foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {file}", modifiedDoc.Id); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); + await DeleteChunksForDocumentAsync(modifiedDocument); - if (modifiedDoc.Records.Count > 0) - { - await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id)); - } + await documentsCollection.UpsertAsync(modifiedDocument); - var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id); - await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { } + var newRecords = await source.CreateChunksForDocumentAsync(modifiedDocument); + await chunksCollection.UpsertAsync(newRecords); + } - modifiedDoc.Records.Clear(); - modifiedDoc.Records.AddRange(newRecords.Select(r => new IngestedRecord { Id = r.Key, DocumentId = modifiedDoc.Id })); + logger.LogInformation("Ingestion is up-to-date"); - if (ingestionCacheDb.Entry(modifiedDoc).State == EntityState.Detached) + async Task DeleteChunksForDocumentAsync(IngestedDocument document) + { + var documentId = document.DocumentId; + var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); + if (chunksToDelete.Count != 0) { - ingestionCacheDb.Documents.Add(modifiedDoc); + await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } } - - await ingestionCacheDb.SaveChangesAsync(); - logger.LogInformation("Ingestion is up-to-date"); } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs index 73c728865af..ae06879b4ce 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IIngestionSource.cs @@ -1,14 +1,12 @@ -using Microsoft.Extensions.AI; - -namespace ChatWithCustomData_CSharp.Web.Services.Ingestion; +namespace ChatWithCustomData_CSharp.Web.Services.Ingestion; public interface IIngestionSource { string SourceId { get; } - Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments); + Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments); - Task> GetDeletedDocumentsAsync(IQueryable existingDocuments); + Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments); - Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId); + Task> CreateChunksForDocumentAsync(IngestedDocument document); } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IngestionCacheDbContext.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IngestionCacheDbContext.cs deleted file mode 100644 index 71be4c82cdf..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/IngestionCacheDbContext.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace ChatWithCustomData_CSharp.Web.Services.Ingestion; - -// A DbContext that keeps track of which documents have been ingested. -// This makes it possible to avoid re-ingesting documents that have not changed, -// and to delete documents that have been removed from the underlying source. -public class IngestionCacheDbContext : DbContext -{ - public IngestionCacheDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet Documents { get; set; } = default!; - public DbSet Records { get; set; } = default!; - - public static void Initialize(IServiceProvider services) - { - using var scope = services.CreateScope(); - using var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.Entity().HasMany(d => d.Records).WithOne().HasForeignKey(r => r.DocumentId).OnDelete(DeleteBehavior.Cascade); - } -} - -public class IngestedDocument -{ - // TODO: Make Id+SourceId a composite key - public required string Id { get; set; } - public required string SourceId { get; set; } - public required string Version { get; set; } - public List Records { get; set; } = []; -} - -public class IngestedRecord -{ -#if (UseQdrant) - public required Guid Id { get; set; } -#else - public required string Id { get; set; } -#endif - public required string DocumentId { get; set; } -} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs index 6354d04d375..0ea678d888b 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs @@ -1,72 +1,66 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.SemanticKernel.Text; -using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; -using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; +using Microsoft.SemanticKernel.Text; using UglyToad.PdfPig; -using Microsoft.Extensions.AI; using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; namespace ChatWithCustomData_CSharp.Web.Services.Ingestion; public class PDFDirectorySource(string sourceDirectory) : IIngestionSource { public static string SourceFileId(string path) => Path.GetFileName(path); + public static string SourceFileVersion(string path) => File.GetLastWriteTimeUtc(path).ToString("o"); public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}"; - public async Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments) + public Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments) { var results = new List(); var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var existingDocumentsById = existingDocuments.ToDictionary(d => d.DocumentId); foreach (var sourceFile in sourceFiles) { var sourceFileId = SourceFileId(sourceFile); - var sourceFileVersion = File.GetLastWriteTimeUtc(sourceFile).ToString("o"); - - var existingDocument = await existingDocuments.Where(d => d.SourceId == SourceId && d.Id == sourceFileId).FirstOrDefaultAsync(); - if (existingDocument is null) + var sourceFileVersion = SourceFileVersion(sourceFile); + var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; + if (existingDocumentVersion != sourceFileVersion) { - results.Add(new() { Id = sourceFileId, Version = sourceFileVersion, SourceId = SourceId }); - } - else if (existingDocument.Version != sourceFileVersion) - { - existingDocument.Version = sourceFileVersion; - results.Add(existingDocument); +#if (IsQdrant) + results.Add(new() { Key = Guid.CreateVersion7(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); +#else + results.Add(new() { Key = Guid.CreateVersion7().ToString(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); +#endif } } - return results; + return Task.FromResult((IEnumerable)results); } - public async Task> GetDeletedDocumentsAsync(IQueryable existingDocuments) + public Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments) { - var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); - var sourceFileIds = sourceFiles.Select(SourceFileId).ToList(); - return await existingDocuments - .Where(d => !sourceFileIds.Contains(d.Id)) - .ToListAsync(); + var currentFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var currentFileIds = currentFiles.ToLookup(SourceFileId); + var deletedDocuments = existingDocuments.Where(d => !currentFileIds.Contains(d.DocumentId)); + return Task.FromResult(deletedDocuments); } - public async Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId) + public Task> CreateChunksForDocumentAsync(IngestedDocument document) { - using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId)); + using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, document.DocumentId)); var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList(); - var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text)); - - return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord + return Task.FromResult(paragraphs.Select(p => new IngestedChunk { -#if (UseQdrant) +#if (IsQdrant) Key = Guid.CreateVersion7(), #else - Key = $"{Path.GetFileNameWithoutExtension(documentId)}_{pair.First.PageNumber}_{pair.First.IndexOnPage}", + Key = Guid.CreateVersion7().ToString(), #endif - FileName = documentId, - PageNumber = pair.First.PageNumber, - Text = pair.First.Text, - Vector = pair.Second.Vector, - }); + DocumentId = document.DocumentId, + PageNumber = p.PageNumber, + Text = p.Text, + })); } private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs deleted file mode 100644 index 09425f6a00a..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Microsoft.Extensions.VectorData; -using System.Numerics.Tensors; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text.Json; - -namespace ChatWithCustomData_CSharp.Web.Services; - -/// -/// This IVectorStore implementation is for prototyping only. Do not use this in production. -/// In production, you must replace this with a real vector store. There are many IVectorStore -/// implementations available, including ones for standalone vector databases like Qdrant or Milvus, -/// or for integrating with relational databases such as SQL Server or PostgreSQL. -/// -/// This implementation stores the vector records in large JSON files on disk. It is very inefficient -/// and is provided only for convenience when prototyping. -/// -public class JsonVectorStore(string basePath) : IVectorStore -{ - public IVectorStoreRecordCollection GetCollection(string name, VectorStoreRecordDefinition? vectorStoreRecordDefinition = null) where TKey : notnull - => new JsonVectorStoreRecordCollection(name, Path.Combine(basePath, name + ".json"), vectorStoreRecordDefinition); - - public IAsyncEnumerable ListCollectionNamesAsync(CancellationToken cancellationToken = default) - => Directory.EnumerateFiles(basePath, "*.json").ToAsyncEnumerable(); - - private class JsonVectorStoreRecordCollection : IVectorStoreRecordCollection - where TKey : notnull - { - private static readonly Func _getKey = CreateKeyReader(); - private static readonly Func> _getVector = CreateVectorReader(); - - private readonly string _name; - private readonly string _filePath; - private Dictionary? _records; - - public JsonVectorStoreRecordCollection(string name, string filePath, VectorStoreRecordDefinition? vectorStoreRecordDefinition) - { - _name = name; - _filePath = filePath; - - if (File.Exists(filePath)) - { - _records = JsonSerializer.Deserialize>(File.ReadAllText(filePath)); - } - } - - public string CollectionName => _name; - - public Task CollectionExistsAsync(CancellationToken cancellationToken = default) - => Task.FromResult(_records is not null); - - public async Task CreateCollectionAsync(CancellationToken cancellationToken = default) - { - _records = []; - await WriteToDiskAsync(cancellationToken); - } - - public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellationToken = default) - { - if (_records is null) - { - await CreateCollectionAsync(cancellationToken); - } - } - - public Task DeleteAsync(TKey key, CancellationToken cancellationToken = default) - { - _records!.Remove(key); - return WriteToDiskAsync(cancellationToken); - } - - public Task DeleteBatchAsync(IEnumerable keys, CancellationToken cancellationToken = default) - { - foreach (var key in keys) - { - _records!.Remove(key); - } - - return WriteToDiskAsync(cancellationToken); - } - - public Task DeleteCollectionAsync(CancellationToken cancellationToken = default) - { - _records = null; - File.Delete(_filePath); - return Task.CompletedTask; - } - - public Task GetAsync(TKey key, GetRecordOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(_records!.GetValueOrDefault(key)); - - public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = null, CancellationToken cancellationToken = default) - => keys.Select(key => _records!.GetValueOrDefault(key)!).Where(r => r is not null).ToAsyncEnumerable(); - - public async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default) - { - var key = _getKey(record); - _records![key] = record; - await WriteToDiskAsync(cancellationToken); - return key; - } - - public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var results = new List(); - foreach (var record in records) - { - var key = _getKey(record); - _records![key] = record; - results.Add(key); - } - - await WriteToDiskAsync(cancellationToken); - - foreach (var key in results) - { - yield return key; - } - } - - public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default) - { - if (vector is not ReadOnlyMemory floatVector) - { - throw new NotSupportedException($"The provided vector type {vector!.GetType().FullName} is not supported."); - } - - IEnumerable filteredRecords = _records!.Values; - if (options?.Filter is { } filter) - { - filteredRecords = filteredRecords.AsQueryable().Where(filter); - } - - var ranked = from record in filteredRecords - let candidateVector = _getVector(record) - let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span) - orderby similarity descending - select (Record: record, Similarity: similarity); - - var results = ranked.Skip(options?.Skip ?? 0).Take(options?.Top ?? int.MaxValue); - return Task.FromResult(new VectorSearchResults( - results.Select(r => new VectorSearchResult(r.Record, r.Similarity)).ToAsyncEnumerable())); - } - - private static Func CreateKeyReader() - { - var propertyInfo = typeof(TRecord).GetProperties() - .Where(p => p.GetCustomAttribute() is not null - && p.PropertyType == typeof(TKey)) - .Single(); - return record => (TKey)propertyInfo.GetValue(record)!; - } - - private static Func> CreateVectorReader() - { - var propertyInfo = typeof(TRecord).GetProperties() - .Where(p => p.GetCustomAttribute() is not null - && p.PropertyType == typeof(ReadOnlyMemory)) - .Single(); - return record => (ReadOnlyMemory)propertyInfo.GetValue(record)!; - } - - private async Task WriteToDiskAsync(CancellationToken cancellationToken = default) - { - var json = JsonSerializer.Serialize(_records); - Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); - await File.WriteAllTextAsync(_filePath, json, cancellationToken); - } - } -} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs index e44c4144d27..42abf3151fc 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs @@ -1,32 +1,21 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData; namespace ChatWithCustomData_CSharp.Web.Services; public class SemanticSearch( - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore) -{ - public async Task> SearchAsync(string text, string? filenameFilter, int maxResults) - { - var queryEmbedding = await embeddingGenerator.GenerateVectorAsync(text); -#if (UseQdrant) - var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion"); +#if (IsQdrant) + VectorStoreCollection vectorCollection) #else - var vectorCollection = vectorStore.GetCollection("data-ChatWithCustomData-CSharp.Web-ingestion"); + VectorStoreCollection vectorCollection) #endif - - var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions +{ + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions { - Top = maxResults, - Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null, + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, }); - var results = new List(); - await foreach (var item in nearest.Results) - { - results.Add(item.Record); - } - return results; + return await nearest.Select(result => result.Record).ToListAsync(); } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearchRecord.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearchRecord.cs deleted file mode 100644 index 13f11d54294..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearchRecord.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.Extensions.VectorData; - -namespace ChatWithCustomData_CSharp.Web.Services; - -public class SemanticSearchRecord -{ - [VectorStoreRecordKey] -#if (UseQdrant) - public required Guid Key { get; set; } -#else - public required string Key { get; set; } -#endif - - [VectorStoreRecordData(IsFilterable = true)] - public required string FileName { get; set; } - - [VectorStoreRecordData] - public int PageNumber { get; set; } - - [VectorStoreRecordData] - public required string Text { get; set; } - -#if (IsOllama) - [VectorStoreRecordVector(384, DistanceFunction.CosineSimilarity)] // 384 is the default vector size for the all-minilm embedding model -#else - [VectorStoreRecordVector(1536, DistanceFunction.CosineSimilarity)] // 1536 is the default vector size for the OpenAI text-embedding-3-small model -#endif - public ReadOnlyMemory Vector { get; set; } -} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.props.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.props.in new file mode 100644 index 00000000000..0eb47a5ac25 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.props.in @@ -0,0 +1,5 @@ + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in index 66ea183ef70..08d54995389 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in @@ -1,17 +1,11 @@ - - - - <_UsingJustBuiltPackages>${UsingJustBuiltPackages} - - + + + + + + + + + + - - - - - - - + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md index f7c944dacc8..f30fcbfcf2e 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/README.Aspire.md @@ -5,7 +5,7 @@ This project is an AI chat application that demonstrates how to chat with custom >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. -#### ---#if (UseAzure) +#### ---#if (IsAzure) ### Prerequisites To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). @@ -81,77 +81,13 @@ Download, install, and run Docker Desktop from the [official website](https://ww Note: Ollama and Docker are excellent open source products, but are not maintained by Microsoft. #### ---#endif -#### ---#if (IsAzureOpenAI) -## Using Azure OpenAI +#### ---#if (IsAzureOpenAI || IsAzureAISearch) +## Using Azure Provisioning -To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service resource. For detailed instructions, see the [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource). +The project is set up to automatically provision Azure resources. When running the app for the first time, you will be prompted to provide Azure configuration values. For detailed instructions, see the [Local Provisioning documentation](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). -### 1. Create an Azure OpenAI Service Resource -[Create an Azure OpenAI Service resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal). - -### 2. Deploy the Models -Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). - -### 3. Configure API Key and Endpoint -Configure your Azure OpenAI API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure OpenAI resource. - 2. Copy the "Endpoint" URL and "Key 1" from the "Keys and Endpoint" section. -#### ---#if (hostIdentifier == "vs") - 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". - 4. This will open a secrets.json file where you can store your API key and endpoint without it being tracked in source control. Add the following keys & values to the file: - - ```json - { - "ConnectionStrings:openai": "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - } - ``` -#### ---#else - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd ChatWithCustomData-CSharp.AppHost - dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - ``` -#### ---#endif - -Make sure to replace `YOUR-API-KEY` and `YOUR-DEPLOYMENT-NAME` with your actual Azure OpenAI key and endpoint. Make sure your endpoint URL is formatted like https://YOUR-DEPLOYMENT-NAME.openai.azure.com/ (do not include any path after .openai.azure.com/). -#### ---#endif -#### ---#if (UseAzureAISearch) - -## Configure Azure AI Search - -To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). - -### 1. Create an Azure AI Search Resource -Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. - -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-ChatWithCustomData-CSharp.Web-ingestion` index using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. - -### 3. Configure API Key and Endpoint - Configure your Azure AI Search API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure AI Search resource. - 2. Copy the "Endpoint" URL and "Primary admin key" from the "Keys" section. -#### ---#if (hostIdentifier == "vs") - 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". - 4. This will open a `secrets.json` file where you can store your API key and endpoint without them being tracked in source control. Add the following keys and values to the file: - - ```json - { - "ConnectionStrings:azureAISearch": "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - } - ``` -#### ---#else - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd ChatWithCustomData-CSharp.AppHost - dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - ``` -#### ---#endif - -Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual Azure AI Search deployment name and key. #### ---#endif -#### ---#if (UseQdrant) +#### ---#if (IsQdrant) ## Setting up a local environment for Qdrant This project is configured to run Qdrant in a Docker container. Docker Desktop must be installed and running for the project to run successfully. A Qdrant container will automatically start when running the application. @@ -177,9 +113,9 @@ Note: Qdrant and Docker are excellent open source products, but are not maintain ## Trust the localhost certificate -Several .NET Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. -See [Troubleshoot untrusted localhost certificate in .NET Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. # Updating JavaScript dependencies diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json new file mode 100644 index 00000000000..f5b270270d0 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json new file mode 100644 index 00000000000..64694e46660 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/dotnetcli.host", + "symbolInfo": { + "TargetFrameworkOverride": { + "isHidden": "true", + "longName": "target-framework-override", + "shortName": "" + }, + "Framework": { + "longName": "framework" + }, + "NativeAot": { + "longName": "aot", + "shortName": "" + }, + "SelfContained": { + "longName": "self-contained", + "shortName": "" + } + }, + "usageExamples": [ + "" + ] +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json new file mode 100644 index 00000000000..8574a4767a5 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/ide.host", + "order": 0, + "icon": "ide/icon.ico", + "symbolInfo": [ + { + "id": "NativeAot", + "isVisible": true + }, + { + "id": "SelfContained", + "isVisible": true + } + ] +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico new file mode 100644 index 00000000000..954709ffd6b Binary files /dev/null and b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico differ diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json new file mode 100644 index 00000000000..3f4f85f1563 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ + "Common", + "AI", + "MCP" + ], + "identity": "Microsoft.Extensions.AI.Templates.McpServer.CSharp", + "name": "Local MCP Server Console App", + "description": "A project template for creating a Model Context Protocol (MCP) server using C# and the ModelContextProtocol package.", + "shortName": "mcpserver", + "defaultName": "McpServer", + "sourceName": "McpServer-CSharp", + "preferNameDirectory": true, + "tags": { + "language": "C#", + "type": "project" + }, + "symbols": { + "TargetFrameworkOverride": { + "type": "parameter", + "description": "Overrides the target framework", + "displayName": "Target framework override", + "replaces": "TargetFrameworkOverride", + "datatype": "string", + "defaultValue": "" + }, + "Framework": { + "type": "parameter", + "description": "The target framework for the project.", + "displayName": "Framework", + "datatype": "choice", + "choices": [ + { + "choice": "net10.0", + "description": ".NET 10" + }, + { + "choice": "net9.0", + "description": ".NET 9" + }, + { + "choice": "net8.0", + "description": ".NET 8" + } + ], + "replaces": "net9.0", + "defaultValue": "net9.0" + }, + "hostIdentifier": { + "type": "bind", + "binding": "HostIdentifier" + }, + "NativeAot": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "displayName": "Enable _native AOT publish", + "description": "Whether to enable the MCP server for publishing as a native AOT application." + }, + "SelfContained": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "true", + "displayName": "Enable _self-contained publish", + "description": "Whether to enable the MCP server for publishing as a self-contained application." + } + }, + "primaryOutputs": [ + { + "path": "./README.md" + }, + { + "path": "./McpServer-CSharp.csproj" + } + ], + "postActions": [ + { + "condition": "(hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", + "description": "Opens README file in the editor", + "manualInstructions": [], + "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", + "args": { + "files": "0" + }, + "continueOnError": true + } + ], + "SpecialCustomOperations": { + "**/*.md": { + "operations": [ + { + "type": "conditional", + "configuration": { + "if": [ "#### ---#if" ], + "else": [ "#### ---#else" ], + "elseif": [ "#### ---#elseif", "#### ---#elif" ], + "endif": [ "#### ---#endif" ], + "trim": "true", + "wholeLine": "true", + "evaluator": "C++" + } + } + ] + } + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in new file mode 100644 index 00000000000..2f07994302b --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in @@ -0,0 +1,53 @@ + + + + net9.0 + TargetFrameworkOverride + + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + + Major + + Exe + enable + enable + + + true + McpServer + + + + true + true + + + true + + + + + true + true + + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs new file mode 100644 index 00000000000..f320c93fd88 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md new file mode 100644 index 00000000000..95612c5e35e --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -0,0 +1,105 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +#### ---#if (SelfContained) +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. +#### ---#else +The MCP server is built as a framework-dependent application and requires the .NET runtime to be installed on the target machine. +The application is configured to roll-forward to the next highest major version of the runtime if one is available on the target machine. +If an applicable .NET runtime is not available, the MCP server will not start. +Consider building the MCP server as a self-contained application if you want to avoid this dependency. +#### ---#endif + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..568574f47d9 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/src/Shared/.editorconfig b/src/Shared/.editorconfig index bc980461f04..defd1d59afc 100644 --- a/src/Shared/.editorconfig +++ b/src/Shared/.editorconfig @@ -5868,7 +5868,7 @@ dotnet_diagnostic.SA1414.severity = warning # Title : Braces for multi-line statements should not share line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1500.md -dotnet_diagnostic.SA1500.severity = warning +dotnet_diagnostic.SA1500.severity = suggestion # rule does not work well with field-based property initializers # Title : Statement should not be on a single line # Category : StyleCop.CSharp.LayoutRules @@ -5936,7 +5936,7 @@ dotnet_diagnostic.SA1512.severity = none # Title : Closing brace should be followed by blank line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1513.md -dotnet_diagnostic.SA1513.severity = warning +dotnet_diagnostic.SA1513.severity = suggestion # rule does not work well with field-based property initializers # Title : Element documentation header should be preceded by blank line # Category : StyleCop.CSharp.LayoutRules diff --git a/src/Shared/Debugger/DebuggerExtensions.cs b/src/Shared/Debugger/DebuggerExtensions.cs index cc197be12c2..8c8ae506a20 100644 --- a/src/Shared/Debugger/DebuggerExtensions.cs +++ b/src/Shared/Debugger/DebuggerExtensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Throw = Microsoft.Shared.Diagnostics.Throw; #pragma warning disable CA1716 namespace Microsoft.Shared.Diagnostics; diff --git a/src/Shared/Debugger/IDebuggerState.cs b/src/Shared/Debugger/IDebuggerState.cs index 9e5eb7ac0be..bcbe3d13187 100644 --- a/src/Shared/Debugger/IDebuggerState.cs +++ b/src/Shared/Debugger/IDebuggerState.cs @@ -13,5 +13,5 @@ internal interface IDebuggerState /// /// Gets a value indicating whether a debugger is attached or not. /// - public bool IsAttached { get; } + bool IsAttached { get; } } diff --git a/src/Shared/FxPolyfills/ArgumentException.cs b/src/Shared/FxPolyfills/ArgumentException.cs new file mode 100644 index 00000000000..aafc737ab13 --- /dev/null +++ b/src/Shared/FxPolyfills/ArgumentException.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System; + +internal static partial class FxPolyfillArgumentException +{ + extension(ArgumentException) + { + public static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (string.IsNullOrEmpty(argument)) + { + ThrowNullOrEmptyException(argument, paramName); + } + } + } + + [DoesNotReturn] + private static void ThrowNullOrEmptyException(string? argument, string? paramName) + { + ArgumentNullException.ThrowIfNull(argument, paramName); + throw new ArgumentException("The value cannot be an empty string.", paramName); + } +} diff --git a/src/Shared/FxPolyfills/ArgumentNullException.cs b/src/Shared/FxPolyfills/ArgumentNullException.cs new file mode 100644 index 00000000000..5585b1e66f8 --- /dev/null +++ b/src/Shared/FxPolyfills/ArgumentNullException.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System; + +internal static partial class FxPolyfillArgumentNullException +{ + extension(ArgumentNullException) + { + public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (argument is null) + { + Throw(paramName); + } + } + } + + [DoesNotReturn] + internal static void Throw(string? paramName) => throw new ArgumentNullException(paramName); +} diff --git a/src/Shared/FxPolyfills/ConcurrentDictionary.cs b/src/Shared/FxPolyfills/ConcurrentDictionary.cs new file mode 100644 index 00000000000..92e4a2195df --- /dev/null +++ b/src/Shared/FxPolyfills/ConcurrentDictionary.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Collections.Concurrent; + +internal static partial class FxPolyfillConcurrentDictionary +{ + extension(ConcurrentDictionary dictionary) + { + public TValue GetOrAdd(TKey key, Func valueFactory) + { + if (dictionary.TryGetValue(key, out var existing)) + { + return existing; + } + + return dictionary.GetOrAdd(key, valueFactory(key)); + } + + public TValue GetOrAdd(TKey key, Func valueFactory, TState state) + { + if (dictionary.TryGetValue(key, out var existing)) + { + return existing; + } + + return dictionary.GetOrAdd(key, valueFactory(key, state)); + } + + public void TryRemove(TKey key) + { + dictionary.TryRemove(key, out _); + } + + public void TryRemove(KeyValuePair pair) + { + if (dictionary.TryRemove(pair.Key, out var existing) && !EqualityComparer.Default.Equals(existing, pair.Value)) + { + dictionary.TryAdd(pair.Key, pair.Value); + } + } + } +} diff --git a/src/Shared/FxPolyfills/ExceptionDispatchInfo.cs b/src/Shared/FxPolyfills/ExceptionDispatchInfo.cs new file mode 100644 index 00000000000..81cee7cba9a --- /dev/null +++ b/src/Shared/FxPolyfills/ExceptionDispatchInfo.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace System.Runtime.ExceptionServices; + +internal static partial class FxPolyfillExceptionDispatchInfo +{ + extension(ExceptionDispatchInfo) + { + [DoesNotReturn] + public static void Throw(Exception ex) + { + ExceptionDispatchInfo.Capture(ex).Throw(); + } + } +} diff --git a/src/Shared/FxPolyfills/FxPolyfills.targets b/src/Shared/FxPolyfills/FxPolyfills.targets new file mode 100644 index 00000000000..ca38f9ab986 --- /dev/null +++ b/src/Shared/FxPolyfills/FxPolyfills.targets @@ -0,0 +1,25 @@ + + + $(MSBuildThisFileDirectory) + + $(NoWarn);CS8763;CS8777;CS8603;CA1031;IDE0058;S108;S2166;S2302;S2333;S2486;S3400;SA1402;SA1509;SA1515;SA1649;EA0014;LA0001;VSTHRD003 + + + + + + + + + + + + + + + + diff --git a/src/Shared/FxPolyfills/IPEndPoint.cs b/src/Shared/FxPolyfills/IPEndPoint.cs new file mode 100644 index 00000000000..8571b675bb5 --- /dev/null +++ b/src/Shared/FxPolyfills/IPEndPoint.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; + +namespace System.Net; + +internal static partial class FxPolyfillIPEndPoint +{ + extension(IPEndPoint) + { + public static IPEndPoint Parse(string endpoint) + { + if (TryParse(endpoint.AsSpan(), out var result)) + { + return result; + } + + throw new FormatException("The endpoint format is invalid."); + } + + public static bool TryParse(ReadOnlySpan s, out IPEndPoint? result) + { + const int MaxPort = 0x0000FFFF; + + int addressLength = s.Length; // If there's no port then send the entire string to the address parser + int lastColonPos = s.LastIndexOf(':'); + + // Look to see if this is an IPv6 address with a port. + if (lastColonPos > 0) + { + if (s[lastColonPos - 1] == ']') + { + addressLength = lastColonPos; + } + // Look to see if this is IPv4 with a port (IPv6 will have another colon) + else if (s.Slice(0, lastColonPos).LastIndexOf(':') == -1) + { + addressLength = lastColonPos; + } + } + + if (IPAddress.TryParse(s.Slice(0, addressLength).ToString(), out IPAddress? address)) + { + uint port = 0; + if (addressLength == s.Length || + (uint.TryParse(s.Slice(addressLength + 1).ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out port) && port <= MaxPort)) + + { + result = new IPEndPoint(address, (int)port); + return true; + } + } + + result = null; + return false; + } + } +} diff --git a/src/Shared/FxPolyfills/Interlocked.cs b/src/Shared/FxPolyfills/Interlocked.cs new file mode 100644 index 00000000000..6177e411c35 --- /dev/null +++ b/src/Shared/FxPolyfills/Interlocked.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace System.Threading; + +internal static partial class FxPolyfillInterlocked +{ + extension(Interlocked) + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Decrement(ref uint location) => + (uint)Interlocked.Add(ref Unsafe.As(ref location), -1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Decrement(ref ulong location) => + (ulong)Interlocked.Add(ref Unsafe.As(ref location), -1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Increment(ref uint location) => + Add(ref location, 1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Increment(ref ulong location) => + Add(ref location, 1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Add(ref uint location1, uint value) => + (uint)Interlocked.Add(ref Unsafe.As(ref location1), (int)value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Add(ref ulong location1, ulong value) => + (ulong)Interlocked.Add(ref Unsafe.As(ref location1), (long)value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long Or(ref long location1, long value) + { + long current = location1; + while (true) + { + long newValue = current | value; + long oldValue = Interlocked.CompareExchange(ref location1, newValue, current); + if (oldValue == current) + { + return oldValue; + } + current = oldValue; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong And(ref ulong location1, ulong value) => + (ulong)Interlocked.And(ref Unsafe.As(ref location1), (long)value); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint And(ref uint location1, uint value) => + (uint)Interlocked.And(ref Unsafe.As(ref location1), (int)value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int And(ref int location1, int value) + { + int current = location1; + while (true) + { + int newValue = current & value; + int oldValue = Interlocked.CompareExchange(ref location1, newValue, current); + if (oldValue == current) + { + return oldValue; + } + current = oldValue; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long And(ref long location1, long value) + { + long current = location1; + while (true) + { + long newValue = current & value; + long oldValue = Interlocked.CompareExchange(ref location1, newValue, current); + if (oldValue == current) + { + return oldValue; + } + current = oldValue; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Or(ref ulong location1, ulong value) => + (ulong)Or(ref Unsafe.As(ref location1), (long)value); + } +} diff --git a/src/Shared/FxPolyfills/KeyValuePair.cs b/src/Shared/FxPolyfills/KeyValuePair.cs new file mode 100644 index 00000000000..64c79606bf4 --- /dev/null +++ b/src/Shared/FxPolyfills/KeyValuePair.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Collections.Generic; + +internal static partial class FxPolyfillKeyValuePair +{ + extension(KeyValuePair pair) + { + public void Deconstruct(out TKey key, out TValue value) + { + key = pair.Key; + value = pair.Value; + } + } +} + +internal static class KeyValuePair +{ + public static KeyValuePair Create(TKey key, TValue value) + { + return new KeyValuePair(key, value); + } +} diff --git a/src/Shared/FxPolyfills/ObjectDisposedException.cs b/src/Shared/FxPolyfills/ObjectDisposedException.cs new file mode 100644 index 00000000000..85ec090dd6c --- /dev/null +++ b/src/Shared/FxPolyfills/ObjectDisposedException.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace System; + +internal static partial class FxPolyfillObjectDisposedException +{ + extension(ObjectDisposedException) + { + public static void ThrowIf([DoesNotReturnIf(true)] bool condition, object instance) + { + if (condition) + { + throw new ObjectDisposedException(instance?.GetType().FullName); + } + } + } +} diff --git a/src/Shared/FxPolyfills/OperatingSystem.cs b/src/Shared/FxPolyfills/OperatingSystem.cs new file mode 100644 index 00000000000..4c88e7909aa --- /dev/null +++ b/src/Shared/FxPolyfills/OperatingSystem.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System; + +internal static class FrameworkExtensions +{ + extension(OperatingSystem) + { + public static bool IsLinux() => false; + public static bool IsWindows() => true; + public static bool IsMacOS() => false; + } +} diff --git a/src/Shared/FxPolyfills/String.cs b/src/Shared/FxPolyfills/String.cs new file mode 100644 index 00000000000..92df065be7e --- /dev/null +++ b/src/Shared/FxPolyfills/String.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System; + +internal static partial class FxPolyfillString +{ + extension(string s) + { + public bool StartsWith(char c) => s is [{ } first, ..] && first == c; + } +} diff --git a/src/Shared/FxPolyfills/Task.TimeProvider.cs b/src/Shared/FxPolyfills/Task.TimeProvider.cs new file mode 100644 index 00000000000..7e3a9c85bf0 --- /dev/null +++ b/src/Shared/FxPolyfills/Task.TimeProvider.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Threading.Tasks; + +internal static partial class FxPolyfillTask +{ + extension(Task task) + { + public Task WaitAsync(CancellationToken token) + { + return task.WaitAsync(Timeout.InfiniteTimeSpan, TimeProvider.System, token); + } + } +} diff --git a/src/Shared/FxPolyfills/Task.cs b/src/Shared/FxPolyfills/Task.cs new file mode 100644 index 00000000000..0035cde4b6f --- /dev/null +++ b/src/Shared/FxPolyfills/Task.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Threading.Tasks; + +internal enum ConfigureAwaitOptions +{ + None, + ContinueOnCapturedContext, + ForceYielding, + SuppressThrowing, +} + +internal static partial class FxPolyfillTask +{ + extension(Task task) + { + public async Task ConfigureAwait(ConfigureAwaitOptions options) + { + if (options == ConfigureAwaitOptions.None) + { + await task.ConfigureAwait(false); + } + else if (options == ConfigureAwaitOptions.ContinueOnCapturedContext) + { + await task.ConfigureAwait(true); + } + else if (options == ConfigureAwaitOptions.ForceYielding) + { + await Task.Yield(); + await task.ConfigureAwait(false); + } + else if (options == ConfigureAwaitOptions.SuppressThrowing) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + } + } + else + { + throw new InvalidOperationException(); + } + } + } +} + +internal sealed class TaskCompletionSource(TaskCreationOptions options) : TaskCompletionSource(options) +{ + public void SetResult() => SetResult(true); +} diff --git a/src/Shared/Instruments/ResourceUtilizationInstruments.cs b/src/Shared/Instruments/ResourceUtilizationInstruments.cs index 3b3e4f80ea2..b1593edd396 100644 --- a/src/Shared/Instruments/ResourceUtilizationInstruments.cs +++ b/src/Shared/Instruments/ResourceUtilizationInstruments.cs @@ -18,6 +18,14 @@ internal static class ResourceUtilizationInstruments /// public const string MeterName = "Microsoft.Extensions.Diagnostics.ResourceMonitoring"; + /// + /// The name of an instrument to retrieve CPU time consumed by the specific container on all available CPU cores, measured in seconds. + /// + /// + /// The type of an instrument is . + /// + public const string ContainerCpuTime = "container.cpu.time"; + /// /// The name of an instrument to retrieve CPU limit consumption of all processes running inside a container or control group in range [0, 1]. /// @@ -42,6 +50,14 @@ internal static class ResourceUtilizationInstruments /// public const string ContainerMemoryLimitUtilization = "container.memory.limit.utilization"; + /// + /// The name of an instrument to retrieve memory usage measured in bytes of all processes running inside a container or control group. + /// + /// + /// The type of an instrument is . + /// + public const string ContainerMemoryUsage = "container.memory.usage"; + /// /// The name of an instrument to retrieve CPU consumption share of the running process in range [0, 1]. /// @@ -89,22 +105,6 @@ internal static class ResourceUtilizationInstruments /// The type of an instrument is . /// public const string SystemNetworkConnections = "system.network.connections"; - - /// - /// The name of an instrument to count occurrences when CPU utilization exceeds 100% of the limit. - /// - /// - /// The type of an instrument is . - /// - public const string CpuUtilizationLimit100PercentExceeded = "cpu.utilization.limit.100percent.exceeded"; - - /// - /// The name of an instrument to count occurrences when CPU utilization exceeds 110% of the limit. - /// - /// - /// The type of an instrument is . - /// - public const string CpuUtilizationLimit110PercentExceeded = "cpu.utilization.limit.110percent.exceeded"; } #pragma warning disable CS1574 diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs deleted file mode 100644 index a395c133980..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs +++ /dev/null @@ -1,545 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Text.Json.Nodes; - -namespace System.Text.Json.Schema; - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S1144 // Unused private types or members should be removed - -internal static partial class JsonSchemaExporter -{ - // Simple JSON schema representation taken from System.Text.Json - // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs - private sealed class JsonSchema - { - public static JsonSchema CreateFalseSchema() => new(false); - public static JsonSchema CreateTrueSchema() => new(true); - - public JsonSchema() - { - } - - private JsonSchema(bool trueOrFalse) - { - _trueOrFalse = trueOrFalse; - } - - public bool IsTrue => _trueOrFalse is true; - public bool IsFalse => _trueOrFalse is false; - private readonly bool? _trueOrFalse; - - public string? Schema - { - get => _schema; - set - { - VerifyMutable(); - _schema = value; - } - } - - private string? _schema; - - public string? Title - { - get => _title; - set - { - VerifyMutable(); - _title = value; - } - } - - private string? _title; - - public string? Description - { - get => _description; - set - { - VerifyMutable(); - _description = value; - } - } - - private string? _description; - - public string? Ref - { - get => _ref; - set - { - VerifyMutable(); - _ref = value; - } - } - - private string? _ref; - - public string? Comment - { - get => _comment; - set - { - VerifyMutable(); - _comment = value; - } - } - - private string? _comment; - - public JsonSchemaType Type - { - get => _type; - set - { - VerifyMutable(); - _type = value; - } - } - - private JsonSchemaType _type = JsonSchemaType.Any; - - public string? Format - { - get => _format; - set - { - VerifyMutable(); - _format = value; - } - } - - private string? _format; - - public string? Pattern - { - get => _pattern; - set - { - VerifyMutable(); - _pattern = value; - } - } - - private string? _pattern; - - public JsonNode? Constant - { - get => _constant; - set - { - VerifyMutable(); - _constant = value; - } - } - - private JsonNode? _constant; - - public List>? Properties - { - get => _properties; - set - { - VerifyMutable(); - _properties = value; - } - } - - private List>? _properties; - - public List? Required - { - get => _required; - set - { - VerifyMutable(); - _required = value; - } - } - - private List? _required; - - public JsonSchema? Items - { - get => _items; - set - { - VerifyMutable(); - _items = value; - } - } - - private JsonSchema? _items; - - public JsonSchema? AdditionalProperties - { - get => _additionalProperties; - set - { - VerifyMutable(); - _additionalProperties = value; - } - } - - private JsonSchema? _additionalProperties; - - public JsonArray? Enum - { - get => _enum; - set - { - VerifyMutable(); - _enum = value; - } - } - - private JsonArray? _enum; - - public JsonSchema? Not - { - get => _not; - set - { - VerifyMutable(); - _not = value; - } - } - - private JsonSchema? _not; - - public List? AnyOf - { - get => _anyOf; - set - { - VerifyMutable(); - _anyOf = value; - } - } - - private List? _anyOf; - - public bool HasDefaultValue - { - get => _hasDefaultValue; - set - { - VerifyMutable(); - _hasDefaultValue = value; - } - } - - private bool _hasDefaultValue; - - public JsonNode? DefaultValue - { - get => _defaultValue; - set - { - VerifyMutable(); - _defaultValue = value; - } - } - - private JsonNode? _defaultValue; - - public int? MinLength - { - get => _minLength; - set - { - VerifyMutable(); - _minLength = value; - } - } - - private int? _minLength; - - public int? MaxLength - { - get => _maxLength; - set - { - VerifyMutable(); - _maxLength = value; - } - } - - private int? _maxLength; - - public JsonSchemaExporterContext? GenerationContext { get; set; } - - public int KeywordCount - { - get - { - if (_trueOrFalse != null) - { - return 0; - } - - int count = 0; - Count(Schema != null); - Count(Ref != null); - Count(Comment != null); - Count(Title != null); - Count(Description != null); - Count(Type != JsonSchemaType.Any); - Count(Format != null); - Count(Pattern != null); - Count(Constant != null); - Count(Properties != null); - Count(Required != null); - Count(Items != null); - Count(AdditionalProperties != null); - Count(Enum != null); - Count(Not != null); - Count(AnyOf != null); - Count(HasDefaultValue); - Count(MinLength != null); - Count(MaxLength != null); - - return count; - - void Count(bool isKeywordSpecified) => count += isKeywordSpecified ? 1 : 0; - } - } - - public void MakeNullable() - { - if (_trueOrFalse != null) - { - return; - } - - if (Type != JsonSchemaType.Any) - { - Type |= JsonSchemaType.Null; - } - } - - public JsonNode ToJsonNode(JsonSchemaExporterOptions options) - { - if (_trueOrFalse is { } boolSchema) - { - return CompleteSchema((JsonNode)boolSchema); - } - - var objSchema = new JsonObject(); - - if (Schema != null) - { - objSchema.Add(JsonSchemaConstants.SchemaPropertyName, Schema); - } - - if (Title != null) - { - objSchema.Add(JsonSchemaConstants.TitlePropertyName, Title); - } - - if (Description != null) - { - objSchema.Add(JsonSchemaConstants.DescriptionPropertyName, Description); - } - - if (Ref != null) - { - objSchema.Add(JsonSchemaConstants.RefPropertyName, Ref); - } - - if (Comment != null) - { - objSchema.Add(JsonSchemaConstants.CommentPropertyName, Comment); - } - - if (MapSchemaType(Type) is JsonNode type) - { - objSchema.Add(JsonSchemaConstants.TypePropertyName, type); - } - - if (Format != null) - { - objSchema.Add(JsonSchemaConstants.FormatPropertyName, Format); - } - - if (Pattern != null) - { - objSchema.Add(JsonSchemaConstants.PatternPropertyName, Pattern); - } - - if (Constant != null) - { - objSchema.Add(JsonSchemaConstants.ConstPropertyName, Constant); - } - - if (Properties != null) - { - var properties = new JsonObject(); - foreach (KeyValuePair property in Properties) - { - properties.Add(property.Key, property.Value.ToJsonNode(options)); - } - - objSchema.Add(JsonSchemaConstants.PropertiesPropertyName, properties); - } - - if (Required != null) - { - var requiredArray = new JsonArray(); - foreach (string requiredProperty in Required) - { - requiredArray.Add((JsonNode)requiredProperty); - } - - objSchema.Add(JsonSchemaConstants.RequiredPropertyName, requiredArray); - } - - if (Items != null) - { - objSchema.Add(JsonSchemaConstants.ItemsPropertyName, Items.ToJsonNode(options)); - } - - if (AdditionalProperties != null) - { - objSchema.Add(JsonSchemaConstants.AdditionalPropertiesPropertyName, AdditionalProperties.ToJsonNode(options)); - } - - if (Enum != null) - { - objSchema.Add(JsonSchemaConstants.EnumPropertyName, Enum); - } - - if (Not != null) - { - objSchema.Add(JsonSchemaConstants.NotPropertyName, Not.ToJsonNode(options)); - } - - if (AnyOf != null) - { - JsonArray anyOfArray = new(); - foreach (JsonSchema schema in AnyOf) - { - anyOfArray.Add(schema.ToJsonNode(options)); - } - - objSchema.Add(JsonSchemaConstants.AnyOfPropertyName, anyOfArray); - } - - if (HasDefaultValue) - { - objSchema.Add(JsonSchemaConstants.DefaultPropertyName, DefaultValue); - } - - if (MinLength is int minLength) - { - objSchema.Add(JsonSchemaConstants.MinLengthPropertyName, (JsonNode)minLength); - } - - if (MaxLength is int maxLength) - { - objSchema.Add(JsonSchemaConstants.MaxLengthPropertyName, (JsonNode)maxLength); - } - - return CompleteSchema(objSchema); - - JsonNode CompleteSchema(JsonNode schema) - { - if (GenerationContext is { } context) - { - Debug.Assert(options.TransformSchemaNode != null, "context should only be populated if a callback is present."); - - // Apply any user-defined transformations to the schema. - return options.TransformSchemaNode!(context, schema); - } - - return schema; - } - } - - public static void EnsureMutable(ref JsonSchema schema) - { - switch (schema._trueOrFalse) - { - case false: - schema = new JsonSchema { Not = JsonSchema.CreateTrueSchema() }; - break; - case true: - schema = new JsonSchema(); - break; - } - } - - private static readonly JsonSchemaType[] _schemaValues = new JsonSchemaType[] - { - // NB the order of these values influences order of types in the rendered schema - JsonSchemaType.String, - JsonSchemaType.Integer, - JsonSchemaType.Number, - JsonSchemaType.Boolean, - JsonSchemaType.Array, - JsonSchemaType.Object, - JsonSchemaType.Null, - }; - - private void VerifyMutable() - { - Debug.Assert(_trueOrFalse is null, "Schema is not mutable"); - } - - private static JsonNode? MapSchemaType(JsonSchemaType schemaType) - { - if (schemaType is JsonSchemaType.Any) - { - return null; - } - - if (ToIdentifier(schemaType) is string identifier) - { - return identifier; - } - - var array = new JsonArray(); - foreach (JsonSchemaType type in _schemaValues) - { - if ((schemaType & type) != 0) - { - array.Add((JsonNode)ToIdentifier(type)!); - } - } - - return array; - - static string? ToIdentifier(JsonSchemaType schemaType) => schemaType switch - { - JsonSchemaType.Null => "null", - JsonSchemaType.Boolean => "boolean", - JsonSchemaType.Integer => "integer", - JsonSchemaType.Number => "number", - JsonSchemaType.String => "string", - JsonSchemaType.Array => "array", - JsonSchemaType.Object => "object", - _ => null, - }; - } - } - - [Flags] - private enum JsonSchemaType - { - Any = 0, // No type declared on the schema - Null = 1, - Boolean = 2, - Integer = 4, - Number = 8, - String = 16, - Array = 32, - Object = 64, - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs deleted file mode 100644 index 481e5f75753..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs +++ /dev/null @@ -1,427 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -#if !NET -using System.Linq; -#endif -using System.Reflection; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - -namespace System.Text.Json.Schema; - -internal static partial class JsonSchemaExporter -{ - private static class ReflectionHelpers - { - private const BindingFlags AllInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - private static PropertyInfo? _jsonTypeInfo_ElementType; - private static PropertyInfo? _jsonPropertyInfo_MemberName; - private static FieldInfo? _nullableConverter_ElementConverter_Generic; - private static FieldInfo? _enumConverter_Options_Generic; - private static FieldInfo? _enumConverter_NamingPolicy_Generic; - - public static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - public static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) is not null; - - public static Type GetElementType(JsonTypeInfo typeInfo) - { - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary, "TypeInfo must be of collection type"); - - // Uses reflection to access the element type encapsulated by a JsonTypeInfo. - if (_jsonTypeInfo_ElementType is null) - { - PropertyInfo? elementTypeProperty = typeof(JsonTypeInfo).GetProperty("ElementType", AllInstance); - _jsonTypeInfo_ElementType = Throw.IfNull(elementTypeProperty); - } - - return (Type)_jsonTypeInfo_ElementType.GetValue(typeInfo)!; - } - - public static string? GetMemberName(JsonPropertyInfo propertyInfo) - { - // Uses reflection to the member name encapsulated by a JsonPropertyInfo. - if (_jsonPropertyInfo_MemberName is null) - { - PropertyInfo? memberName = typeof(JsonPropertyInfo).GetProperty("MemberName", AllInstance); - _jsonPropertyInfo_MemberName = Throw.IfNull(memberName); - } - - return (string?)_jsonPropertyInfo_MemberName.GetValue(propertyInfo); - } - - public static JsonConverter GetElementConverter(JsonConverter nullableConverter) - { - // Uses reflection to access the element converter encapsulated by a nullable converter. - if (_nullableConverter_ElementConverter_Generic is null) - { - FieldInfo? genericFieldInfo = Type - .GetType("System.Text.Json.Serialization.Converters.NullableConverter`1, System.Text.Json")! - .GetField("_elementConverter", AllInstance); - - _nullableConverter_ElementConverter_Generic = Throw.IfNull(genericFieldInfo); - } - - Type converterType = nullableConverter.GetType(); - var thisFieldInfo = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_nullableConverter_ElementConverter_Generic); - return (JsonConverter)thisFieldInfo.GetValue(nullableConverter)!; - } - - public static void GetEnumConverterConfig(JsonConverter enumConverter, out JsonNamingPolicy? namingPolicy, out bool allowString) - { - // Uses reflection to access configuration encapsulated by an enum converter. - if (_enumConverter_Options_Generic is null) - { - FieldInfo? genericFieldInfo = Type - .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! - .GetField("_converterOptions", AllInstance); - - _enumConverter_Options_Generic = Throw.IfNull(genericFieldInfo); - } - - if (_enumConverter_NamingPolicy_Generic is null) - { - FieldInfo? genericFieldInfo = Type - .GetType("System.Text.Json.Serialization.Converters.EnumConverter`1, System.Text.Json")! - .GetField("_namingPolicy", AllInstance); - - _enumConverter_NamingPolicy_Generic = Throw.IfNull(genericFieldInfo); - } - - const int EnumConverterOptionsAllowStrings = 1; - Type converterType = enumConverter.GetType(); - var converterOptionsField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_Options_Generic); - var namingPolicyField = (FieldInfo)converterType.GetMemberWithSameMetadataDefinitionAs(_enumConverter_NamingPolicy_Generic); - - namingPolicy = (JsonNamingPolicy?)namingPolicyField.GetValue(enumConverter); - int converterOptions = (int)converterOptionsField.GetValue(enumConverter)!; - allowString = (converterOptions & EnumConverterOptionsAllowStrings) != 0; - } - - // The .NET 8 source generator doesn't populate attribute providers for properties - // cf. https://github.com/dotnet/runtime/issues/100095 - // Work around the issue by running a query for the relevant MemberInfo using the internal MemberName property - // https://github.com/dotnet/runtime/blob/de774ff9ee1a2c06663ab35be34b755cd8d29731/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonPropertyInfo.cs#L206 - public static ICustomAttributeProvider? ResolveAttributeProvider( - [DynamicallyAccessedMembers( - DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | - DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] - Type? declaringType, - JsonPropertyInfo? propertyInfo) - { - if (declaringType is null || propertyInfo is null) - { - return null; - } - - if (propertyInfo.AttributeProvider is { } provider) - { - return provider; - } - - string? memberName = ReflectionHelpers.GetMemberName(propertyInfo); - if (memberName is not null) - { - return (MemberInfo?)declaringType.GetProperty(memberName, AllInstance) ?? - declaringType.GetField(memberName, AllInstance); - } - - return null; - } - - // Resolves the parameters of the deserialization constructor for a type, if they exist. - public static Func? ResolveJsonConstructorParameterMapper( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] - Type type, - JsonTypeInfo typeInfo) - { - Debug.Assert(type == typeInfo.Type, "The declaring type must match the typeInfo type."); - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.Object, "Should only be passed object JSON kinds."); - - if (typeInfo.Properties.Count > 0 && - typeInfo.CreateObject is null && // Ensure that a default constructor isn't being used - TryGetDeserializationConstructor(type, useDefaultCtorInAnnotatedStructs: true, out ConstructorInfo? ctor)) - { - ParameterInfo[]? parameters = ctor?.GetParameters(); - if (parameters?.Length > 0) - { - Dictionary dict = new(parameters.Length); - foreach (ParameterInfo parameter in parameters) - { - if (parameter.Name is not null) - { - // We don't care about null parameter names or conflicts since they - // would have already been rejected by JsonTypeInfo exporterOptions. - dict[new(parameter.Name, parameter.ParameterType)] = parameter; - } - } - - return prop => dict.TryGetValue(new(prop.Name, prop.PropertyType), out ParameterInfo? parameter) ? parameter : null; - } - } - - return null; - } - - // Resolves the nullable reference type annotations for a property or field, - // additionally addressing a few known bugs of the NullabilityInfo pre .NET 9. - public static NullabilityInfo GetMemberNullability(NullabilityInfoContext context, MemberInfo memberInfo) - { - Debug.Assert(memberInfo is PropertyInfo or FieldInfo, "Member must be property or field."); - return memberInfo is PropertyInfo prop - ? context.Create(prop) - : context.Create((FieldInfo)memberInfo); - } - - public static NullabilityState GetParameterNullability(NullabilityInfoContext context, ParameterInfo parameterInfo) - { -#if NET8_0 - // Workaround for https://github.com/dotnet/runtime/issues/92487 - // The fix has been incorporated into .NET 9 (and the polyfilled implementations in netfx). - // Should be removed once .NET 8 support is dropped. - if (GetGenericParameterDefinition(parameterInfo) is { ParameterType: { IsGenericParameter: true } typeParam }) - { - // Step 1. Look for nullable annotations on the type parameter. - if (GetNullableFlags(typeParam) is byte[] flags) - { - return TranslateByte(flags[0]); - } - - // Step 2. Look for nullable annotations on the generic method declaration. - if (typeParam.DeclaringMethod != null && GetNullableContextFlag(typeParam.DeclaringMethod) is byte flag) - { - return TranslateByte(flag); - } - - // Step 3. Look for nullable annotations on the generic method declaration. - if (GetNullableContextFlag(typeParam.DeclaringType!) is byte flag2) - { - return TranslateByte(flag2); - } - - // Default to nullable. - return NullabilityState.Nullable; - - static byte[]? GetNullableFlags(MemberInfo member) - { - foreach (CustomAttributeData attr in member.GetCustomAttributesData()) - { - Type attrType = attr.AttributeType; - if (attrType.Name == "NullableAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") - { - foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) - { - switch (ctorArg.Value) - { - case byte flag: - return [flag]; - case byte[] flags: - return flags; - } - } - } - } - - return null; - } - - static byte? GetNullableContextFlag(MemberInfo member) - { - foreach (CustomAttributeData attr in member.GetCustomAttributesData()) - { - Type attrType = attr.AttributeType; - if (attrType.Name == "NullableContextAttribute" && attrType.Namespace == "System.Runtime.CompilerServices") - { - foreach (CustomAttributeTypedArgument ctorArg in attr.ConstructorArguments) - { - if (ctorArg.Value is byte flag) - { - return flag; - } - } - } - } - - return null; - } - -#pragma warning disable S109 // Magic numbers should not be used - static NullabilityState TranslateByte(byte b) => b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; -#pragma warning restore S109 // Magic numbers should not be used - } - - static ParameterInfo GetGenericParameterDefinition(ParameterInfo parameter) - { - if (parameter.Member is { DeclaringType.IsConstructedGenericType: true } - or MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false }) - { - var genericMethod = (MethodBase)GetGenericMemberDefinition(parameter.Member); - return genericMethod.GetParameters()[parameter.Position]; - } - - return parameter; - } - - static MemberInfo GetGenericMemberDefinition(MemberInfo member) - { - if (member is Type type) - { - return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; - } - - if (member.DeclaringType?.IsConstructedGenericType is true) - { - return member.DeclaringType.GetGenericTypeDefinition().GetMemberWithSameMetadataDefinitionAs(member); - } - - if (member is MethodInfo { IsGenericMethod: true, IsGenericMethodDefinition: false } method) - { - return method.GetGenericMethodDefinition(); - } - - return member; - } -#endif - return context.Create(parameterInfo).WriteState; - } - - // Taken from https://github.com/dotnet/runtime/blob/903bc019427ca07080530751151ea636168ad334/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L288-L317 - public static object? GetNormalizedDefaultValue(ParameterInfo parameterInfo) - { - Type parameterType = parameterInfo.ParameterType; - object? defaultValue = parameterInfo.DefaultValue; - - if (defaultValue is null) - { - return null; - } - - // DBNull.Value is sometimes used as the default value (returned by reflection) of nullable params in place of null. - if (defaultValue == DBNull.Value && parameterType != typeof(DBNull)) - { - return null; - } - - // Default values of enums or nullable enums are represented using the underlying type and need to be cast explicitly - // cf. https://github.com/dotnet/runtime/issues/68647 - if (parameterType.IsEnum) - { - return Enum.ToObject(parameterType, defaultValue); - } - - if (Nullable.GetUnderlyingType(parameterType) is Type underlyingType && underlyingType.IsEnum) - { - return Enum.ToObject(underlyingType, defaultValue); - } - - return defaultValue; - } - - // Resolves the deserialization constructor for a type using logic copied from - // https://github.com/dotnet/runtime/blob/e12e2fa6cbdd1f4b0c8ad1b1e2d960a480c21703/src/libraries/System.Text.Json/Common/ReflectionExtensions.cs#L227-L286 - private static bool TryGetDeserializationConstructor( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] - Type type, - bool useDefaultCtorInAnnotatedStructs, - out ConstructorInfo? deserializationCtor) - { - ConstructorInfo? ctorWithAttribute = null; - ConstructorInfo? publicParameterlessCtor = null; - ConstructorInfo? lonePublicCtor = null; - - ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); - - if (constructors.Length == 1) - { - lonePublicCtor = constructors[0]; - } - - foreach (ConstructorInfo constructor in constructors) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - else if (constructor.GetParameters().Length == 0) - { - publicParameterlessCtor = constructor; - } - } - - // Search for non-public ctors with [JsonConstructor]. - foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)) - { - if (HasJsonConstructorAttribute(constructor)) - { - if (ctorWithAttribute != null) - { - deserializationCtor = null; - return false; - } - - ctorWithAttribute = constructor; - } - } - - // Structs will use default constructor if attribute isn't used. - if (useDefaultCtorInAnnotatedStructs && type.IsValueType && ctorWithAttribute == null) - { - deserializationCtor = null; - return true; - } - - deserializationCtor = ctorWithAttribute ?? publicParameterlessCtor ?? lonePublicCtor; - return true; - - static bool HasJsonConstructorAttribute(ConstructorInfo constructorInfo) => - constructorInfo.GetCustomAttribute() != null; - } - - // Parameter to property matching semantics as declared in - // https://github.com/dotnet/runtime/blob/12d96ccfaed98e23c345188ee08f8cfe211c03e7/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/JsonTypeInfo.cs#L1007-L1030 - private readonly struct ParameterLookupKey : IEquatable - { - public ParameterLookupKey(string name, Type type) - { - Name = name; - Type = type; - } - - public string Name { get; } - public Type Type { get; } - - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); - public bool Equals(ParameterLookupKey other) => Type == other.Type && string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase); - public override bool Equals(object? obj) => obj is ParameterLookupKey key && Equals(key); - } - } - -#if !NET - private static MemberInfo GetMemberWithSameMetadataDefinitionAs(this Type specializedType, MemberInfo member) - { - const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - return specializedType.GetMember(member.Name, member.MemberType, All).First(m => m.MetadataToken == member.MetadataToken); - } -#endif -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs deleted file mode 100644 index 2d8ffc5497c..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ /dev/null @@ -1,801 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Reflection; -#if NET -using System.Runtime.InteropServices; -#endif -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance -#pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - -namespace System.Text.Json.Schema; - -/// -/// Maps .NET types to JSON schema objects using contract metadata from instances. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal static partial class JsonSchemaExporter -{ - // Polyfill implementation of JsonSchemaExporter for System.Text.Json version 8.0.0. - // Uses private reflection to access metadata not available with the older APIs of STJ. - - private const string RequiresUnreferencedCodeMessage = - "Uses private reflection on System.Text.Json components to access converter metadata. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."; - - /// - /// Generates a JSON schema corresponding to the contract metadata of the specified type. - /// - /// The options instance from which to resolve the contract metadata. - /// The root type for which to generate the JSON schema. - /// The exporterOptions object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported exporterOptions. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null) - { - _ = Throw.IfNull(options); - _ = Throw.IfNull(type); - ValidateOptions(options); - - exporterOptions ??= JsonSchemaExporterOptions.Default; - JsonTypeInfo typeInfo = options.GetTypeInfo(type); - return MapRootTypeJsonSchema(typeInfo, exporterOptions); - } - - /// - /// Generates a JSON schema corresponding to the specified contract metadata. - /// - /// The contract metadata for which to generate the schema. - /// The exporterOptions object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported exporterOptions. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null) - { - _ = Throw.IfNull(typeInfo); - ValidateOptions(typeInfo.Options); - - exporterOptions ??= JsonSchemaExporterOptions.Default; - return MapRootTypeJsonSchema(typeInfo, exporterOptions); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static JsonNode MapRootTypeJsonSchema(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions) - { - GenerationState state = new(exporterOptions, typeInfo.Options); - JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo); - return schema.ToJsonNode(exporterOptions); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static JsonSchema MapJsonSchemaCore( - ref GenerationState state, - JsonTypeInfo typeInfo, - Type? parentType = null, - JsonPropertyInfo? propertyInfo = null, - ICustomAttributeProvider? propertyAttributeProvider = null, - ParameterInfo? parameterInfo = null, - bool isNonNullableType = false, - JsonConverter? customConverter = null, - JsonNumberHandling? customNumberHandling = null, - JsonTypeInfo? parentPolymorphicTypeInfo = null, - bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false, - bool parentPolymorphicTypeIsNonNullable = false, - KeyValuePair? typeDiscriminator = null, - bool cacheResult = true) - { - Debug.Assert(typeInfo.IsReadOnly, "The specified contract must have been made read-only."); - - JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, parentPolymorphicTypeInfo, parentType, propertyInfo, parameterInfo, propertyAttributeProvider); - - if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None && - state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer)) - { - // The schema context has already been generated in the schema document, return a reference to it. - return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer }); - } - - JsonSchema schema; - JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; - JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; - - if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) - { - // Return a `true` schema for types with user-defined converters. - return CompleteSchema(ref state, JsonSchema.CreateTrueSchema()); - } - - if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) - { - // This is the base type of a polymorphic type hierarchy. The schema for this type - // will include an "anyOf" property with the schemas for all derived types. - - string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; - List derivedTypes = polyOptions.DerivedTypes.ToList(); - - if (!typeInfo.Type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == typeInfo.Type)) - { - // For non-abstract base types that haven't been explicitly configured, - // add a trivial schema to the derived types since we should support it. - derivedTypes.Add(new JsonDerivedType(typeInfo.Type)); - } - - bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDiscriminator is null); - JsonSchemaType schemaType = JsonSchemaType.Any; - List? anyOf = new(derivedTypes.Count); - - state.PushSchemaNode(JsonSchemaConstants.AnyOfPropertyName); - - foreach (JsonDerivedType derivedType in derivedTypes) - { - Debug.Assert(derivedType.TypeDiscriminator is null or int or string, "Type discriminator does not have the expected type."); - - KeyValuePair? derivedTypeDiscriminator = null; - if (derivedType.TypeDiscriminator is { } discriminatorValue) - { - JsonNode discriminatorNode = discriminatorValue switch - { - string stringId => (JsonNode)stringId, - _ => (JsonNode)(int)discriminatorValue, - }; - - JsonSchema discriminatorSchema = new() { Constant = discriminatorNode }; - derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema); - } - - JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); - - state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture)); - JsonSchema derivedSchema = MapJsonSchemaCore( - ref state, - derivedTypeInfo, - parentPolymorphicTypeInfo: typeInfo, - typeDiscriminator: derivedTypeDiscriminator, - parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator, - parentPolymorphicTypeIsNonNullable: isNonNullableType, - cacheResult: false); - - state.PopSchemaNode(); - - // Determine if all derived schemas have the same type. - if (anyOf.Count == 0) - { - schemaType = derivedSchema.Type; - } - else if (schemaType != derivedSchema.Type) - { - schemaType = JsonSchemaType.Any; - } - - anyOf.Add(derivedSchema); - } - - state.PopSchemaNode(); - - if (schemaType is not JsonSchemaType.Any) - { - // If all derived types have the same schema type, we can simplify the schema - // by moving the type keyword to the base schema and removing it from the derived schemas. - foreach (JsonSchema derivedSchema in anyOf) - { - derivedSchema.Type = JsonSchemaType.Any; - - if (derivedSchema.KeywordCount == 0) - { - // if removing the type results in an empty schema, - // remove the anyOf array entirely since it's always true. - anyOf = null; - break; - } - } - } - - schema = new() - { - Type = schemaType, - AnyOf = anyOf, - - // If all derived types have a discriminator, we can require it in the base schema. - Required = containsTypesWithoutDiscriminator ? null : new() { typeDiscriminatorKey }, - }; - - return CompleteSchema(ref state, schema); - } - - if (Nullable.GetUnderlyingType(typeInfo.Type) is Type nullableElementType) - { - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); - customConverter = ExtractCustomNullableConverter(customConverter); - schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: customConverter, cacheResult: false); - - if (schema.Enum != null) - { - Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for enum types."); - schema.Enum.Add(null); // Append null to the enum array. - } - - return CompleteSchema(ref state, schema); - } - - switch (typeInfo.Kind) - { - case JsonTypeInfoKind.Object: - List>? properties = null; - List? required = null; - JsonSchema? additionalProperties = null; - - JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling; - if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) - { - // Disallow unspecified properties. - additionalProperties = JsonSchema.CreateFalseSchema(); - } - - if (typeDiscriminator is { } typeDiscriminatorPair) - { - (properties = new()).Add(typeDiscriminatorPair); - if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) - { - // Require the discriminator here since it's not common to all derived types. - (required = new()).Add(typeDiscriminatorPair.Key); - } - } - - Func? parameterInfoMapper = - ReflectionHelpers.ResolveJsonConstructorParameterMapper(typeInfo.Type, typeInfo); - - state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - if (property is { Get: null, Set: null } or { IsExtensionData: true }) - { - continue; // Skip JsonIgnored properties and extension data - } - - JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; - JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); - - // Resolve the attribute provider for the property. - ICustomAttributeProvider? attributeProvider = ReflectionHelpers.ResolveAttributeProvider(typeInfo.Type, property); - - // Declare the property as nullable if either getter or setter are nullable. - bool isNonNullableProperty = false; - if (attributeProvider is MemberInfo memberInfo) - { - NullabilityInfo nullabilityInfo = ReflectionHelpers.GetMemberNullability(state.NullabilityInfoContext, memberInfo); - isNonNullableProperty = - (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && - (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); - } - - bool isRequired = property.IsRequired; - bool hasDefaultValue = false; - JsonNode? defaultValue = null; - - ParameterInfo? associatedParameter = parameterInfoMapper?.Invoke(property); - if (associatedParameter != null) - { - ResolveParameterInfo( - associatedParameter, - propertyTypeInfo, - state.NullabilityInfoContext, - out hasDefaultValue, - out defaultValue, - out bool isNonNullableParameter, - ref isRequired); - - isNonNullableProperty &= isNonNullableParameter; - } - - state.PushSchemaNode(property.Name); - JsonSchema propertySchema = MapJsonSchemaCore( - ref state, - propertyTypeInfo, - parentType: typeInfo.Type, - propertyInfo: property, - parameterInfo: associatedParameter, - propertyAttributeProvider: attributeProvider, - isNonNullableType: isNonNullableProperty, - customConverter: property.CustomConverter, - customNumberHandling: propertyNumberHandling); - - state.PopSchemaNode(); - - if (hasDefaultValue) - { - JsonSchema.EnsureMutable(ref propertySchema); - propertySchema.DefaultValue = defaultValue; - propertySchema.HasDefaultValue = true; - } - - (properties ??= new()).Add(new(property.Name, propertySchema)); - - if (isRequired) - { - (required ??= new()).Add(property.Name); - } - } - - state.PopSchemaNode(); - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = properties, - Required = required, - AdditionalProperties = additionalProperties, - }); - - case JsonTypeInfoKind.Enumerable: - Type elementType = ReflectionHelpers.GetElementType(typeInfo); - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); - - if (typeDiscriminator is null) - { - state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); - JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Array, - Items = items.IsTrue ? null : items, - }); - } - else - { - // Polymorphic enumerable types are represented using a wrapping object: - // { "$type" : "discriminator", "$values" : [element1, element2, ...] } - // Which corresponds to the schema - // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } - const string ValuesKeyword = "$values"; - - state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); - state.PushSchemaNode(ValuesKeyword); - state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); - - JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); - - state.PopSchemaNode(); - state.PopSchemaNode(); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = new() - { - typeDiscriminator.Value, - new(ValuesKeyword, - new JsonSchema - { - Type = JsonSchemaType.Array, - Items = items.IsTrue ? null : items, - }), - }, - Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? new() { typeDiscriminator.Value.Key } : null, - }); - } - - case JsonTypeInfoKind.Dictionary: - Type valueType = ReflectionHelpers.GetElementType(typeInfo); - JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); - - List>? dictProps = null; - List? dictRequired = null; - - if (typeDiscriminator is { } dictDiscriminator) - { - dictProps = new() { dictDiscriminator }; - if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) - { - // Require the discriminator here since it's not common to all derived types. - dictRequired = new() { dictDiscriminator.Key }; - } - } - - state.PushSchemaNode(JsonSchemaConstants.AdditionalPropertiesPropertyName); - JsonSchema valueSchema = MapJsonSchemaCore(ref state, valueTypeInfo, customNumberHandling: effectiveNumberHandling); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = dictProps, - Required = dictRequired, - AdditionalProperties = valueSchema.IsTrue ? null : valueSchema, - }); - - default: - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None, "The default case should handle unrecognize type kinds."); - - if (_simpleTypeSchemaFactories.TryGetValue(typeInfo.Type, out Func? simpleTypeSchemaFactory)) - { - schema = simpleTypeSchemaFactory(effectiveNumberHandling); - } - else if (typeInfo.Type.IsEnum) - { - schema = GetEnumConverterSchema(typeInfo, effectiveConverter); - } - else - { - schema = JsonSchema.CreateTrueSchema(); - } - - return CompleteSchema(ref state, schema); - } - - JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) - { - if (schema.Ref is null) - { - if (IsNullableSchema(ref state)) - { - schema.MakeNullable(); - } - - bool IsNullableSchema(ref GenerationState state) - { - // A schema is marked as nullable if either - // 1. We have a schema for a property where either the getter or setter are marked as nullable. - // 2. We have a schema for a reference type, unless we're explicitly treating null-oblivious types as non-nullable - - if (propertyInfo != null || parameterInfo != null) - { - return !isNonNullableType; - } - else - { - return ReflectionHelpers.CanBeNull(typeInfo.Type) && - !parentPolymorphicTypeIsNonNullable && - !state.ExporterOptions.TreatNullObliviousAsNonNullable; - } - } - } - - if (state.ExporterOptions.TransformSchemaNode != null) - { - // Prime the schema for invocation by the JsonNode transformer. - schema.GenerationContext = exporterContext; - } - - return schema; - } - } - - private readonly ref struct GenerationState - { - private const int DefaultMaxDepth = 64; - private readonly List _currentPath = new(); - private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new(); - private readonly int _maxDepth; - - public GenerationState(JsonSchemaExporterOptions exporterOptions, JsonSerializerOptions options, NullabilityInfoContext? nullabilityInfoContext = null) - { - ExporterOptions = exporterOptions; - NullabilityInfoContext = nullabilityInfoContext ?? new(); - _maxDepth = options.MaxDepth is 0 ? DefaultMaxDepth : options.MaxDepth; - } - - public JsonSchemaExporterOptions ExporterOptions { get; } - public NullabilityInfoContext NullabilityInfoContext { get; } - public int CurrentDepth => _currentPath.Count; - - public void PushSchemaNode(string nodeId) - { - if (CurrentDepth == _maxDepth) - { - ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); - } - - _currentPath.Add(nodeId); - } - - public void PopSchemaNode() - { - _currentPath.RemoveAt(_currentPath.Count - 1); - } - - /// - /// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location. - /// - public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer) - { - (JsonTypeInfo, JsonPropertyInfo?) key = (context.TypeInfo, context.PropertyInfo); -#if NET - ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists); -#else - bool exists = _generated.TryGetValue(key, out string[]? pathToSchema); -#endif - if (exists) - { - existingJsonPointer = FormatJsonPointer(pathToSchema); - return true; - } -#if NET - pathToSchema = context._path; -#else - _generated[key] = context._path; -#endif - existingJsonPointer = null; - return false; - } - - public JsonSchemaExporterContext CreateContext( - JsonTypeInfo typeInfo, - JsonTypeInfo? baseTypeInfo, - Type? declaringType, - JsonPropertyInfo? propertyInfo, - ParameterInfo? parameterInfo, - ICustomAttributeProvider? propertyAttributeProvider) - { - return new JsonSchemaExporterContext(typeInfo, baseTypeInfo, declaringType, propertyInfo, parameterInfo, propertyAttributeProvider, _currentPath.ToArray()); - } - - private static string FormatJsonPointer(ReadOnlySpan path) - { - if (path.IsEmpty) - { - return "#"; - } - - StringBuilder sb = new(); - _ = sb.Append('#'); - - for (int i = 0; i < path.Length; i++) - { - string segment = path[i]; - if (segment.AsSpan().IndexOfAny('~', '/') != -1) - { -#pragma warning disable CA1307 // Specify StringComparison for clarity - segment = segment.Replace("~", "~0").Replace("/", "~1"); -#pragma warning restore CA1307 - } - - _ = sb.Append('/'); - _ = sb.Append(segment); - } - - return sb.ToString(); - } - } - - private static readonly Dictionary> _simpleTypeSchemaFactories = new() - { - [typeof(object)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, - [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(uint)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(ulong)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(sbyte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(short)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(int)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(long)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(float)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), - [typeof(double)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), - [typeof(decimal)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling), -#if NET6_0_OR_GREATER - [typeof(Half)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), -#endif -#if NET7_0_OR_GREATER - [typeof(UInt128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(Int128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), -#endif - [typeof(char)] = _ => new JsonSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1 }, - [typeof(string)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(byte[])] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(Memory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(ReadOnlyMemory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(DateTime)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, - [typeof(DateTimeOffset)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, - [typeof(TimeSpan)] = _ => new JsonSchema - { - Comment = "Represents a System.TimeSpan value.", - Type = JsonSchemaType.String, - Pattern = @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", - }, - -#if NET6_0_OR_GREATER - [typeof(DateOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date" }, - [typeof(TimeOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "time" }, -#endif - [typeof(Guid)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uuid" }, - [typeof(Uri)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uri" }, - [typeof(Version)] = _ => new JsonSchema - { - Comment = "Represents a version string.", - Type = JsonSchemaType.String, - Pattern = @"^\d+(\.\d+){1,3}$", - }, - - [typeof(JsonDocument)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonElement)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonNode)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonValue)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, - [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, - }; - - // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/JsonPrimitiveConverter.cs#L36-L69 - private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, JsonNumberHandling numberHandling, bool isIeeeFloatingPoint = false) - { - Debug.Assert(schemaType is JsonSchemaType.Integer or JsonSchemaType.Number, "schema type must be number or integer"); - Debug.Assert(!isIeeeFloatingPoint || schemaType is JsonSchemaType.Number, "If specifying IEEE the schema type must be number"); - - string? pattern = null; - - if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) - { - if (schemaType is JsonSchemaType.Integer) - { - pattern = @"^-?(?:0|[1-9]\d*)$"; - } - else if (isIeeeFloatingPoint) - { - pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$"; - } - else - { - pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; - } - - schemaType |= JsonSchemaType.String; - } - - if (isIeeeFloatingPoint && (numberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) - { - return new JsonSchema - { - AnyOf = new() - { - new JsonSchema { Type = schemaType, Pattern = pattern }, - new JsonSchema { Enum = new() { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" } }, - }, - }; - } - - return new JsonSchema { Type = schemaType, Pattern = pattern }; - } - - private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) - { - Debug.Assert(converter is null || ReflectionHelpers.IsBuiltInConverter(converter), "If specified the converter must be built-in."); - - if (converter is null) - { - return null; - } - - return ReflectionHelpers.GetElementConverter(converter); - } - - private static void ValidateOptions(JsonSerializerOptions options) - { - if (options.ReferenceHandler == ReferenceHandler.Preserve) - { - ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); - } - - options.MakeReadOnly(); - } - - private static void ResolveParameterInfo( - ParameterInfo parameter, - JsonTypeInfo parameterTypeInfo, - NullabilityInfoContext nullabilityInfoContext, - out bool hasDefaultValue, - out JsonNode? defaultValue, - out bool isNonNullable, - ref bool isRequired) - { - Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); - - // Incorporate the nullability information from the parameter. - isNonNullable = ReflectionHelpers.GetParameterNullability(nullabilityInfoContext, parameter) is NullabilityState.NotNull; - - if (parameter.HasDefaultValue) - { - // Append the default value to the description. - object? defaultVal = ReflectionHelpers.GetNormalizedDefaultValue(parameter); - defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); - hasDefaultValue = true; - } - else - { - // Parameter is not optional, mark as required. - isRequired = true; - defaultValue = null; - hasDefaultValue = false; - } - } - - // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 - private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) - { - Debug.Assert(typeInfo.Type.IsEnum && ReflectionHelpers.IsBuiltInConverter(converter), "must be using a built-in enum converter."); - - if (converter is JsonConverterFactory factory) - { - converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; - } - - ReflectionHelpers.GetEnumConverterConfig(converter, out JsonNamingPolicy? namingPolicy, out bool allowString); - - if (allowString) - { - // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings - // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. - - if (typeInfo.Type.GetCustomAttribute() is not null) - { - // Do not report enum values in case of flags. - return new() { Type = JsonSchemaType.String }; - } - - JsonArray enumValues = new(); - foreach (string name in Enum.GetNames(typeInfo.Type)) - { - // This does not account for custom names specified via the new - // JsonStringEnumMemberNameAttribute introduced in .NET 9. - string effectiveName = namingPolicy?.ConvertName(name) ?? name; - enumValues.Add((JsonNode)effectiveName); - } - - return new() { Enum = enumValues }; - } - - return new() { Type = JsonSchemaType.Integer }; - } - - private static class JsonSchemaConstants - { - public const string SchemaPropertyName = "$schema"; - public const string RefPropertyName = "$ref"; - public const string CommentPropertyName = "$comment"; - public const string TitlePropertyName = "title"; - public const string DescriptionPropertyName = "description"; - public const string TypePropertyName = "type"; - public const string FormatPropertyName = "format"; - public const string PatternPropertyName = "pattern"; - public const string PropertiesPropertyName = "properties"; - public const string RequiredPropertyName = "required"; - public const string ItemsPropertyName = "items"; - public const string AdditionalPropertiesPropertyName = "additionalProperties"; - public const string EnumPropertyName = "enum"; - public const string NotPropertyName = "not"; - public const string AnyOfPropertyName = "anyOf"; - public const string ConstPropertyName = "const"; - public const string DefaultPropertyName = "default"; - public const string MinLengthPropertyName = "minLength"; - public const string MaxLengthPropertyName = "maxLength"; - } - - private static class ThrowHelpers - { - [DoesNotReturn] - public static void ThrowInvalidOperationException_MaxDepthReached() => - throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); - - [DoesNotReturn] - public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => - throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs deleted file mode 100644 index 3602ee46df4..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System; -using System.Reflection; -using System.Text.Json.Serialization.Metadata; - -namespace System.Text.Json.Schema; - -/// -/// Defines the context in which a JSON schema within a type graph is being generated. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal readonly struct JsonSchemaExporterContext -{ -#pragma warning disable IDE1006 // Naming Styles - internal readonly string[] _path; -#pragma warning restore IDE1006 // Naming Styles - - internal JsonSchemaExporterContext( - JsonTypeInfo typeInfo, - JsonTypeInfo? baseTypeInfo, - Type? declaringType, - JsonPropertyInfo? propertyInfo, - ParameterInfo? parameterInfo, - ICustomAttributeProvider? propertyAttributeProvider, - string[] path) - { - TypeInfo = typeInfo; - DeclaringType = declaringType; - BaseTypeInfo = baseTypeInfo; - PropertyInfo = propertyInfo; - ParameterInfo = parameterInfo; - PropertyAttributeProvider = propertyAttributeProvider; - _path = path; - } - - /// - /// Gets the path to the schema document currently being generated. - /// - public ReadOnlySpan Path => _path; - - /// - /// Gets the for the type being processed. - /// - public JsonTypeInfo TypeInfo { get; } - - /// - /// Gets the declaring type of the property or parameter being processed. - /// - public Type? DeclaringType { get; } - - /// - /// Gets the type info for the polymorphic base type if generated as a derived type. - /// - public JsonTypeInfo? BaseTypeInfo { get; } - - /// - /// Gets the if the schema is being generated for a property. - /// - public JsonPropertyInfo? PropertyInfo { get; } - - /// - /// Gets the if a constructor parameter - /// has been associated with the accompanying . - /// - public ParameterInfo? ParameterInfo { get; } - - /// - /// Gets the corresponding to the property or field being processed. - /// - public ICustomAttributeProvider? PropertyAttributeProvider { get; } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs deleted file mode 100644 index 53a269ea612..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System; -using System.Text.Json.Nodes; - -namespace System.Text.Json.Schema; - -/// -/// Controls the behavior of the class. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal sealed class JsonSchemaExporterOptions -{ - /// - /// Gets the default configuration object used by . - /// - public static JsonSchemaExporterOptions Default { get; } = new(); - - /// - /// Gets a value indicating whether non-nullable schemas should be generated for null oblivious reference types. - /// - /// - /// Defaults to . Due to restrictions in the run-time representation of nullable reference types - /// most occurrences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule - /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. - /// - public bool TreatNullObliviousAsNonNullable { get; init; } - - /// - /// Gets a callback that is invoked for every schema that is generated within the type graph. - /// - public Func? TransformSchemaNode { get; init; } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs deleted file mode 100644 index bd9b132cd0f..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -#pragma warning disable SA1623 // Property summary documentation should match accessors - -namespace System.Reflection -{ - /// - /// A class that represents nullability info. - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfo - { - internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, - NullabilityInfo? elementType, NullabilityInfo[] typeArguments) - { - Type = type; - ReadState = readState; - WriteState = writeState; - ElementType = elementType; - GenericTypeArguments = typeArguments; - } - - /// - /// The of the member or generic parameter - /// to which this NullabilityInfo belongs. - /// - public Type Type { get; } - - /// - /// The nullability read state of the member. - /// - public NullabilityState ReadState { get; internal set; } - - /// - /// The nullability write state of the member. - /// - public NullabilityState WriteState { get; internal set; } - - /// - /// If the member type is an array, gives the of the elements of the array, null otherwise. - /// - public NullabilityInfo? ElementType { get; } - - /// - /// If the member type is a generic type, gives the array of for each type parameter. - /// - public NullabilityInfo[] GenericTypeArguments { get; } - } - - /// - /// An enum that represents nullability state. - /// - internal enum NullabilityState - { - /// - /// Nullability context not enabled (oblivious). - /// - Unknown, - - /// - /// Non nullable value or reference type. - /// - NotNull, - - /// - /// Nullable value or reference type. - /// - Nullable, - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs deleted file mode 100644 index 3edee1b9cb8..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs +++ /dev/null @@ -1,661 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S4136 // Method overloads should be grouped together -#pragma warning disable SA1202 // Elements should be ordered by access -#pragma warning disable IDE1006 // Naming Styles - -namespace System.Reflection -{ - /// - /// Provides APIs for populating nullability information/context from reflection members: - /// , , and . - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfoContext - { - private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; - private readonly Dictionary _publicOnlyModules = new(); - private readonly Dictionary _context = new(); - - [Flags] - private enum NotAnnotatedStatus - { - None = 0x0, // no restriction, all members annotated - Private = 0x1, // private members not annotated - Internal = 0x2, // internal members not annotated - } - - private NullabilityState? GetNullableContext(MemberInfo? memberInfo) - { - while (memberInfo != null) - { - if (_context.TryGetValue(memberInfo, out NullabilityState state)) - { - return state; - } - - foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) - { - if (attribute.AttributeType.Name == "NullableContextAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - state = TranslateByte(attribute.ConstructorArguments[0].Value); - _context.Add(memberInfo, state); - return state; - } - } - - memberInfo = memberInfo.DeclaringType; - } - - return null; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the parameterInfo parameter is null. - /// . - public NullabilityInfo Create(ParameterInfo parameterInfo) - { - IList attributes = parameterInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) - ? NullableAttributeStateParser.Unknown - : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); - - if (nullability.ReadState != NullabilityState.Unknown) - { - CheckParameterMetadataType(parameterInfo, nullability); - } - - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) - { - ParameterInfo? metaParameter; - MemberInfo metaMember; - - switch (parameter.Member) - { - case ConstructorInfo ctor: - var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); - metaMember = metaCtor; - metaParameter = GetMetaParameter(metaCtor, parameter); - break; - - case MethodInfo method: - MethodInfo metaMethod = GetMethodMetadataDefinition(method); - metaMember = metaMethod; - metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); - break; - - default: - return; - } - - if (metaParameter != null) - { - CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); - } - } - - private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) - { - var parameters = metaMethod.GetParameters(); - for (int i = 0; i < parameters.Length; i++) - { - if (parameter.Position == i && - parameter.Name == parameters[i].Name) - { - return parameters[i]; - } - } - - return null; - } - - private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) - { - if (method.IsGenericMethod && !method.IsGenericMethodDefinition) - { - method = method.GetGenericMethodDefinition(); - } - - return (MethodInfo)GetMemberMetadataDefinition(method); - } - - private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) - { - var codeAnalysisReadState = NullabilityState.Unknown; - var codeAnalysisWriteState = NullabilityState.Unknown; - - foreach (CustomAttributeData attribute in attributes) - { - if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") - { - if (attribute.AttributeType.Name == "NotNullAttribute") - { - codeAnalysisReadState = NullabilityState.NotNull; - } - else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || - attribute.AttributeType.Name == "MaybeNullWhenAttribute") && - codeAnalysisReadState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisReadState = NullabilityState.Nullable; - } - else if (attribute.AttributeType.Name == "DisallowNullAttribute") - { - codeAnalysisWriteState = NullabilityState.NotNull; - } - else if (attribute.AttributeType.Name == "AllowNullAttribute" && - codeAnalysisWriteState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisWriteState = NullabilityState.Nullable; - } - } - } - - if (codeAnalysisReadState != NullabilityState.Unknown) - { - nullability.ReadState = codeAnalysisReadState; - } - - if (codeAnalysisWriteState != NullabilityState.Unknown) - { - nullability.WriteState = codeAnalysisWriteState; - } - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the propertyInfo parameter is null. - /// . - public NullabilityInfo Create(PropertyInfo propertyInfo) - { - MethodInfo? getter = propertyInfo.GetGetMethod(true); - MethodInfo? setter = propertyInfo.GetSetMethod(true); - bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) - && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); - NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); - NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); - - if (getter != null) - { - CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); - } - else - { - nullability.ReadState = NullabilityState.Unknown; - } - - if (setter != null) - { - CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); - } - else - { - nullability.WriteState = NullabilityState.Unknown; - } - - return nullability; - } - - private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) - { - if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && - IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) - { - return true; - } - - return false; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the eventInfo parameter is null. - /// . - public NullabilityInfo Create(EventInfo eventInfo) - { - return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); - } - - /// - /// Populates for the given - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the fieldInfo parameter is null. - /// . - public NullabilityInfo Create(FieldInfo fieldInfo) - { - IList attributes = fieldInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) - { - if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && - IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) - { - return true; - } - - return false; - } - - private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) - { - if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) - { - value = PopulateAnnotationInfo(module.GetCustomAttributesData()); - _publicOnlyModules.Add(module, value); - } - - if (value == NotAnnotatedStatus.None) - { - return false; - } - - if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || - (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) - { - return true; - } - - return false; - } - - private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) - { - return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; - } - else - { - return NotAnnotatedStatus.Private; - } - } - } - - return NotAnnotatedStatus.None; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) - { - int index = 0; - NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); - - if (nullability.ReadState != NullabilityState.Unknown) - { - TryLoadGenericMetaTypeNullability(memberInfo, nullability); - } - - return nullability; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) - { - NullabilityState state = NullabilityState.Unknown; - NullabilityInfo? elementState = null; - NullabilityInfo[] genericArgumentsState = Array.Empty(); - Type underlyingType = type; - - if (underlyingType.IsByRef || underlyingType.IsPointer) - { - underlyingType = underlyingType.GetElementType()!; - } - - if (underlyingType.IsValueType) - { - if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) - { - underlyingType = nullableUnderlyingType; - state = NullabilityState.Nullable; - } - else - { - state = NullabilityState.NotNull; - } - - if (underlyingType.IsGenericType) - { - ++index; - } - } - else - { - if (!parser.ParseNullableState(index++, ref state) - && GetNullableContext(memberInfo) is { } contextState) - { - state = contextState; - } - - if (underlyingType.IsArray) - { - elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); - } - } - - if (underlyingType.IsGenericType) - { - Type[] genericArguments = underlyingType.GetGenericArguments(); - genericArgumentsState = new NullabilityInfo[genericArguments.Length]; - - for (int i = 0; i < genericArguments.Length; i++) - { - genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); - } - } - - return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); - } - - private static NullableAttributeStateParser CreateParser(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullableAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); - } - } - - return new NullableAttributeStateParser(null); - } - - private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) - { - MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); - Type? metaType = null; - if (metaMember is FieldInfo field) - { - metaType = field.FieldType; - } - else if (metaMember is PropertyInfo property) - { - metaType = GetPropertyMetaType(property); - } - - if (metaType != null) - { - CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); - } - } - - private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) - { - Type? type = member.DeclaringType; - if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) - { - return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); - } - - return member; - } - - private static Type GetPropertyMetaType(PropertyInfo property) - { - if (property.GetGetMethod(true) is MethodInfo method) - { - return method.ReturnType; - } - - return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; - } - - private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) - { - if (metaType.IsGenericParameter) - { - if (nullability.ReadState == NullabilityState.NotNull) - { - _ = TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); - } - } - else if (metaType.ContainsGenericParameters) - { - if (nullability.GenericTypeArguments.Length > 0) - { - Type[] genericArguments = metaType.GetGenericArguments(); - - for (int i = 0; i < genericArguments.Length; i++) - { - CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); - } - } - else if (nullability.ElementType is { } elementNullability && metaType.IsArray) - { - CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); - } - - // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this - // will be a no-op regardless - else if (metaType.IsByRef) - { - CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); - } - } - } - - private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter, "must be generic parameter"); - - if (reflectedType is not null - && !genericParameter.IsGenericMethodParameter() - && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) - { - return true; - } - - if (IsValueTypeOrValueTypeByRef(nullability.Type)) - { - return true; - } - - var state = NullabilityState.Unknown; - if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) - { - nullability.ReadState = state; - nullability.WriteState = state; - return true; - } - - if (GetNullableContext(genericParameter) is { } contextState) - { - nullability.ReadState = contextState; - nullability.WriteState = contextState; - return true; - } - - return false; - } - - private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter(), "must be generic parameter"); - - Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; - if (genericParameter.DeclaringType == contextTypeDefinition) - { - return false; - } - - Type? baseType = contextTypeDefinition.BaseType; - if (baseType is null) - { - return false; - } - - if (!baseType.IsGenericType - || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) - { - return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); - } - - Type[] genericArguments = baseType.GetGenericArguments(); - Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; - if (genericArgument.IsGenericParameter) - { - return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); - } - - NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); - int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself - for (int i = 0; i < genericParameter.GenericParameterPosition; i++) - { - nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); - } - - return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); - - static int CountNullabilityStates(Type type) - { - Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; - if (underlyingType.IsGenericType) - { - int count = 1; - foreach (Type genericArgument in underlyingType.GetGenericArguments()) - { - count += CountNullabilityStates(genericArgument); - } - - return count; - } - - if (underlyingType.HasElementType) - { - return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); - } - - return type.IsValueType ? 0 : 1; - } - } - -#pragma warning disable SA1204 // Static elements should appear before instance elements - private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) -#pragma warning restore SA1204 // Static elements should appear before instance elements - { - bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); - if (!isValueType) - { - var state = NullabilityState.Unknown; - if (!parser.ParseNullableState(index, ref state)) - { - return false; - } - - nullability.ReadState = state; - nullability.WriteState = state; - } - - if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) - { - index++; - } - - if (nullability.GenericTypeArguments.Length > 0) - { - foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) - { - _ = TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); - } - } - else if (nullability.ElementType is { } elementTypeNullability) - { - _ = TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); - } - - return true; - } - - private static NullabilityState TranslateByte(object? value) - { - return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; - } - - private static NullabilityState TranslateByte(byte b) => - b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; - - private static bool IsValueTypeOrValueTypeByRef(Type type) => - type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); - - private readonly struct NullableAttributeStateParser - { - private static readonly object UnknownByte = (byte)0; - - private readonly object? _nullableAttributeArgument; - - public NullableAttributeStateParser(object? nullableAttributeArgument) - { - _nullableAttributeArgument = nullableAttributeArgument; - } - - public static NullableAttributeStateParser Unknown => new(UnknownByte); - - public bool ParseNullableState(int index, ref NullabilityState state) - { - switch (_nullableAttributeArgument) - { - case byte b: - state = TranslateByte(b); - return true; - case ReadOnlyCollection args - when index < args.Count && args[index].Value is byte elementB: - state = TranslateByte(elementB); - return true; - default: - return false; - } - } - } - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs deleted file mode 100644 index 1ee573a0020..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - -namespace System.Reflection -{ - /// - /// Polyfills for System.Private.CoreLib internals. - /// - [ExcludeFromCodeCoverage] - internal static class NullabilityInfoHelpers - { - public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) - { - const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - foreach (var info in type.GetMembers(all)) - { - if (info.HasSameMetadataDefinitionAs(member)) - { - return info; - } - } - - throw new MissingMemberException(type.FullName, member.Name); - } - - // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs - public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) - { - return target.MetadataToken == other.MetadataToken && - target.Module.Equals(other.Module); - } - - // https://github.com/dotnet/runtime/issues/23493 - public static bool IsGenericMethodParameter(this Type target) - { - return target.IsGenericParameter && - target.DeclaringMethod != null; - } - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/README.md b/src/Shared/JsonSchemaExporter/README.md deleted file mode 100644 index 1a4d13c5841..00000000000 --- a/src/Shared/JsonSchemaExporter/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# JsonSchemaExporter - -Provides a polyfill for the [.NET 9 `JsonSchemaExporter` component](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) that is compatible with all supported targets using System.Text.Json version 8. - -To use this in your project, add the following to your `.csproj` file: - -```xml - - true - -``` diff --git a/src/Shared/ServerSentEvents/SseItem.cs b/src/Shared/ServerSentEvents/SseItem.cs index 9c6092fd3cf..013d2fe9098 100644 --- a/src/Shared/ServerSentEvents/SseItem.cs +++ b/src/Shared/ServerSentEvents/SseItem.cs @@ -17,12 +17,6 @@ internal readonly struct SseItem [EditorBrowsable(EditorBrowsableState.Never)] internal readonly string? _eventType; - /// The event's id. - private readonly string? _eventId; - - /// The event's reconnection interval. - private readonly TimeSpan? _reconnectionInterval; - /// Initializes a new instance of the struct. /// The event's payload. /// The event's type. @@ -48,7 +42,7 @@ public SseItem(T data, string? eventType = null) /// Thrown when the value contains a line break. public string? EventId { - get => _eventId; + get; init { if (value.AsSpan().ContainsLineBreaks() is true) @@ -56,7 +50,7 @@ public string? EventId ThrowHelper.ThrowArgumentException_CannotContainLineBreaks(nameof(EventId)); } - _eventId = value; + field = value; } } @@ -66,7 +60,7 @@ public string? EventId /// public TimeSpan? ReconnectionInterval { - get => _reconnectionInterval; + get; init { if (value < TimeSpan.Zero) @@ -74,7 +68,7 @@ public TimeSpan? ReconnectionInterval ThrowHelper.ThrowArgumentException_CannotBeNegative(nameof(ReconnectionInterval)); } - _reconnectionInterval = value; + field = value; } } } diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index d25c011a05f..62feb6759b6 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -26,6 +26,11 @@ 85 + + + + + diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs index 1d74dc68713..12771c42767 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs @@ -580,6 +580,37 @@ public void EventNameTests() Assert.Equal("M1_Event", logRecord.Id.Name); } + [Fact] + public void EventIdTests() + { + using var logger = Utils.GetLogger(); + var collector = logger.FakeLogCollector; + + collector.Clear(); + EventNameTestExtensions.M1(LogLevel.Warning, logger, "Eight"); + Assert.Equal(1, collector.Count); + + var firstEventId = collector.LatestRecord.Id; + Assert.NotEqual(0, firstEventId.Id); + Assert.Equal("M1_Event", firstEventId.Name); + + collector.Clear(); + EventNameTestExtensions.M1_Event(logger, "Nine"); + Assert.Equal(1, collector.Count); + + var secondEventId = collector.LatestRecord.Id; + Assert.Equal(firstEventId.Id, secondEventId.Id); // Same EventName means same generated EventId + Assert.Equal(nameof(EventNameTestExtensions.M1_Event), secondEventId.Name); + + collector.Clear(); + EventNameTestExtensions.M2(logger, "Ten"); + Assert.Equal(1, collector.Count); + + var thirdEventId = collector.LatestRecord.Id; + Assert.NotEqual(thirdEventId.Id, secondEventId.Id); // Different EventName means different generated EventId + Assert.Equal(nameof(EventNameTestExtensions.M2), thirdEventId.Name); + } + [Fact] public void NestedClassTests() { diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs index a8c5d752fc9..589f237d2cc 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs @@ -587,4 +587,26 @@ public void LogPropertiesReadonlyRecordStructArgument() latestRecord.StructuredState.Should().NotBeNull().And.Equal(expectedState); } + + [Fact] + public void LogPropertiesCorrectlyLogsObjectFromAnotherAssembly() + { + LogObjectFromAnotherAssembly(_logger, new ObjectToLog + { + PropertyToIgnore = "Foo", + PropertyToLog = "Bar", + FieldToLog = new FieldToLog { Name = "Fizz", Value = "Buzz" } + }); + + Assert.Equal(1, _logger.Collector.Count); + + var state = _logger.Collector.LatestRecord.StructuredState! + .ToDictionary(p => p.Key, p => p.Value); + + Assert.Equal(4, state.Count); + Assert.Contains("{OriginalFormat}", state); + Assert.Contains("logObject.PropertyToLog", state); + Assert.Contains("logObject.FieldToLog.Name", state); + Assert.Contains("logObject.FieldToLog.Value", state); + } } diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj b/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj index 84621f147e7..d4a72e9e371 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj +++ b/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj @@ -24,5 +24,6 @@ + diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs new file mode 100644 index 00000000000..6eb8cc08d5e --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Gen.Logging.Test; + +public class FieldToLog +{ + public string? Name { get; set; } + public string? Value { get; set; } +} diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj new file mode 100644 index 00000000000..0f3e6d3bedf --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj @@ -0,0 +1,15 @@ + + + Microsoft.Gen.Logging.Test + Test classes for Microsoft.Gen.Logging.Generated.Tests. + + + + $(TestNetCoreTargetFrameworks) + $(TestNetCoreTargetFrameworks)$(ConditionalNet462) + + + + + + diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs new file mode 100644 index 00000000000..c8e071ce6d9 --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Gen.Logging.Test; + +public class ObjectToLog +{ + [LogPropertyIgnore] + public string? PropertyToIgnore { get; set; } + + public string? PropertyToLog { get; set; } + + [LogProperties] + public FieldToLog? FieldToLog { get; set; } +} diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs index 48385c1fd0a..83c239499e1 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs @@ -12,5 +12,13 @@ internal static partial class EventNameTestExtensions [LoggerMessage(EventName = "M1_Event")] public static partial void M1(LogLevel level, ILogger logger, string p0); + + // This one should have the same generated EventId as the method above + [LoggerMessage(LogLevel.Debug)] + public static partial void M1_Event(ILogger logger, string p0); + + // This one should have different generated EventId as the methods above + [LoggerMessage(LogLevel.Error)] + public static partial void M2(ILogger logger, string p0); } } diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs index d2b9c05b05b..3e0e8683ba5 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using Microsoft.Extensions.Logging; +using Microsoft.Gen.Logging.Test; namespace TestClasses { @@ -195,10 +196,10 @@ public MyDerivedClass(double privateFieldValue) internal interface IMyInterface { - public int IntProperty { get; set; } + int IntProperty { get; set; } [LogProperties] - public LeafTransitiveBaseClass? TransitiveProp { get; set; } + LeafTransitiveBaseClass? TransitiveProp { get; set; } } internal sealed class MyInterfaceImpl : IMyInterface @@ -244,5 +245,8 @@ public override string ToString() [LoggerMessage(6, LogLevel.Information, "Testing interface-typed argument here...")] public static partial void LogMethodInterfaceArg(ILogger logger, [LogProperties] IMyInterface complexParam); + + [LoggerMessage(7, LogLevel.Information, "Testing logging a complex object residing in another assembly...")] + public static partial void LogObjectFromAnotherAssembly(ILogger logger, [LogProperties] ObjectToLog logObject); } } diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs b/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs index 58012c4915b..a53f1711bd3 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs @@ -45,6 +45,7 @@ public async Task TestEmitter() Assembly.GetAssembly(typeof(IRedactorProvider))!, Assembly.GetAssembly(typeof(PrivateDataAttribute))!, Assembly.GetAssembly(typeof(BigInteger))!, + Assembly.GetAssembly(typeof(ObjectToLog))! }, sources, symbols) diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj b/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj index e566a5dbe9f..9090a895c67 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj +++ b/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs b/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs index d6496ac10f3..54a19ea2e86 100644 --- a/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs +++ b/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs @@ -81,6 +81,7 @@ class CustomClass {{ }} _ = Assert.Single(d); Assert.Equal(DiagDescriptors.ErrorInvalidMethodReturnType.Id, d[0].Id); + Assert.Contains($"must not return '{returnType}'", d[0].GetMessage()); } [Theory] diff --git a/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/PerIncomingRequestLoggingBuilderExtensionsTests.cs b/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/PerIncomingRequestLoggingBuilderExtensionsTests.cs index 31ea8b45d4a..5bad831ab68 100644 --- a/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/PerIncomingRequestLoggingBuilderExtensionsTests.cs +++ b/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/PerIncomingRequestLoggingBuilderExtensionsTests.cs @@ -3,15 +3,23 @@ #if NET9_0_OR_GREATER using System; using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.Buffering; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.Buffering; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Test; using Microsoft.Extensions.Options; using Xunit; using PerRequestLogBuffer = Microsoft.Extensions.Diagnostics.Buffering.PerRequestLogBuffer; -namespace Microsoft.Extensions.Logging; +namespace Microsoft.AspNetCore.Diagnostics.Logging.Test; public class PerIncomingRequestLoggingBuilderExtensionsTests { @@ -82,5 +90,72 @@ public void WhenConfigurationActionProvided_RegistersInDI() Assert.NotNull(options.CurrentValue); Assert.Equivalent(expectedData, options.CurrentValue.Rules); } + + [Fact] + public async Task WhenConfigUpdated_PicksUpConfigChanges() + { + List initialData = + [ + new(categoryName: "Program.MyLogger", logLevel: LogLevel.Information, eventId: 1, eventName: "number one"), + new(logLevel : LogLevel.Information), + ]; + List updatedData = + [ + new(logLevel: LogLevel.Information), + ]; + string jsonConfig = + @" +{ + ""PerIncomingRequestLogBuffering"": { + ""Rules"": [ + { + ""CategoryName"": ""Program.MyLogger"", + ""LogLevel"": ""Information"", + ""EventId"": 1, + ""EventName"": ""number one"", + }, + { + ""LogLevel"": ""Information"", + }, + ] + } +} +"; + + using ConfigurationRoot config = TestConfiguration.Create(() => jsonConfig); + using IHost host = await FakeHost.CreateBuilder() + .ConfigureWebHost(builder => builder + .UseTestServer() + .ConfigureServices(x => x.AddRouting()) + .ConfigureLogging(loggingBuilder => loggingBuilder + .AddPerIncomingRequestBuffer(config)) + .Configure(app => app.UseRouting())) + .StartAsync(); + + IOptionsMonitor? options = host.Services.GetService>(); + Assert.NotNull(options); + Assert.NotNull(options.CurrentValue); + Assert.Equivalent(initialData, options.CurrentValue.Rules); + + jsonConfig = +@" +{ + ""PerIncomingRequestLogBuffering"": { + ""Rules"": [ + { + ""LogLevel"": ""Information"", + }, + ] + } +} +"; + config.Reload(); + + var bufferManager = host.Services.GetRequiredService() as PerRequestLogBufferManager; + Assert.NotNull(bufferManager); + Assert.Equivalent(updatedData, bufferManager.Options.CurrentValue.Rules, strict: true); + + await host.StopAsync(); + } } #endif diff --git a/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/TestConfiguration.cs b/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/TestConfiguration.cs new file mode 100644 index 00000000000..832c120873a --- /dev/null +++ b/test/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware.Tests/Buffering/TestConfiguration.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Json; + +namespace Microsoft.Extensions.Logging.Test; + +internal class TestConfiguration : JsonConfigurationProvider +{ + private Func _json; + + public TestConfiguration(JsonConfigurationSource source, Func json) + : base(source) + { + _json = json; + } + + public static ConfigurationRoot Create(Func getJson) + { + var provider = new TestConfiguration(new JsonConfigurationSource { Optional = true }, getJson); + return new ConfigurationRoot(new List { provider }); + } + + public override void Load() + { + var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + writer.Write(_json()); + writer.Flush(); + stream.Seek(0, SeekOrigin.Begin); + Load(stream); + } +} diff --git a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs index ce23cb2dfb0..15000ccb21a 100644 --- a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs +++ b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs @@ -23,13 +23,10 @@ public sealed class HeaderParsingFeatureTests private readonly IOptions _options; private readonly IServiceCollection _services; private readonly FakeLogger _logger = new(); - private IHeaderRegistry? _registry; - private HttpContext? _context; - private IHeaderRegistry Registry => _registry ??= new HeaderRegistry(_services.BuildServiceProvider(), _options); + private IHeaderRegistry Registry => field ??= new HeaderRegistry(_services.BuildServiceProvider(), _options); - private HttpContext Context - => _context ??= new DefaultHttpContext { RequestServices = _services.BuildServiceProvider() }; + private HttpContext Context => field ??= new DefaultHttpContext { RequestServices = _services.BuildServiceProvider() }; public HeaderParsingFeatureTests() { @@ -48,7 +45,7 @@ public void Parses_header() var key = Registry.Register(CommonHeaders.Date); Context.Request.Headers["Date"] = date; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; Assert.True(feature.TryGetHeaderValue(key, out var value, out var _)); Assert.Equal(date, value.ToString("R", CultureInfo.InvariantCulture)); @@ -67,7 +64,7 @@ public void Parses_multiple_headers() Context.Request.Headers["Date"] = currentDate; Context.Request.Headers["Test"] = futureDate; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; Assert.True(feature.TryGetHeaderValue(key, out var value, out var result)); Assert.Equal(currentDate, value.ToString("R", CultureInfo.InvariantCulture)); @@ -89,7 +86,7 @@ public void Parses_with_late_binding() Context.Request.Headers["Date"] = date; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); @@ -103,7 +100,7 @@ public void TryParse_returns_false_on_header_not_found() { using var meter = new Meter(nameof(TryParse_returns_false_on_header_not_found)); var metrics = GetMockedMetrics(meter); - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); Assert.False(feature.TryGetHeaderValue(key, out var value, out var _)); @@ -120,7 +117,7 @@ public void TryParse_returns_default_on_header_not_found() var date = DateTimeOffset.Now.ToString("R", CultureInfo.InvariantCulture); _options.Value.DefaultValues.Add("Date", date); - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); Assert.True(feature.TryGetHeaderValue(key, out var value, out var result)); @@ -138,7 +135,7 @@ public void TryParse_returns_false_on_error() using var metricCollector = new MetricCollector(meter, "aspnetcore.header_parsing.parse_errors"); Context.Request.Headers["Date"] = "Not a date."; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); Assert.False(feature.TryGetHeaderValue(key, out var value, out var result)); @@ -161,7 +158,7 @@ public void Dispose_resets_state_and_returns_to_pool() var metrics = GetMockedMetrics(meter); var pool = new Mock>(MockBehavior.Strict); - var helper = new HeaderParsingFeature.PoolHelper(pool.Object, Registry, _logger, metrics); + var helper = new HeaderParsingFeature.PoolHelper(pool.Object, _logger, metrics); helper.Feature.Context = Context; pool.Setup(x => x.Return(helper)); @@ -195,8 +192,8 @@ public void CachingWorks() Context.Request.Headers[HeaderNames.CacheControl] = "max-age=604800"; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; - var feature2 = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; + var feature2 = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.CacheControl); Assert.True(feature.TryGetHeaderValue(key, out var value1, out var error1)); diff --git a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs index 0932aab6ac0..1a826f086c6 100644 --- a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs +++ b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs @@ -146,8 +146,8 @@ public void ContentDisposition_ReturnsParsedValue() { var sv = new StringValues("attachment; filename=\"cool.html\""); Assert.True(ContentDispositionHeaderValueParser.Instance.TryParse(sv, out var result, out var error)); - Assert.Equal("cool.html", result.FileName); - Assert.Equal("attachment", result.DispositionType); + Assert.Equal("cool.html", result.FileName.ToString()); + Assert.Equal("attachment", result.DispositionType.ToString()); Assert.Null(error); } @@ -174,8 +174,8 @@ public void MediaType_ReturnsParsedValue() { var sv = new StringValues("text/html; charset=UTF-8"); Assert.True(MediaTypeHeaderValueParser.Instance.TryParse(sv, out var result, out var error)); - Assert.Equal("text/html", result.MediaType); - Assert.Equal("UTF-8", result.Charset); + Assert.Equal("text/html", result.MediaType.ToString()); + Assert.Equal("UTF-8", result.Charset.ToString()); Assert.Null(error); } @@ -203,8 +203,8 @@ public void MediaTypes_ReturnsParsedValue() var sv = new StringValues("text/html; charset=UTF-8"); Assert.True(MediaTypeHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Single(result); - Assert.Equal("text/html", result[0].MediaType); - Assert.Equal("UTF-8", result[0].Charset); + Assert.Equal("text/html", result[0].MediaType.ToString()); + Assert.Equal("UTF-8", result[0].Charset.ToString()); Assert.Null(error); } @@ -223,7 +223,7 @@ public void EntityTag_ReturnsParsedValue() var sv = new StringValues("\"HelloWorld\""); Assert.True(EntityTagHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Single(result!); - Assert.Equal("\"HelloWorld\"", result[0].Tag); + Assert.Equal("\"HelloWorld\"", result[0].Tag.ToString()); Assert.Null(error); } @@ -242,7 +242,7 @@ public void StringQuality_ReturnsParsedValue() var sv = new StringValues("en-US"); Assert.True(StringWithQualityHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Single(result!); - Assert.Equal("en-US", result[0].Value); + Assert.Equal("en-US", result[0].Value.ToString()); Assert.Null(error); } @@ -252,8 +252,8 @@ public void StringQuality_Multi() var sv = new StringValues("en-US,en;q=0.5"); Assert.True(StringWithQualityHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Equal(2, result.Count); - Assert.Equal("en-US", result[0].Value); - Assert.Equal("en", result[1].Value); + Assert.Equal("en-US", result[0].Value.ToString()); + Assert.Equal("en", result[1].Value.ToString()); Assert.Equal(0.5, result[1].Quality); Assert.Null(error); } @@ -300,7 +300,7 @@ public void Range_ReturnsParsedValue() { var sv = new StringValues("bytes=200-1000"); Assert.True(RangeHeaderValueParser.Instance.TryParse(sv, out var result, out var error)); - Assert.Equal("bytes", result!.Unit); + Assert.Equal("bytes", result!.Unit.ToString()); Assert.Single(result.Ranges); Assert.Equal(200, result.Ranges.Single().From); Assert.Equal(1000, result.Ranges.Single().To); @@ -341,7 +341,7 @@ public void RangeCondition_ReturnsParsedValue() sv = new StringValues("\"67ab43\""); Assert.True(RangeConditionHeaderValueParser.Instance.TryParse(sv, out result, out error)); - Assert.Equal("\"67ab43\"", result!.EntityTag!.Tag); + Assert.Equal("\"67ab43\"", result!.EntityTag!.Tag.ToString()); Assert.Null(error); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIToolTests.cs deleted file mode 100644 index 5e092d107ec..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIToolTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class AIToolTests -{ - [Fact] - public void Constructor_Roundtrips() - { - DerivedAITool tool = new(); - Assert.Equal(nameof(DerivedAITool), tool.Name); - Assert.Equal(nameof(DerivedAITool), tool.ToString()); - Assert.Empty(tool.Description); - Assert.Empty(tool.AdditionalProperties); - } - - private sealed class DerivedAITool : AITool; -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs index 09f515fa066..0635d45250c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs @@ -12,7 +12,7 @@ public class AdditionalPropertiesDictionaryTests [Fact] public void Constructor_Roundtrips() { - AdditionalPropertiesDictionary d = new(); + AdditionalPropertiesDictionary d = []; Assert.Empty(d); d = new(new Dictionary { ["key1"] = "value1" }); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index 72985108c6e..546b93a7f20 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -12,6 +12,44 @@ namespace Microsoft.Extensions.AI; internal static class AssertExtensions { + /// + /// Asserts that the two message lists are equal. + /// + public static void EqualMessageLists(List expectedMessages, List actualMessages) + { + Assert.Equal(expectedMessages.Count, actualMessages.Count); + for (int i = 0; i < expectedMessages.Count; i++) + { + var expectedMessage = expectedMessages[i]; + var chatMessage = actualMessages[i]; + + Assert.Equal(expectedMessage.Role, chatMessage.Role); + Assert.Equal(expectedMessage.Text, chatMessage.Text); + Assert.Equal(expectedMessage.GetType(), chatMessage.GetType()); + + Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count); + for (int j = 0; j < expectedMessage.Contents.Count; j++) + { + var expectedItem = expectedMessage.Contents[j]; + var chatItem = chatMessage.Contents[j]; + + Assert.Equal(expectedItem.GetType(), chatItem.GetType()); + Assert.Equal(expectedItem.ToString(), chatItem.ToString()); + if (expectedItem is FunctionCallContent expectedFunctionCall) + { + var chatFunctionCall = (FunctionCallContent)chatItem; + Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name); + AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments); + } + else if (expectedItem is FunctionResultContent expectedFunctionResult) + { + var chatFunctionResult = (FunctionResultContent)chatItem; + AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result); + } + } + } + } + /// /// Asserts that the two function call parameters are equal, up to JSON equivalence. /// @@ -53,21 +91,29 @@ public static void EqualFunctionCallParameters( public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null) => AreJsonEquivalentValues(expected, actual, options); - private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + /// + /// Asserts that the two JSON values are equal. + /// + public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null) { - options ??= AIJsonUtilities.DefaultOptions; - JsonElement expectedElement = NormalizeToElement(expected, options); - JsonElement actualElement = NormalizeToElement(actual, options); if (!JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(expectedElement, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(actualElement, AIJsonUtilities.DefaultOptions))) + JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions))) { string message = propertyName is null - ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" - : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}"; + ? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}" + : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"; throw new XunitException(message); } + } + + private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + { + options ??= AIJsonUtilities.DefaultOptions; + JsonElement expectedElement = NormalizeToElement(expected, options); + JsonElement actualElement = NormalizeToElement(actual, options); + EqualJsonValues(expectedElement, actualElement, propertyName); static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options) => value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs index c74c50813f4..d5a474f0309 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs @@ -158,6 +158,92 @@ public async Task GetStreamingResponseAsync_CreatesTextMessageAsync() Assert.Equal(1, count); } + [Fact] + public async Task GetResponseAsync_UsesProvidedContinuationToken() + { + var expectedResponse = new ChatResponse(); + var expectedContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + var expectedChatOptions = new ChatOptions + { + ContinuationToken = expectedContinuationToken, + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary // Setting this to ensure cloning is happening + { + { "key", "value" }, + }, + }; + + using var cts = new CancellationTokenSource(); + + using TestChatClient client = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Empty(messages); + Assert.NotNull(options); + + Assert.True(options.AdditionalProperties!.ContainsKey("key")); // Assert that chat options were cloned + + Assert.Same(expectedChatOptions, options); + Assert.Same(expectedContinuationToken, options.ContinuationToken); + Assert.Equal(expectedChatOptions.AllowBackgroundResponses, options.AllowBackgroundResponses); + + Assert.Equal(cts.Token, cancellationToken); + + return Task.FromResult(expectedResponse); + }, + }; + + ChatResponse response = await client.GetResponseAsync([], expectedChatOptions, cts.Token); + + Assert.Same(expectedResponse, response); + } + + [Fact] + public async Task GetStreamingResponseAsync_UsesProvidedContinuationToken() + { + var expectedOptions = new ChatOptions(); + var expectedContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + var expectedChatOptions = new ChatOptions + { + ContinuationToken = expectedContinuationToken, + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary // Setting this to ensure cloning is happening + { + { "key", "value" }, + }, + }; + using var cts = new CancellationTokenSource(); + + using TestChatClient client = new() + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Empty(messages); + Assert.NotNull(options); + + Assert.True(options.AdditionalProperties!.ContainsKey("key")); // Assert that chat options were cloned + + Assert.Same(expectedChatOptions, options); + Assert.Same(expectedContinuationToken, options.ContinuationToken); + Assert.Equal(expectedChatOptions.AllowBackgroundResponses, options.AllowBackgroundResponses); + + Assert.Equal(cts.Token, cancellationToken); + + return YieldAsync([new ChatResponseUpdate(ChatRole.Assistant, "world")]); + }, + }; + + int count = 0; + await foreach (var update in client.GetStreamingResponseAsync([], expectedChatOptions, cts.Token)) + { + Assert.Equal(0, count); + count++; + } + + Assert.Equal(1, count); + } + private static async IAsyncEnumerable YieldAsync(params ChatResponseUpdate[] updates) { await Task.Yield(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs index c449f064255..378ff03af69 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Text.Json; using Xunit; -using static System.Net.Mime.MediaTypeNames; namespace Microsoft.Extensions.AI; @@ -18,6 +17,7 @@ public void Constructor_Parameterless_PropsDefaulted() ChatMessage message = new(); Assert.Null(message.AuthorName); Assert.Empty(message.Contents); + Assert.Null(message.CreatedAt); Assert.Equal(ChatRole.User, message.Role); Assert.Empty(message.Text); Assert.NotNull(message.Contents); @@ -50,6 +50,7 @@ public void Constructor_RoleString_PropsRoundtrip(string? text) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); Assert.Equal(text ?? string.Empty, message.ToString()); @@ -113,6 +114,7 @@ public void Constructor_RoleList_PropsRoundtrip(int messageCount) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); } @@ -230,6 +232,20 @@ public void AdditionalProperties_Roundtrips() Assert.Same(props, message.AdditionalProperties); } + [Fact] + public void CreatedAt_Roundtrips() + { + ChatMessage message = new(); + Assert.Null(message.CreatedAt); + + DateTimeOffset now = DateTimeOffset.Now; + message.CreatedAt = now; + Assert.Equal(now, message.CreatedAt); + + message.CreatedAt = null; + Assert.Null(message.CreatedAt); + } + [Fact] public void ItCanBeSerializeAndDeserialized() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index cdf1aab09c9..e6d863220e1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -15,6 +15,7 @@ public void Constructor_Parameterless_PropsDefaulted() { ChatOptions options = new(); Assert.Null(options.ConversationId); + Assert.Null(options.Instructions); Assert.Null(options.Temperature); Assert.Null(options.MaxOutputTokens); Assert.Null(options.TopP); @@ -33,6 +34,7 @@ public void Constructor_Parameterless_PropsDefaulted() ChatOptions clone = options.Clone(); Assert.Null(clone.ConversationId); + Assert.Null(clone.Instructions); Assert.Null(clone.Temperature); Assert.Null(clone.MaxOutputTokens); Assert.Null(clone.TopP); @@ -48,6 +50,8 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.Tools); Assert.Null(clone.AdditionalProperties); Assert.Null(clone.RawRepresentationFactory); + Assert.Null(clone.ContinuationToken); + Assert.Null(clone.AllowBackgroundResponses); } [Fact] @@ -74,7 +78,10 @@ public void Properties_Roundtrip() Func rawRepresentationFactory = (c) => null; + ResponseContinuationToken continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + options.ConversationId = "12345"; + options.Instructions = "Some instructions"; options.Temperature = 0.1f; options.MaxOutputTokens = 2; options.TopP = 0.3f; @@ -90,8 +97,11 @@ public void Properties_Roundtrip() options.Tools = tools; options.RawRepresentationFactory = rawRepresentationFactory; options.AdditionalProperties = additionalProps; + options.ContinuationToken = continuationToken; + options.AllowBackgroundResponses = true; Assert.Equal("12345", options.ConversationId); + Assert.Equal("Some instructions", options.Instructions); Assert.Equal(0.1f, options.Temperature); Assert.Equal(2, options.MaxOutputTokens); Assert.Equal(0.3f, options.TopP); @@ -107,6 +117,8 @@ public void Properties_Roundtrip() Assert.Same(tools, options.Tools); Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(continuationToken, options.ContinuationToken); + Assert.True(options.AllowBackgroundResponses); ChatOptions clone = options.Clone(); Assert.Equal("12345", clone.ConversationId); @@ -125,6 +137,8 @@ public void Properties_Roundtrip() Assert.Equal(tools, clone.Tools); Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(continuationToken, clone.ContinuationToken); + Assert.True(clone.AllowBackgroundResponses); } [Fact] @@ -143,7 +157,10 @@ public void JsonSerialization_Roundtrips() ["key"] = "value", }; + ResponseContinuationToken continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + options.ConversationId = "12345"; + options.Instructions = "Some instructions"; options.Temperature = 0.1f; options.MaxOutputTokens = 2; options.TopP = 0.3f; @@ -163,6 +180,8 @@ public void JsonSerialization_Roundtrips() ]; options.RawRepresentationFactory = (c) => null; options.AdditionalProperties = additionalProps; + options.ContinuationToken = continuationToken; + options.AllowBackgroundResponses = true; string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ChatOptions); @@ -170,6 +189,7 @@ public void JsonSerialization_Roundtrips() Assert.NotNull(deserialized); Assert.Equal("12345", deserialized.ConversationId); + Assert.Equal("Some instructions", deserialized.Instructions); Assert.Equal(0.1f, deserialized.Temperature); Assert.Equal(2, deserialized.MaxOutputTokens); Assert.Equal(0.3f, deserialized.TopP); @@ -192,4 +212,70 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + ChatOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : ChatOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override ChatOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override ChatOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : ChatOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs index c65bef12fc8..420871ca9e6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -2,9 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; using System.Text.Json; using Xunit; +#pragma warning disable SA1204 // Static elements should appear before instance elements + namespace Microsoft.Extensions.AI; public class ChatResponseFormatTests @@ -81,4 +86,131 @@ public void Serialization_ForJsonSchemaRoundtrips() Assert.Equal("name", actual.SchemaName); Assert.Equal("description", actual.SchemaDescription); } + + [Fact] + public void ForJsonSchema_NullType_Throws() + { + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!)); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options)); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name")); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name", "description")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_PrimitiveType_Succeeds(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(int)); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Equal("""{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"integer"}""", format.Schema.ToString()); + Assert.Equal("Int32", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_IncludedType_Succeeds(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(DataContent)); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Contains("\"uri\"", format.Schema.ToString()); + Assert.Equal("DataContent", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + public static IEnumerable ForJsonSchema_ComplexType_Succeeds_MemberData() => + from generic in new[] { false, true } + from name in new string?[] { null, "CustomName" } + from description in new string?[] { null, "CustomDescription" } + select new object?[] { generic, name, description }; + + [Theory] + [MemberData(nameof(ForJsonSchema_ComplexType_Succeeds_MemberData))] + public void ForJsonSchema_ComplexType_Succeeds(bool generic, string? name, string? description) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options, name, description) : + ChatResponseFormat.ForJsonSchema(typeof(SomeType), TestJsonSerializerContext.Default.Options, name, description); + + Assert.NotNull(format); + Assert.Equal( + """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "abcd", + "type": "object", + "properties": { + "someInteger": { + "description": "efg", + "type": "integer" + }, + "someString": { + "description": "hijk", + "type": [ + "string", + "null" + ] + } + } + } + """, + JsonSerializer.Serialize(format.Schema, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))); + Assert.Equal(name ?? "SomeType", format.SchemaName); + Assert.Equal(description ?? "abcd", format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_DisplayNameAttribute_UsedForSchemaName(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options) : + ChatResponseFormat.ForJsonSchema(typeof(TypeWithDisplayName), TestJsonSerializerContext.Default.Options); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Equal("custom_type_name", format.SchemaName); + Assert.Equal("Type description", format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_DisplayNameAttribute_CanBeOverridden(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options, schemaName: "override_name") : + ChatResponseFormat.ForJsonSchema(typeof(TypeWithDisplayName), TestJsonSerializerContext.Default.Options, schemaName: "override_name"); + + Assert.NotNull(format); + Assert.Equal("override_name", format.SchemaName); + } + + [Description("abcd")] + public class SomeType + { + [Description("efg")] + public int SomeInteger { get; set; } + + [Description("hijk")] + public string? SomeString { get; set; } + } + + [DisplayName("custom_type_name")] + [Description("Type description")] + public class TypeWithDisplayName + { + public int Value { get; set; } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs index 802b414437d..de5809d3d97 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs @@ -125,7 +125,7 @@ public void ToString_OutputsText() } [Fact] - public void ToChatResponseUpdates() + public void ToChatResponseUpdates_SingleMessage() { ChatResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage" }) { @@ -153,4 +153,55 @@ public void ToChatResponseUpdates() Assert.Equal("value1", update1.AdditionalProperties?["key1"]); Assert.Equal(42, update1.AdditionalProperties?["key2"]); } + + [Fact] + public void ToChatResponseUpdates_MultipleMessages() + { + ChatResponse response = new( + [ + new ChatMessage(new ChatRole("customRole"), "Text") + { + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + MessageId = "someMessage" + }, + new ChatMessage(new ChatRole("secondRole"), "Another message") + { + CreatedAt = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), + MessageId = "anotherMessage" + } + ]) + { + ResponseId = "12345", + ModelId = "someModel", + FinishReason = ChatFinishReason.ContentFilter, + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + }; + + ChatResponseUpdate[] updates = response.ToChatResponseUpdates(); + Assert.NotNull(updates); + Assert.Equal(3, updates.Length); + + ChatResponseUpdate update0 = updates[0]; + Assert.Equal("12345", update0.ResponseId); + Assert.Equal("someMessage", update0.MessageId); + Assert.Equal("someModel", update0.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason); + Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt); + Assert.Equal("customRole", update0.Role?.Value); + Assert.Equal("Text", update0.Text); + + ChatResponseUpdate update1 = updates[1]; + Assert.Equal("12345", update1.ResponseId); + Assert.Equal("anotherMessage", update1.MessageId); + Assert.Equal("someModel", update1.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), update1.CreatedAt); + Assert.Equal("secondRole", update1.Role?.Value); + Assert.Equal("Another message", update1.Text); + + ChatResponseUpdate update2 = updates[2]; + Assert.Equal("value1", update2.AdditionalProperties?["key1"]); + Assert.Equal(42, update2.AdditionalProperties?["key2"]); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs index 8b13d640ae1..09f3600e522 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; using Xunit; #pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable MEAI0001 // Suppress experimental warnings for testing namespace Microsoft.Extensions.AI; @@ -29,7 +29,7 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) ChatResponseUpdate[] updates = [ new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model123" }, - new(new("human"), ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } }, + new(ChatRole.Assistant, ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } }, new(null, "world!") { CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), ConversationId = "123", AdditionalProperties = new() { ["c"] = "d" } }, new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] }, @@ -37,8 +37,8 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) ]; ChatResponse response = useAsync ? - updates.ToChatResponse() : - await YieldAsync(updates).ToChatResponseAsync(); + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); Assert.NotNull(response); Assert.NotNull(response.Usage); @@ -53,7 +53,7 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) ChatMessage message = response.Messages.Single(); Assert.Equal("12345", message.MessageId); - Assert.Equal(new ChatRole("human"), message.Role); + Assert.Equal(ChatRole.Assistant, message.Role); Assert.Equal("Someone", message.AuthorName); Assert.Null(message.AdditionalProperties); @@ -65,6 +65,455 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) Assert.Equal("Hello, world!", response.Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_RoleOrIdOrAuthorNameChangeDictatesMessageChange(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + new(null, "!") { MessageId = "1" }, + new(ChatRole.Assistant, "a") { MessageId = "1" }, + new(ChatRole.Assistant, "b") { MessageId = "2" }, + new(ChatRole.User, "c") { MessageId = "2" }, + new(ChatRole.User, "d") { MessageId = "2" }, + new(ChatRole.Assistant, "e") { MessageId = "3" }, + new(ChatRole.Tool, "f") { MessageId = "4" }, + new(ChatRole.Tool, "g") { MessageId = "4" }, + new(ChatRole.Tool, "h") { MessageId = "5" }, + new(new("human"), "i") { MessageId = "6" }, + new(new("human"), "j") { MessageId = "7" }, + new(new("human"), "k") { MessageId = "7" }, + new(null, "l") { MessageId = "7" }, + new(null, "m") { MessageId = "8" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + Assert.Equal(9, response.Messages.Count); + + Assert.Equal("!a", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + Assert.Equal("b", response.Messages[1].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + + Assert.Equal("cd", response.Messages[2].Text); + Assert.Equal(ChatRole.User, response.Messages[2].Role); + + Assert.Equal("e", response.Messages[3].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[3].Role); + + Assert.Equal("fg", response.Messages[4].Text); + Assert.Equal(ChatRole.Tool, response.Messages[4].Role); + + Assert.Equal("h", response.Messages[5].Text); + Assert.Equal(ChatRole.Tool, response.Messages[5].Role); + + Assert.Equal("i", response.Messages[6].Text); + Assert.Equal(new ChatRole("human"), response.Messages[6].Role); + + Assert.Equal("jkl", response.Messages[7].Text); + Assert.Equal(new ChatRole("human"), response.Messages[7].Role); + + Assert.Equal("m", response.Messages[8].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[8].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AuthorNameChangeDictatesMessageBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with AuthorName "Alice" + new(ChatRole.Assistant, "Hello ") { AuthorName = "Alice" }, + new(null, "from ") { AuthorName = "Alice" }, + new(null, "Alice!"), + + // Second message - AuthorName changes to "Bob" + new(null, "Hi ") { AuthorName = "Bob" }, + new(null, "from ") { AuthorName = "Bob" }, + new(null, "Bob!"), + + // Third message - AuthorName changes to "Charlie" + new(ChatRole.Assistant, "Greetings ") { AuthorName = "Charlie" }, + new(null, "from Charlie!") { AuthorName = "Charlie" }, + + // Fourth message - AuthorName changes back to "Alice" + new(null, "Alice again!") { AuthorName = "Alice" }, + + // Fifth message - empty/null AuthorName should continue with last message + new(null, " Still Alice.") { AuthorName = "" }, + new(null, " And more."), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(4, response.Messages.Count); + + Assert.Equal("Hello from Alice!", response.Messages[0].Text); + Assert.Equal("Alice", response.Messages[0].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + Assert.Equal("Hi from Bob!", response.Messages[1].Text); + Assert.Equal("Bob", response.Messages[1].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + + Assert.Equal("Greetings from Charlie!", response.Messages[2].Text); + Assert.Equal("Charlie", response.Messages[2].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[2].Role); + + Assert.Equal("Alice again! Still Alice. And more.", response.Messages[3].Text); + Assert.Equal("Alice", response.Messages[3].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[3].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AuthorNameWithOtherBoundaries(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // Message 1: Role=Assistant, MessageId="1", AuthorName="Alice" + new(ChatRole.Assistant, "A") { MessageId = "1", AuthorName = "Alice" }, + new(null, "B") { MessageId = "1", AuthorName = "Alice" }, + + // Message 2: AuthorName changes to "Bob", same MessageId and Role + new(null, "C") { MessageId = "1", AuthorName = "Bob" }, + + // Message 3: MessageId changes to "2", AuthorName stays "Bob" + new(null, "D") { MessageId = "2", AuthorName = "Bob" }, + new(null, "E") { MessageId = "2", AuthorName = "Bob" }, + + // Message 4: Role changes to User, AuthorName stays "Bob" + new(ChatRole.User, "F") { MessageId = "2", AuthorName = "Bob" }, + + // Message 5: All three boundaries change + new(ChatRole.Tool, "G") { MessageId = "3", AuthorName = "Charlie" }, + new(null, "H") { MessageId = "3", AuthorName = "Charlie" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(5, response.Messages.Count); + + Assert.Equal("AB", response.Messages[0].Text); + Assert.Equal("Alice", response.Messages[0].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("1", response.Messages[0].MessageId); + + Assert.Equal("C", response.Messages[1].Text); + Assert.Equal("Bob", response.Messages[1].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + Assert.Equal("1", response.Messages[1].MessageId); + + Assert.Equal("DE", response.Messages[2].Text); + Assert.Equal("Bob", response.Messages[2].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[2].Role); + Assert.Equal("2", response.Messages[2].MessageId); + + Assert.Equal("F", response.Messages[3].Text); + Assert.Equal("Bob", response.Messages[3].AuthorName); + Assert.Equal(ChatRole.User, response.Messages[3].Role); + Assert.Equal("2", response.Messages[3].MessageId); + + Assert.Equal("GH", response.Messages[4].Text); + Assert.Equal("Charlie", response.Messages[4].AuthorName); + Assert.Equal(ChatRole.Tool, response.Messages[4].Role); + Assert.Equal("3", response.Messages[4].MessageId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_EmptyOrNullAuthorNameDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with AuthorName "Assistant" + new(ChatRole.Assistant, "Hello") { AuthorName = "Assistant" }, + + // Empty AuthorName should not create new message + new(null, " world") { AuthorName = "" }, + + // Null AuthorName should not create new message + new(null, "!"), + + // Another empty AuthorName + new(null, " How") { AuthorName = "" }, + new(null, " are") { AuthorName = "" }, + + // Null again + new(null, " you?") { AuthorName = null }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal("Hello world! How are you?", message.Text); + Assert.Equal("Assistant", message.AuthorName); + Assert.Equal(ChatRole.Assistant, message.Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AuthorNameNullToNonNullDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with no AuthorName + new(ChatRole.Assistant, "Hello") { MessageId = "1" }, + new(null, " there") { MessageId = "1" }, + + // AuthorName becomes non-empty but doesn't create boundary + new(null, " I'm Bob") { MessageId = "1", AuthorName = "Bob" }, + new(null, " speaking") { MessageId = "1", AuthorName = "Bob" }, + + // Second message - AuthorName changes to "Alice" creates boundary + new(null, "Now Alice") { MessageId = "1", AuthorName = "Alice" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(2, response.Messages.Count); + + Assert.Equal("Hello there I'm Bob speaking", response.Messages[0].Text); + Assert.Equal("Bob", response.Messages[0].AuthorName); // Last AuthorName wins + Assert.Equal("1", response.Messages[0].MessageId); + + Assert.Equal("Now Alice", response.Messages[1].Text); + Assert.Equal("Alice", response.Messages[1].AuthorName); + Assert.Equal("1", response.Messages[1].MessageId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_MessageIdNullToNonNullDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with no MessageId + new(ChatRole.Assistant, "Hello"), + new(null, " there"), + + // MessageId becomes non-empty but doesn't create boundary + new(null, " from") { MessageId = "msg1" }, + new(null, " AI") { MessageId = "msg1" }, + + // Second message - MessageId changes to different value creates boundary + new(null, "Next message") { MessageId = "msg2" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(2, response.Messages.Count); + + Assert.Equal("Hello there from AI", response.Messages[0].Text); + Assert.Equal("msg1", response.Messages[0].MessageId); // Last MessageId wins + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + Assert.Equal("Next message", response.Messages[1].Text); + Assert.Equal("msg2", response.Messages[1].MessageId); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_EmptyMessageIdDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with MessageId + new(ChatRole.Assistant, "Hello") { MessageId = "msg1" }, + new(null, " world") { MessageId = "msg1" }, + + // Empty MessageId should not create new message + new(null, "!") { MessageId = "" }, + + // Null MessageId should not create new message + new(null, " How"), + + // Another message with empty MessageId + new(null, " are") { MessageId = "" }, + new(null, " you?"), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal("Hello world! How are you?", message.Text); + Assert.Equal("msg1", message.MessageId); + Assert.Equal(ChatRole.Assistant, message.Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_RoleNullToNonNullDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with no explicit Role (will default to Assistant) + new(null, "Hello") { MessageId = "1" }, + new(null, " there") { MessageId = "1" }, + + // Role becomes explicit Assistant - shouldn't create boundary + new(ChatRole.Assistant, " from") { MessageId = "1" }, + new(null, " AI") { MessageId = "1" }, + + // Second message - Role changes to User creates boundary + new(ChatRole.User, "User message") { MessageId = "1" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(2, response.Messages.Count); + + Assert.Equal("Hello there from AI", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("1", response.Messages[0].MessageId); + + Assert.Equal("User message", response.Messages[1].Text); + Assert.Equal(ChatRole.User, response.Messages[1].Role); + Assert.Equal("1", response.Messages[1].MessageId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CustomRoleChangesCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with custom role "agent1" + new(new ChatRole("agent1"), "Hello") { MessageId = "1" }, + new(null, " from") { MessageId = "1" }, + new(new ChatRole("agent1"), " agent1") { MessageId = "1" }, + + // Second message - custom role changes to "agent2" + new(new ChatRole("agent2"), "Hi") { MessageId = "1" }, + new(null, " from") { MessageId = "1" }, + new(new ChatRole("agent2"), " agent2") { MessageId = "1" }, + + // Third message - changes to standard role + new(ChatRole.Assistant, "Assistant here") { MessageId = "1" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(3, response.Messages.Count); + + Assert.Equal("Hello from agent1", response.Messages[0].Text); + Assert.Equal(new ChatRole("agent1"), response.Messages[0].Role); + + Assert.Equal("Hi from agent2", response.Messages[1].Text); + Assert.Equal(new ChatRole("agent2"), response.Messages[1].Role); + + Assert.Equal("Assistant here", response.Messages[2].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[2].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message - ID "msg1", AuthorName "Assistant" + new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(null, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should win + new(null, " AI") { MessageId = "msg1", AuthorName = "Assistant" }, // Keep same AuthorName to avoid creating new message + + // Second message - ID "msg1" changes to "msg2", still AuthorName "Assistant" + new(null, "More text") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 2, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + + // Third message - ID "msg3", Role changes to User + new(ChatRole.User, "How") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" }, + new(null, " are") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) }, + new(null, " you?") { MessageId = "msg3", AuthorName = "User" }, // Keep same AuthorName + + // Fourth message - ID "msg4", Role changes back to Assistant + new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) }, + new(null, " thank you!") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win + + // Updates without MessageId should continue the last message (msg4) + new(null, " How can I help?"), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.NotNull(response); + Assert.Equal(4, response.Messages.Count); + + // Verify first message + ChatMessage message1 = response.Messages[0]; + Assert.Equal("msg1", message1.MessageId); + Assert.Equal(ChatRole.Assistant, message1.Role); + Assert.Equal("Assistant", message1.AuthorName); + Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero), message1.CreatedAt); // Last value should win + Assert.Equal("Hi! Hello from AI", message1.Text); + + // Verify second message + ChatMessage message2 = response.Messages[1]; + Assert.Equal("msg2", message2.MessageId); + Assert.Equal(ChatRole.Assistant, message2.Role); + Assert.Equal("Assistant", message2.AuthorName); + Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 2, 0, TimeSpan.Zero), message2.CreatedAt); + Assert.Equal("More text", message2.Text); + + // Verify third message + ChatMessage message3 = response.Messages[2]; + Assert.Equal("msg3", message3.MessageId); + Assert.Equal(ChatRole.User, message3.Role); + Assert.Equal("User", message3.AuthorName); + Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win + Assert.Equal("How are you?", message3.Text); + + // Verify fourth message + ChatMessage message4 = response.Messages[3]; + Assert.Equal("msg4", message4.MessageId); + Assert.Equal(ChatRole.Assistant, message4.Role); + Assert.Null(message4.AuthorName); // No AuthorName set + Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message4.CreatedAt); // Last value should win + Assert.Equal("I'm doing well, thank you! How can I help?", message4.Text); + } + public static IEnumerable ToChatResponse_Coalescing_VariousSequenceAndGapLengths_MemberData() { foreach (bool useAsync in new[] { false, true }) @@ -183,6 +632,85 @@ public async Task ToChatResponse_CoalescesTextContentAndTextReasoningContentSepa Assert.Equal("OP", Assert.IsType(message.Contents[7]).Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CoalescesTextReasoningContentUpToProtectedData(bool useAsync) + { + ChatResponseUpdate[] updates = + { + new() { Contents = [new TextReasoningContent("A") { ProtectedData = "1" }] }, + new() { Contents = [new TextReasoningContent("B") { ProtectedData = "2" }] }, + new() { Contents = [new TextReasoningContent("C")] }, + new() { Contents = [new TextReasoningContent("D")] }, + new() { Contents = [new TextReasoningContent("E") { ProtectedData = "3" }] }, + new() { Contents = [new TextReasoningContent("F") { ProtectedData = "4" }] }, + new() { Contents = [new TextReasoningContent("G")] }, + new() { Contents = [new TextReasoningContent("H")] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(5, message.Contents.Count); + + Assert.Equal("A", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("1", ((TextReasoningContent)message.Contents[0]).ProtectedData); + + Assert.Equal("B", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("2", ((TextReasoningContent)message.Contents[1]).ProtectedData); + + Assert.Equal("CDE", Assert.IsType(message.Contents[2]).Text); + Assert.Equal("3", ((TextReasoningContent)message.Contents[2]).ProtectedData); + + Assert.Equal("F", Assert.IsType(message.Contents[3]).Text); + Assert.Equal("4", ((TextReasoningContent)message.Contents[3]).ProtectedData); + + Assert.Equal("GH", Assert.IsType(message.Contents[4]).Text); + Assert.Null(((TextReasoningContent)message.Contents[4]).ProtectedData); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_DoesNotCoalesceAnnotatedContent(bool useAsync) + { + ChatResponseUpdate[] updates = + { + new(null, "A"), + new(null, "B"), + new(null, "C"), + new() { Contents = [new TextContent("D") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("E") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("F") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("G") { Annotations = [] }] }, + new() { Contents = [new TextContent("H") { Annotations = [] }] }, + new() { Contents = [new TextContent("I") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("J") { Annotations = [new()] }] }, + new(null, "K"), + new() { Contents = [new TextContent("L") { Annotations = [new()] }] }, + new(null, "M"), + new(null, "N"), + new() { Contents = [new TextContent("O") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("P") { Annotations = [new()] }] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(12, message.Contents.Count); + Assert.Equal("ABC", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("D", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("E", Assert.IsType(message.Contents[2]).Text); + Assert.Equal("F", Assert.IsType(message.Contents[3]).Text); + Assert.Equal("GH", Assert.IsType(message.Contents[4]).Text); + Assert.Equal("I", Assert.IsType(message.Contents[5]).Text); + Assert.Equal("J", Assert.IsType(message.Contents[6]).Text); + Assert.Equal("K", Assert.IsType(message.Contents[7]).Text); + Assert.Equal("L", Assert.IsType(message.Contents[8]).Text); + Assert.Equal("MN", Assert.IsType(message.Contents[9]).Text); + Assert.Equal("O", Assert.IsType(message.Contents[10]).Text); + Assert.Equal("P", Assert.IsType(message.Contents[11]).Text); + } + [Fact] public async Task ToChatResponse_UsageContentExtractedFromContents() { @@ -203,6 +731,191 @@ public async Task ToChatResponse_UsageContentExtractedFromContents() Assert.Equal("Hello, world!", Assert.IsType(Assert.Single(Assert.Single(response.Messages).Contents)).Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AlternativeTimestamps(bool useAsync) + { + DateTimeOffset early = new(2024, 1, 1, 10, 0, 0, TimeSpan.Zero); + DateTimeOffset middle = new(2024, 1, 1, 11, 0, 0, TimeSpan.Zero); + DateTimeOffset late = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + DateTimeOffset unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + ChatResponseUpdate[] updates = + [ + + // Start with an early timestamp + new(ChatRole.Tool, "a") { MessageId = "4", CreatedAt = early }, + + // Unix epoch (as "null") should not overwrite + new(null, "b") { CreatedAt = unixEpoch }, + + // Newer timestamp should overwrite + new(null, "c") { CreatedAt = middle }, + + // Older timestamp should not overwrite + new(null, "d") { CreatedAt = early }, + + // Even newer timestamp should overwrite + new(null, "e") { CreatedAt = late }, + + // Unix epoch should not overwrite again + new(null, "f") { CreatedAt = unixEpoch }, + + // null should not overwrite + new(null, "g") { CreatedAt = null }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + Assert.Single(response.Messages); + + Assert.Equal("abcdefg", response.Messages[0].Text); + Assert.Equal(ChatRole.Tool, response.Messages[0].Role); + Assert.Equal(late, response.Messages[0].CreatedAt); + Assert.Equal(late, response.CreatedAt); + } + + public static IEnumerable ToChatResponse_TimestampFolding_MemberData() + { + // Base test cases + var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[] + { + (null, null, null), + ("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"), + (null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), + ("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T11:00:00Z"), + ("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), + ("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"), + ("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), + }; + + // Yield each test case twice, once for useAsync = false and once for useAsync = true + foreach (var (timestamp1, timestamp2, expectedTimestamp) in testCases) + { + yield return new object?[] { false, timestamp1, timestamp2, expectedTimestamp }; + yield return new object?[] { true, timestamp1, timestamp2, expectedTimestamp }; + } + } + + [Theory] + [MemberData(nameof(ToChatResponse_TimestampFolding_MemberData))] + public async Task ToChatResponse_TimestampFolding(bool useAsync, string? timestamp1, string? timestamp2, string? expectedTimestamp) + { + DateTimeOffset? first = timestamp1 is not null ? DateTimeOffset.Parse(timestamp1) : null; + DateTimeOffset? second = timestamp2 is not null ? DateTimeOffset.Parse(timestamp2) : null; + DateTimeOffset? expected = expectedTimestamp is not null ? DateTimeOffset.Parse(expectedTimestamp) : null; + + ChatResponseUpdate[] updates = + [ + new(ChatRole.Assistant, "a") { CreatedAt = first }, + new(null, "b") { CreatedAt = second }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Single(response.Messages); + Assert.Equal("ab", response.Messages[0].Text); + Assert.Equal(expected, response.Messages[0].CreatedAt); + Assert.Equal(expected, response.CreatedAt); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CoalescesImageGenerationToolResultContent(bool useAsync) + { + // Create test image content with actual byte arrays + var image1 = new DataContent((byte[])[1, 2, 3, 4], "image/png") { Name = "image1.png" }; + var image2 = new DataContent((byte[])[5, 6, 7, 8], "image/jpeg") { Name = "image2.jpg" }; + var image3 = new DataContent((byte[])[9, 10, 11, 12], "image/png") { Name = "image3.png" }; + var image4 = new DataContent((byte[])[13, 14, 15, 16], "image/gif") { Name = "image4.gif" }; + + ChatResponseUpdate[] updates = + { + new(null, "Let's generate"), + new(null, " some images"), + + // Initial ImageGenerationToolResultContent with ID "img1" + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img1", Outputs = [image1] }] }, + + // Another ImageGenerationToolResultContent with different ID "img2" + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img2", Outputs = [image2] }] }, + + // Another ImageGenerationToolResultContent with same ID "img1" - should replace the first one + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img1", Outputs = [image3] }] }, + + // ImageGenerationToolResultContent with same ID "img2" - should replace the second one + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img2", Outputs = [image4] }] }, + + // Final text + new(null, "Here are those generated images"), + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + + // Should have 4 content items: 1 text (coalesced) + 2 image results (coalesced) + 1 text + Assert.Equal(4, message.Contents.Count); + + // Verify text content was coalesced properly + Assert.Equal("Let's generate some images", + Assert.IsType(message.Contents[0]).Text); + + // Get the image result contents + var imageResults = message.Contents.OfType().ToArray(); + Assert.Equal(2, imageResults.Length); + + // Verify the first image result (ID "img1") has the latest content (image3) + var firstImageResult = imageResults.First(ir => ir.ImageId == "img1"); + Assert.NotNull(firstImageResult.Outputs); + var firstOutput = Assert.Single(firstImageResult.Outputs); + Assert.Same(image3, firstOutput); // Should be the later image, not image1 + + // Verify the second image result (ID "img2") has the latest content (image4) + var secondImageResult = imageResults.First(ir => ir.ImageId == "img2"); + Assert.NotNull(secondImageResult.Outputs); + var secondOutput = Assert.Single(secondImageResult.Outputs); + Assert.Same(image4, secondOutput); // Should be the later image, not image2 + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_ImageGenerationToolResultContentWithNullOrEmptyImageId_DoesNotCoalesce(bool useAsync) + { + var image1 = new DataContent((byte[])[1, 2, 3, 4], "image/png") { Name = "image1.png" }; + var image2 = new DataContent((byte[])[5, 6, 7, 8], "image/jpeg") { Name = "image2.jpg" }; + var image3 = new DataContent((byte[])[9, 10, 11, 12], "image/png") { Name = "image3.png" }; + + ChatResponseUpdate[] updates = + { + // ImageGenerationToolResultContent with null ImageId - should not coalesce + new() { Contents = [new ImageGenerationToolResultContent { ImageId = null, Outputs = [image1] }] }, + + // ImageGenerationToolResultContent with empty ImageId - should not coalesce + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "", Outputs = [image2] }] }, + + // Another with null ImageId - should not coalesce with the first + new() { Contents = [new ImageGenerationToolResultContent { ImageId = null, Outputs = [image3] }] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + + // Should have all 3 image result contents since they can't be coalesced + var imageResults = message.Contents.OfType().ToArray(); + Assert.Equal(3, imageResults.Length); + + // Verify each has its original content + Assert.Same(image1, imageResults[0].Outputs![0]); + Assert.Same(image2, imageResults[1].Outputs![0]); + Assert.Same(image3, imageResults[2].Outputs![0]); + } + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (ChatResponseUpdate update in updates) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs index 413215d9a44..9727a58ac47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs @@ -167,4 +167,165 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void Clone_CreatesShallowCopy() + { + // Arrange + var originalAdditionalProperties = new AdditionalPropertiesDictionary { ["key"] = "value" }; + var originalContents = new List { new TextContent("text1"), new TextContent("text2") }; + var originalRawRepresentation = new object(); + var originalCreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var original = new ChatResponseUpdate + { + AdditionalProperties = originalAdditionalProperties, + AuthorName = "author", + Contents = originalContents, + CreatedAt = originalCreatedAt, + ConversationId = "conv123", + FinishReason = ChatFinishReason.ContentFilter, + MessageId = "msg456", + ModelId = "model789", + RawRepresentation = originalRawRepresentation, + ResponseId = "resp012", + Role = ChatRole.Assistant, + }; + + // Act + var clone = original.Clone(); + + // Assert - Different instances + Assert.NotSame(original, clone); + + // Assert - All properties copied correctly + Assert.Equal(original.AuthorName, clone.AuthorName); + Assert.Equal(original.Role, clone.Role); + Assert.Equal(original.CreatedAt, clone.CreatedAt); + Assert.Equal(original.ConversationId, clone.ConversationId); + Assert.Equal(original.FinishReason, clone.FinishReason); + Assert.Equal(original.MessageId, clone.MessageId); + Assert.Equal(original.ModelId, clone.ModelId); + Assert.Equal(original.ResponseId, clone.ResponseId); + + // Assert - Reference properties are shallow copied (same references) + Assert.Same(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Same(original.Contents, clone.Contents); + Assert.Same(original.RawRepresentation, clone.RawRepresentation); + } + + [Fact] + public void Clone_WithNullProperties_CopiesCorrectly() + { + // Arrange + var original = new ChatResponseUpdate + { + Role = ChatRole.User, + ResponseId = "resp123" + }; + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Equal(ChatRole.User, clone.Role); + Assert.Equal("resp123", clone.ResponseId); + Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.AuthorName); + Assert.Null(clone.CreatedAt); + Assert.Null(clone.ConversationId); + Assert.Null(clone.FinishReason); + Assert.Null(clone.MessageId); + Assert.Null(clone.ModelId); + Assert.Null(clone.RawRepresentation); + Assert.Empty(clone.Contents); // Contents property initializes to empty list + } + + [Fact] + public void Clone_WithDefaultConstructor_CopiesCorrectly() + { + // Arrange + var original = new ChatResponseUpdate(); + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Null(clone.AuthorName); + Assert.Null(clone.Role); + Assert.Empty(clone.Contents); + Assert.Null(clone.RawRepresentation); + Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.ResponseId); + Assert.Null(clone.MessageId); + Assert.Null(clone.CreatedAt); + Assert.Null(clone.FinishReason); + Assert.Null(clone.ConversationId); + Assert.Null(clone.ModelId); + } + + [Fact] + public void Clone_ModifyingClone_DoesNotAffectOriginal() + { + // Arrange + var original = new ChatResponseUpdate + { + AuthorName = "original_author", + Role = ChatRole.User, + ResponseId = "original_id", + ModelId = "original_model" + }; + + // Act + var clone = original.Clone(); + clone.AuthorName = "modified_author"; + clone.Role = ChatRole.Assistant; + clone.ResponseId = "modified_id"; + clone.ModelId = "modified_model"; + + // Assert - Original remains unchanged + Assert.Equal("original_author", original.AuthorName); + Assert.Equal(ChatRole.User, original.Role); + Assert.Equal("original_id", original.ResponseId); + Assert.Equal("original_model", original.ModelId); + + // Assert - Clone has modified values + Assert.Equal("modified_author", clone.AuthorName); + Assert.Equal(ChatRole.Assistant, clone.Role); + Assert.Equal("modified_id", clone.ResponseId); + Assert.Equal("modified_model", clone.ModelId); + } + + [Fact] + public void Clone_ModifyingSharedReferences_AffectsBothInstances() + { + // Arrange + var sharedAdditionalProperties = new AdditionalPropertiesDictionary { ["initial"] = "value" }; + var sharedContents = new List { new TextContent("initial") }; + + var original = new ChatResponseUpdate + { + AdditionalProperties = sharedAdditionalProperties, + Contents = sharedContents + }; + + // Act + var clone = original.Clone(); + + // Modify the shared reference objects + sharedAdditionalProperties["modified"] = "new_value"; + sharedContents.Add(new TextContent("added")); + + // Assert - Both original and clone are affected due to shallow copy + Assert.Same(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Same(original.Contents, clone.Contents); + Assert.Equal(2, original.AdditionalProperties.Count); + Assert.Equal(2, clone.AdditionalProperties?.Count); + Assert.Equal(2, original.Contents.Count); + Assert.Equal(2, clone.Contents.Count); + Assert.True(original.AdditionalProperties.ContainsKey("modified")); + Assert.True(clone.AdditionalProperties?.ContainsKey("modified")); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationReferenceTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationReferenceTests.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs new file mode 100644 index 00000000000..2cfb5c765b7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIAnnotationTests +{ + [Fact] + public void Constructor_PropsDefault() + { + AIAnnotation a = new(); + Assert.Null(a.AdditionalProperties); + Assert.Null(a.RawRepresentation); + Assert.Null(a.AnnotatedRegions); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + AIAnnotation a = new(); + + Assert.Null(a.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + a.AdditionalProperties = props; + Assert.Same(props, a.AdditionalProperties); + + Assert.Null(a.AnnotatedRegions); + List regions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }]; + a.AnnotatedRegions = regions; + Assert.Same(regions, a.AnnotatedRegions); + + Assert.Null(a.RawRepresentation); + object raw = new(); + a.RawRepresentation = raw; + Assert.Same(raw, a.RawRepresentation); + } + + [Fact] + public void Serialization_Roundtrips() + { + AIAnnotation original = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary { { "key", "value" } }, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }], + RawRepresentation = new object(), + }; + + string json = JsonSerializer.Serialize(original, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIAnnotation))); + Assert.NotNull(json); + + var deserialized = (AIAnnotation?)JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIAnnotation))); + Assert.NotNull(deserialized); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.Equal(JsonSerializer.Deserialize("\"value\"", AIJsonUtilities.DefaultOptions).ToString(), deserialized.AdditionalProperties["key"]!.ToString()); + + Assert.Null(deserialized.RawRepresentation); + + Assert.NotNull(deserialized.AnnotatedRegions); + TextSpanAnnotatedRegion? region = Assert.IsType(Assert.Single(deserialized.AnnotatedRegions)); + Assert.NotNull(region); + Assert.Equal(10, region.StartIndex); + Assert.Equal(42, region.EndIndex); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentAnnotationTests.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs index 64a20fc5e4a..e5734ccd7cf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Text.Json; using Xunit; @@ -53,4 +54,40 @@ public void Serialization_Roundtrips() Assert.NotNull(deserialized.AdditionalProperties); Assert.Single(deserialized.AdditionalProperties); } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + ChatMessage message = new(ChatRole.User, + [ + new TextContent("a"), + new TextReasoningContent("reasoning text"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new UriContent("http://example.com", "application/json"), + new ErrorContent("error message"), + new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } }), + new FunctionResultContent("call123", "result data"), + new HostedFileContent("file123"), + new HostedVectorStoreContent("vectorStore123"), + new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }), + new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new FunctionApprovalResponseContent("request123", approved: true, new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new McpServerToolCallContent("call123", "myTool", "myServer"), + new McpServerToolResultContent("call123"), + new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + new McpServerToolApprovalResponseContent("request123", approved: true) + ]); + + var serialized = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions); + ChatMessage? deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + + Assert.Equal(message.Role, deserialized.Role); + Assert.Equal(message.Contents.Count, deserialized.Contents.Count); + for (int i = 0; i < message.Contents.Count; i++) + { + Assert.NotNull(message.Contents[i]); + Assert.Equal(message.Contents[i].GetType(), deserialized.Contents[i].GetType()); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs new file mode 100644 index 00000000000..08097f3e05e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class CitationAnnotationTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CitationAnnotation a = new(); + Assert.Null(a.AdditionalProperties); + Assert.Null(a.AnnotatedRegions); + Assert.Null(a.RawRepresentation); + Assert.Null(a.Snippet); + Assert.Null(a.Title); + Assert.Null(a.ToolName); + Assert.Null(a.Url); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + CitationAnnotation a = new(); + + Assert.Null(a.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + a.AdditionalProperties = props; + Assert.Same(props, a.AdditionalProperties); + + Assert.Null(a.RawRepresentation); + object raw = new(); + a.RawRepresentation = raw; + Assert.Same(raw, a.RawRepresentation); + + Assert.Null(a.AnnotatedRegions); + List regions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }]; + a.AnnotatedRegions = regions; + Assert.Same(regions, a.AnnotatedRegions); + + Assert.Null(a.Snippet); + a.Snippet = "snippet"; + Assert.Equal("snippet", a.Snippet); + + Assert.Null(a.Title); + a.Title = "title"; + Assert.Equal("title", a.Title); + + Assert.Null(a.ToolName); + a.ToolName = "toolName"; + Assert.Equal("toolName", a.ToolName); + + Assert.Null(a.Url); + Uri url = new("https://example.com"); + a.Url = url; + Assert.Same(url, a.Url); + } + + [Fact] + public void Serialization_Roundtrips() + { + CitationAnnotation original = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary { { "key", "value" } }, + RawRepresentation = new object(), + Snippet = "snippet", + Title = "title", + ToolName = "toolName", + Url = new("https://example.com"), + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }], + }; + + string json = JsonSerializer.Serialize(original, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CitationAnnotation))); + Assert.NotNull(json); + + var deserialized = (CitationAnnotation?)JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CitationAnnotation))); + Assert.NotNull(deserialized); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.Equal(JsonSerializer.Deserialize("\"value\"", AIJsonUtilities.DefaultOptions).ToString(), deserialized.AdditionalProperties["key"]!.ToString()); + + Assert.Null(deserialized.RawRepresentation); + Assert.Equal("snippet", deserialized.Snippet); + Assert.Equal("title", deserialized.Title); + Assert.Equal("toolName", deserialized.ToolName); + Assert.NotNull(deserialized.AnnotatedRegions); + TextSpanAnnotatedRegion region = Assert.IsType(Assert.Single(deserialized.AnnotatedRegions)); + Assert.Equal(10, region.StartIndex); + Assert.Equal(42, region.EndIndex); + + Assert.NotNull(deserialized.Url); + Assert.Equal(original.Url, deserialized.Url); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolCallContentTests.cs new file mode 100644 index 00000000000..1807f4a169a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolCallContentTests.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class CodeInterpreterToolCallContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CodeInterpreterToolCallContent c = new(); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.CallId); + Assert.Null(c.Inputs); + } + + [Fact] + public void Properties_Roundtrip() + { + CodeInterpreterToolCallContent c = new(); + + Assert.Null(c.CallId); + c.CallId = "call123"; + Assert.Equal("call123", c.CallId); + + Assert.Null(c.Inputs); + IList inputs = [new TextContent("print('hello')")]; + c.Inputs = inputs; + Assert.Same(inputs, c.Inputs); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Inputs_SupportsMultipleContentTypes() + { + CodeInterpreterToolCallContent c = new() + { + CallId = "call456", + Inputs = + [ + new TextContent("import numpy as np"), + new HostedFileContent("file123"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") + ] + }; + + Assert.NotNull(c.Inputs); + Assert.Equal(3, c.Inputs.Count); + Assert.IsType(c.Inputs[0]); + Assert.IsType(c.Inputs[1]); + Assert.IsType(c.Inputs[2]); + } + + [Fact] + public void Serialization_Roundtrips() + { + CodeInterpreterToolCallContent content = new() + { + CallId = "call123", + Inputs = + [ + new TextContent("print('hello')"), + new HostedFileContent("file456") + ] + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("call123", deserializedSut.CallId); + Assert.NotNull(deserializedSut.Inputs); + Assert.Equal(2, deserializedSut.Inputs.Count); + Assert.IsType(deserializedSut.Inputs[0]); + Assert.Equal("print('hello')", ((TextContent)deserializedSut.Inputs[0]).Text); + Assert.IsType(deserializedSut.Inputs[1]); + Assert.Equal("file456", ((HostedFileContent)deserializedSut.Inputs[1]).FileId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolResultContentTests.cs new file mode 100644 index 00000000000..6fb1303be53 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolResultContentTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class CodeInterpreterToolResultContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CodeInterpreterToolResultContent c = new(); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.CallId); + Assert.Null(c.Outputs); + } + + [Fact] + public void Properties_Roundtrip() + { + CodeInterpreterToolResultContent c = new(); + + Assert.Null(c.CallId); + c.CallId = "call123"; + Assert.Equal("call123", c.CallId); + + Assert.Null(c.Outputs); + IList output = [new TextContent("Hello, World!")]; + c.Outputs = output; + Assert.Same(output, c.Outputs); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Output_SupportsMultipleContentTypes() + { + CodeInterpreterToolResultContent c = new() + { + CallId = "call789", + Outputs = + [ + new TextContent("Execution completed"), + new HostedFileContent("output.png"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new ErrorContent("Warning: deprecated function") + ] + }; + + Assert.NotNull(c.Outputs); + Assert.Equal(4, c.Outputs.Count); + Assert.IsType(c.Outputs[0]); + Assert.IsType(c.Outputs[1]); + Assert.IsType(c.Outputs[2]); + Assert.IsType(c.Outputs[3]); + } + + [Fact] + public void Serialization_Roundtrips() + { + CodeInterpreterToolResultContent content = new() + { + CallId = "call123", + Outputs = + [ + new TextContent("Hello, World!"), + new HostedFileContent("result.txt") + ] + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("call123", deserializedSut.CallId); + Assert.NotNull(deserializedSut.Outputs); + Assert.Equal(2, deserializedSut.Outputs.Count); + Assert.IsType(deserializedSut.Outputs[0]); + Assert.Equal("Hello, World!", ((TextContent)deserializedSut.Outputs[0]).Text); + Assert.IsType(deserializedSut.Outputs[1]); + Assert.Equal("result.txt", ((HostedFileContent)deserializedSut.Outputs[1]).FileId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs index 0f5b6b22d92..47bc6ccfd62 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs @@ -111,13 +111,20 @@ public void Serialize_MatchesExpectedJson() { Assert.Equal( """{"uri":"data:application/octet-stream;base64,AQIDBA=="}""", - JsonSerializer.Serialize(new DataContent( - uri: "data:application/octet-stream;base64,AQIDBA=="), TestJsonSerializerContext.Default.Options)); + JsonSerializer.Serialize( + new DataContent(uri: "data:application/octet-stream;base64,AQIDBA=="), + TestJsonSerializerContext.Default.Options)); Assert.Equal( """{"uri":"data:application/octet-stream;base64,AQIDBA=="}""", - JsonSerializer.Serialize(new DataContent( - new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + JsonSerializer.Serialize( + new DataContent(new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","name":"test.bin"}""", + JsonSerializer.Serialize( + new DataContent(new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream") { Name = "test.bin" }, TestJsonSerializerContext.Default.Options)); } @@ -260,4 +267,13 @@ public void NonBase64Data_Normalized() Assert.Equal("aGVsbG8gd29ybGQ=", content.Base64Data.ToString()); Assert.Equal("hello world", Encoding.ASCII.GetString(content.Data.ToArray())); } + + [Fact] + public void FileName_Roundtrips() + { + DataContent content = new(new byte[] { 1, 2, 3 }, "application/octet-stream"); + Assert.Null(content.Name); + content.Name = "test.bin"; + Assert.Equal("test.bin", content.Name); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs new file mode 100644 index 00000000000..924243a7d1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class FunctionApprovalRequestContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + Assert.Throws("id", () => new FunctionApprovalRequestContent(null!, functionCall)); + Assert.Throws("id", () => new FunctionApprovalRequestContent("", functionCall)); + Assert.Throws("id", () => new FunctionApprovalRequestContent("\r\t\n ", functionCall)); + + Assert.Throws("functionCall", () => new FunctionApprovalRequestContent("id", null!)); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + FunctionApprovalRequestContent content = new(id, functionCall); + + Assert.Same(id, content.Id); + Assert.Same(functionCall, content.FunctionCall); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateResponse_ReturnsExpectedResponse(bool approved) + { + string id = "req-1"; + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + FunctionApprovalRequestContent content = new(id, functionCall); + + var response = content.CreateResponse(approved); + + Assert.NotNull(response); + Assert.Same(id, response.Id); + Assert.Equal(approved, response.Approved); + Assert.Same(functionCall, response.FunctionCall); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Id, deserializedContent.Id); + Assert.NotNull(deserializedContent.FunctionCall); + Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); + Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs new file mode 100644 index 00000000000..67d2f13cf49 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class FunctionApprovalResponseContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + Assert.Throws("id", () => new FunctionApprovalResponseContent(null!, true, functionCall)); + Assert.Throws("id", () => new FunctionApprovalResponseContent("", true, functionCall)); + Assert.Throws("id", () => new FunctionApprovalResponseContent("\r\t\n ", true, functionCall)); + + Assert.Throws("functionCall", () => new FunctionApprovalResponseContent("id", true, null!)); + } + + [Theory] + [InlineData("abc", true)] + [InlineData("123", false)] + [InlineData("!@#", true)] + public void Constructor_Roundtrips(string id, bool approved) + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + FunctionApprovalResponseContent content = new(id, approved, functionCall); + + Assert.Same(id, content.Id); + Assert.Equal(approved, content.Approved); + Assert.Same(functionCall, content.FunctionCall); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Id, deserializedContent.Id); + Assert.Equal(content.Approved, deserializedContent.Approved); + Assert.NotNull(deserializedContent.FunctionCall); + Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); + Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedFileContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedFileContentTests.cs new file mode 100644 index 00000000000..58768d32553 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedFileContentTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedFileContentTests +{ + [Fact] + public void Constructor_InvalidInput_Throws() + { + Assert.Throws("fileId", () => new HostedFileContent(null!)); + Assert.Throws("fileId", () => new HostedFileContent(string.Empty)); + Assert.Throws("fileId", () => new HostedFileContent(" ")); + } + + [Fact] + public void Constructor_String_PropsDefault() + { + string fileId = "id123"; + HostedFileContent c = new(fileId); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Equal(fileId, c.FileId); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + HostedFileContent c = new("id123"); + Assert.Equal("id123", c.FileId); + + c.FileId = "id456"; + Assert.Equal("id456", c.FileId); + + Assert.Throws("value", () => c.FileId = null!); + Assert.Throws("value", () => c.FileId = string.Empty); + Assert.Throws("value", () => c.FileId = " "); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new HostedFileContent("file123"); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.FileId, deserializedContent.FileId); + } + + [Fact] + public void MediaType_Roundtrips() + { + HostedFileContent c = new("id123"); + Assert.Null(c.MediaType); + + c.MediaType = "image/png"; + Assert.Equal("image/png", c.MediaType); + + c.MediaType = "application/pdf"; + Assert.Equal("application/pdf", c.MediaType); + + c.MediaType = null; + Assert.Null(c.MediaType); + } + + [Theory] + [InlineData("type")] + [InlineData("type//subtype")] + [InlineData("type/subtype/")] + [InlineData("type/subtype;key=")] + [InlineData("type/subtype;=value")] + [InlineData("type/subtype;key=value;another=")] + public void MediaType_InvalidValue_Throws(string invalidMediaType) + { + HostedFileContent c = new("id123"); + Assert.Throws("value", () => c.MediaType = invalidMediaType); + } + + [Theory] + [InlineData("image/png")] + [InlineData("image/jpeg")] + [InlineData("application/pdf")] + [InlineData("text/plain;charset=UTF-8")] + [InlineData("image/*")] + public void MediaType_ValidValue_Roundtrips(string mediaType) + { + HostedFileContent c = new("id123") { MediaType = mediaType }; + Assert.Equal(mediaType, c.MediaType); + } + + [Fact] + public void Name_Roundtrips() + { + HostedFileContent c = new("id123"); + Assert.Null(c.Name); + + c.Name = "document.pdf"; + Assert.Equal("document.pdf", c.Name); + + c.Name = "image.png"; + Assert.Equal("image.png", c.Name); + + c.Name = null; + Assert.Null(c.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedVectorStoreContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedVectorStoreContentTests.cs new file mode 100644 index 00000000000..105d8f2efd9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedVectorStoreContentTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedVectorStoreContentTests +{ + [Fact] + public void Constructor_InvalidInput_Throws() + { + Assert.Throws("vectorStoreId", () => new HostedVectorStoreContent(null!)); + Assert.Throws("vectorStoreId", () => new HostedVectorStoreContent(string.Empty)); + Assert.Throws("vectorStoreId", () => new HostedVectorStoreContent(" ")); + } + + [Fact] + public void Constructor_String_PropsDefault() + { + HostedVectorStoreContent c = new("id123"); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Equal("id123", c.VectorStoreId); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + HostedVectorStoreContent c = new("id123"); + + Assert.Equal("id123", c.VectorStoreId); + c.VectorStoreId = "id456"; + Assert.Equal("id456", c.VectorStoreId); + + Assert.Throws("value", () => c.VectorStoreId = null!); + Assert.Throws("value", () => c.VectorStoreId = string.Empty); + Assert.Throws("value", () => c.VectorStoreId = " "); + Assert.Equal("id456", c.VectorStoreId); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new HostedVectorStoreContent("vectorstore123"); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.VectorStoreId, deserializedContent.VectorStoreId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs new file mode 100644 index 00000000000..d5c5b43ed0a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class McpServerToolCallContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + McpServerToolCallContent c = new("callId1", "toolName", null); + + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + + Assert.Equal("callId1", c.CallId); + Assert.Equal("toolName", c.ToolName); + Assert.Null(c.ServerName); + Assert.Null(c.Arguments); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + McpServerToolCallContent c = new("callId1", "toolName", "serverName"); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Null(c.Arguments); + IReadOnlyDictionary args = new Dictionary(); + c.Arguments = args; + Assert.Same(args, c.Arguments); + + Assert.Equal("callId1", c.CallId); + Assert.Equal("toolName", c.ToolName); + Assert.Equal("serverName", c.ServerName); + } + + [Fact] + public void Constructor_Throws() + { + Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null)); + + Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!, null)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs new file mode 100644 index 00000000000..8fa6cc8a381 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class McpServerToolResultContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + McpServerToolResultContent c = new("callId"); + Assert.Equal("callId", c.CallId); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.Output); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + McpServerToolResultContent c = new("callId"); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Equal("callId", c.CallId); + + Assert.Null(c.Output); + IList output = []; + c.Output = output; + Assert.Same(output, c.Output); + } + + [Fact] + public void Constructor_Throws() + { + Assert.Throws("callId", () => new McpServerToolResultContent(string.Empty)); + Assert.Throws("callId", () => new McpServerToolResultContent(null!)); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new McpServerToolResultContent("call123") + { + Output = new List { new TextContent("result") } + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.CallId, deserializedContent.CallId); + Assert.NotNull(deserializedContent.Output); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs index 97afc4208e7..c4cb4676cfa 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -47,4 +48,16 @@ public void Constructor_PropsRoundtrip() Assert.Equal(string.Empty, c.Text); Assert.Equal(string.Empty, c.ToString()); } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new TextContent("Hello, world!"); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Text, deserializedContent.Text); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs index 9d2e238a068..5dca45472ae 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -16,6 +17,7 @@ public void Constructor_String_PropsDefault(string? text) TextReasoningContent c = new(text); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); + Assert.Null(c.ProtectedData); Assert.Equal(text ?? string.Empty, c.Text); } @@ -46,5 +48,24 @@ public void Constructor_PropsRoundtrip() c.Text = string.Empty; Assert.Equal(string.Empty, c.Text); Assert.Equal(string.Empty, c.ToString()); + + Assert.Null(c.ProtectedData); + c.ProtectedData = "protected"; + Assert.Equal("protected", c.ProtectedData); + c.ProtectedData = null; + Assert.Null(c.ProtectedData); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new TextReasoningContent("reasoning text") { ProtectedData = "protected" }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Text, deserializedContent.Text); + Assert.Equal("protected", deserializedContent.ProtectedData); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs index 514e2defecf..ed268176c5d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -57,4 +58,24 @@ public void Details_SetNull_Throws() Assert.Same(d, c.Details); } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30 + }); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.NotNull(deserializedContent.Details); + Assert.Equal(content.Details.InputTokenCount, deserializedContent.Details.InputTokenCount); + Assert.Equal(content.Details.OutputTokenCount, deserializedContent.Details.OutputTokenCount); + Assert.Equal(content.Details.TotalTokenCount, deserializedContent.Details.TotalTokenCount); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs new file mode 100644 index 00000000000..fc4dac9cabb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class UserInputRequestContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + Assert.Throws("id", () => new TestUserInputRequestContent(null!)); + Assert.Throws("id", () => new TestUserInputRequestContent("")); + Assert.Throws("id", () => new TestUserInputRequestContent("\r\t\n ")); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + TestUserInputRequestContent content = new(id); + + Assert.Equal(id, content.Id); + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + UserInputRequestContent content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); + var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserializedContent); + Assert.Equal(content.GetType(), deserializedContent.GetType()); + + UserInputRequestContent[] contents = + [ + new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + ]; + + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputRequestContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.UserInputRequestContentArray); + Assert.NotNull(deserializedContents); + + Assert.Equal(contents.Count(), deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(contents.ElementAt(i)); + Assert.Equal(contents.ElementAt(i).GetType(), deserializedContents[i].GetType()); + } + } + + private sealed class TestUserInputRequestContent : UserInputRequestContent + { + public TestUserInputRequestContent(string id) + : base(id) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs new file mode 100644 index 00000000000..2442e57272d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class UserInputResponseContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + Assert.Throws("id", () => new TestUserInputResponseContent(null!)); + Assert.Throws("id", () => new TestUserInputResponseContent("")); + Assert.Throws("id", () => new TestUserInputResponseContent("\r\t\n ")); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + TestUserInputResponseContent content = new(id); + + Assert.Equal(id, content.Id); + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + UserInputResponseContent content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")); + var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserializedContent); + Assert.Equal(content.GetType(), deserializedContent.GetType()); + + UserInputResponseContent[] contents = + [ + new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")), + new McpServerToolApprovalResponseContent("request123", true), + ]; + + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputResponseContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.UserInputResponseContentArray); + Assert.NotNull(deserializedContents); + + Assert.Equal(contents.Length, deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(contents[i]); + Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); + } + } + + private class TestUserInputResponseContent : UserInputResponseContent + { + public TestUserInputResponseContent(string id) + : base(id) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs index c75d715466e..c927a7ccf18 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs @@ -45,7 +45,7 @@ public void Properties_Roundtrips() Assert.Equal(createdAt, e.CreatedAt); Assert.Null(e.AdditionalProperties); - AdditionalPropertiesDictionary props = new(); + AdditionalPropertiesDictionary props = []; e.AdditionalProperties = props; Assert.Same(props, e.AdditionalProperties); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs index 97ffecfc1f6..34cbcd63e1b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs @@ -41,18 +41,23 @@ public void Properties_Roundtrip() ["key"] = "value", }; + Func rawRepresentationFactory = (c) => null; + options.ModelId = "modelId"; options.Dimensions = 1536; options.AdditionalProperties = additionalProps; + options.RawRepresentationFactory = rawRepresentationFactory; Assert.Equal("modelId", options.ModelId); Assert.Equal(1536, options.Dimensions); Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); EmbeddingGenerationOptions clone = options.Clone(); Assert.Equal("modelId", clone.ModelId); Assert.Equal(1536, clone.Dimensions); Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); } [Fact] @@ -83,4 +88,70 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + EmbeddingGenerationOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : EmbeddingGenerationOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override EmbeddingGenerationOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override EmbeddingGenerationOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : EmbeddingGenerationOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs index c13730fe604..a345af7b508 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs @@ -27,7 +27,7 @@ public void Ctor_ValidArgs_NoExceptions() GeneratedEmbeddings>[] instances = [ [], - new(0), + [], new(42), new([]) ]; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/ApprovalRequiredAIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/ApprovalRequiredAIFunctionTests.cs new file mode 100644 index 00000000000..b8d0173d33e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/ApprovalRequiredAIFunctionTests.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI.Functions; + +public class ApprovalRequiredAIFunctionTests +{ + [Fact] + public void Constructor_NullFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new ApprovalRequiredAIFunction(null!)); + } + + [Fact] + public void DelegatesToInnerFunction_Properties() + { + var inner = AIFunctionFactory.Create(() => 42); + var func = new ApprovalRequiredAIFunction(inner); + + Assert.Equal(inner.Name, func.Name); + Assert.Equal(inner.Description, func.Description); + Assert.Equal(inner.JsonSchema, func.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, func.ReturnJsonSchema); + Assert.Same(inner.JsonSerializerOptions, func.JsonSerializerOptions); + Assert.Same(inner.UnderlyingMethod, func.UnderlyingMethod); + Assert.Same(inner.AdditionalProperties, func.AdditionalProperties); + Assert.Equal(inner.ToString(), func.ToString()); + } + + [Fact] + public async Task InvokeAsync_DelegatesToInnerFunction() + { + var inner = AIFunctionFactory.Create(() => "result"); + var func = new ApprovalRequiredAIFunction(inner); + + var result = await func.InvokeAsync(); + + Assert.Equal("result", result?.ToString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs new file mode 100644 index 00000000000..b13e4d345a4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingAIFunctionTests +{ + [Fact] + public void Constructor_NullInnerFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new DerivedFunction(null!)); + } + + [Fact] + public void DefaultOverrides_DelegateToInnerFunction() + { + AIFunction expected = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => 42)); + DerivedFunction actual = new(expected); + + Assert.Same(expected, actual.InnerFunction); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Description, actual.Description); + Assert.Equal(expected.JsonSchema, actual.JsonSchema); + Assert.Equal(expected.ReturnJsonSchema, actual.ReturnJsonSchema); + Assert.Same(expected.JsonSerializerOptions, actual.JsonSerializerOptions); + Assert.Same(expected.UnderlyingMethod, actual.UnderlyingMethod); + Assert.Same(expected.AdditionalProperties, actual.AdditionalProperties); + Assert.Equal(expected.ToString(), actual.ToString()); + Assert.Same(expected, actual.GetService()); + } + + private sealed class DerivedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) + { + public new AIFunction InnerFunction => base.InnerFunction; + } + + [Fact] + public void Virtuals_AllOverridden() + { + Assert.All(typeof(DelegatingAIFunction).GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), m => + { + switch (m) + { + case MethodInfo methodInfo when methodInfo.IsVirtual && methodInfo.Name is not ("Finalize" or "Equals" or "GetHashCode"): + Assert.True(methodInfo.DeclaringType == typeof(DelegatingAIFunction), $"{methodInfo.Name} not overridden"); + break; + + case PropertyInfo propertyInfo when propertyInfo.GetMethod?.IsVirtual is true: + Assert.True(propertyInfo.DeclaringType == typeof(DelegatingAIFunction), $"{propertyInfo.Name} not overridden"); + break; + } + }); + } + + [Fact] + public async Task OverriddenInvocation_SuccessfullyInvoked() + { + bool innerInvoked = false; + AIFunction inner = AIFunctionFactory.Create(int () => + { + innerInvoked = true; + throw new Exception("uh oh"); + }, "TestFunction", "A test function for DelegatingAIFunction"); + + AIFunction actual = new OverridesInvocation(inner, (args, ct) => new ValueTask(84)); + + Assert.Equal(inner.Name, actual.Name); + Assert.Equal(inner.Description, actual.Description); + Assert.Equal(inner.JsonSchema, actual.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, actual.ReturnJsonSchema); + Assert.Same(inner.JsonSerializerOptions, actual.JsonSerializerOptions); + Assert.Same(inner.UnderlyingMethod, actual.UnderlyingMethod); + Assert.Same(inner.AdditionalProperties, actual.AdditionalProperties); + Assert.Equal(inner.ToString(), actual.ToString()); + + object? result = await actual.InvokeAsync([], CancellationToken.None); + Assert.Contains("84", result?.ToString()); + + Assert.False(innerInvoked); + } + + private sealed class OverridesInvocation(AIFunction innerFunction, Func> invokeAsync) : DelegatingAIFunction(innerFunction) + { + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + invokeAsync(arguments, cancellationToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedCodeInterpreterToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedCodeInterpreterToolTests.cs deleted file mode 100644 index f69ffc5b399..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedCodeInterpreterToolTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class HostedCodeInterpreterToolTests -{ - [Fact] - public void Constructor_Roundtrips() - { - var tool = new HostedCodeInterpreterTool(); - Assert.Equal(nameof(HostedCodeInterpreterTool), tool.Name); - Assert.Empty(tool.Description); - Assert.Empty(tool.AdditionalProperties); - Assert.Equal(nameof(HostedCodeInterpreterTool), tool.ToString()); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedMcpServerToolApprovalModeTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedMcpServerToolApprovalModeTests.cs new file mode 100644 index 00000000000..9f72a194f06 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedMcpServerToolApprovalModeTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedMcpServerToolApprovalModeTests +{ + [Fact] + public void Singletons_Idempotent() + { + Assert.Same(HostedMcpServerToolApprovalMode.AlwaysRequire, HostedMcpServerToolApprovalMode.AlwaysRequire); + Assert.Same(HostedMcpServerToolApprovalMode.NeverRequire, HostedMcpServerToolApprovalMode.NeverRequire); + } + + [Fact] + public void Serialization_NeverRequire_Roundtrips() + { + string json = JsonSerializer.Serialize(HostedMcpServerToolApprovalMode.NeverRequire, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal("""{"$type":"never"}""", json); + + HostedMcpServerToolApprovalMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal(HostedMcpServerToolApprovalMode.NeverRequire, result); + } + + [Fact] + public void Serialization_AlwaysRequire_Roundtrips() + { + string json = JsonSerializer.Serialize(HostedMcpServerToolApprovalMode.AlwaysRequire, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal("""{"$type":"always"}""", json); + + HostedMcpServerToolApprovalMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal(HostedMcpServerToolApprovalMode.AlwaysRequire, result); + } + + [Fact] + public void Serialization_RequireSpecific_Roundtrips() + { + var requireSpecific = HostedMcpServerToolApprovalMode.RequireSpecific(["ToolA", "ToolB"], ["ToolC"]); + string json = JsonSerializer.Serialize(requireSpecific, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal("""{"$type":"requireSpecific","alwaysRequireApprovalToolNames":["ToolA","ToolB"],"neverRequireApprovalToolNames":["ToolC"]}""", json); + + HostedMcpServerToolApprovalMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal(requireSpecific, result); + } + + [Fact] + public void Equality_RequireSpecific_WorksAsExpected() + { + var mode1 = HostedMcpServerToolApprovalMode.RequireSpecific(["ToolA", "ToolB"], ["ToolC"]); + var mode2 = HostedMcpServerToolApprovalMode.RequireSpecific(["ToolA", "ToolB"], ["ToolC"]); + Assert.Equal(mode1, mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode1.AlwaysRequireApprovalToolNames); + mode1.AlwaysRequireApprovalToolNames.Add("ToolD"); + Assert.NotEqual(mode1, mode2); + Assert.NotEqual(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode2.AlwaysRequireApprovalToolNames); + mode2.AlwaysRequireApprovalToolNames.Add("ToolD"); + Assert.Equal(mode1, mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode2.NeverRequireApprovalToolNames); + mode2.NeverRequireApprovalToolNames.Add("ToolE"); + Assert.NotEqual(mode1, mode2); + Assert.NotEqual(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode1.NeverRequireApprovalToolNames); + mode1.NeverRequireApprovalToolNames.Add("ToolE"); + Assert.Equal(mode1, mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + + var mode3 = HostedMcpServerToolApprovalMode.RequireSpecific(null, null); + Assert.Equal(mode3.GetHashCode(), mode3.GetHashCode()); + var mode4 = HostedMcpServerToolApprovalMode.RequireSpecific(["a"], null); + Assert.Equal(mode4.GetHashCode(), mode4.GetHashCode()); + Assert.NotEqual(mode3, mode4); + Assert.NotEqual(mode3.GetHashCode(), mode4.GetHashCode()); + + var mode5 = HostedMcpServerToolApprovalMode.RequireSpecific(null, ["b"]); + Assert.Equal(mode5.GetHashCode(), mode5.GetHashCode()); + Assert.NotEqual(mode3, mode5); + Assert.NotEqual(mode3.GetHashCode(), mode5.GetHashCode()); + Assert.NotEqual(mode4, mode5); + Assert.NotEqual(mode4.GetHashCode(), mode5.GetHashCode()); + + var mode6 = HostedMcpServerToolApprovalMode.RequireSpecific([], []); + Assert.Equal(mode6.GetHashCode(), mode6.GetHashCode()); + Assert.NotEqual(mode3, mode6); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs new file mode 100644 index 00000000000..7e8d189b851 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingImageGeneratorTests +{ + [Fact] + public void RequiresInnerImageGenerator() + { + Assert.Throws("innerGenerator", () => new NoOpDelegatingImageGenerator(null!)); + } + + [Fact] + public async Task GenerateImagesAsyncDefaultsToInnerGeneratorAsync() + { + // Arrange + var expectedRequest = new ImageGenerationRequest("test prompt"); + var expectedOptions = new ImageGenerationOptions(); + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedResponse = new ImageGenerationResponse(); + using var inner = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var resultTask = delegating.GenerateAsync(expectedRequest, expectedOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedResponse); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedResponse, await resultTask); + } + + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestImageGenerator(); + using var delegating = new NoOpDelegatingImageGenerator(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestImageGenerator(); + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var generator = delegating.GetService(); + + // Assert + Assert.Same(delegating, generator); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedKey = new object(); + using var expectedResult = new TestImageGenerator(); + using var inner = new TestImageGenerator + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var generator = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, generator); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestImageGenerator + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var tzi = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + [Fact] + public void Dispose_SetsFlag() + { + using var inner = new TestImageGenerator(); + var delegating = new NoOpDelegatingImageGenerator(inner); + Assert.False(inner.DisposeInvoked); + + delegating.Dispose(); + Assert.True(inner.DisposeInvoked); + } + + [Fact] + public void Dispose_MultipleCallsSafe() + { + using var inner = new TestImageGenerator(); + var delegating = new NoOpDelegatingImageGenerator(inner); + + delegating.Dispose(); + Assert.True(inner.DisposeInvoked); + + // Second dispose should not throw +#pragma warning disable S3966 + delegating.Dispose(); +#pragma warning restore S3966 + Assert.True(inner.DisposeInvoked); + } + + private sealed class NoOpDelegatingImageGenerator(IImageGenerator innerGenerator) + : DelegatingImageGenerator(innerGenerator); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs new file mode 100644 index 00000000000..68040e9c29c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs @@ -0,0 +1,201 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Drawing; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGenerationOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ImageGenerationOptions options = new(); + Assert.Null(options.ResponseFormat); + Assert.Null(options.Count); + Assert.Null(options.ImageSize); + Assert.Null(options.MediaType); + Assert.Null(options.ModelId); + Assert.Null(options.RawRepresentationFactory); + + ImageGenerationOptions clone = options.Clone(); + Assert.Null(clone.ResponseFormat); + Assert.Null(clone.Count); + Assert.Null(clone.ImageSize); + Assert.Null(clone.MediaType); + Assert.Null(clone.ModelId); + Assert.Null(clone.RawRepresentationFactory); + } + + [Fact] + public void Properties_Roundtrip() + { + ImageGenerationOptions options = new(); + + Func factory = generator => new { Representation = "raw data" }; + + options.ResponseFormat = ImageGenerationResponseFormat.Data; + options.Count = 5; + options.ImageSize = new Size(1024, 768); + options.MediaType = "image/png"; + options.ModelId = "modelId"; + options.RawRepresentationFactory = factory; + + Assert.Equal(ImageGenerationResponseFormat.Data, options.ResponseFormat); + Assert.Equal(5, options.Count); + Assert.Equal(new Size(1024, 768), options.ImageSize); + Assert.Equal("image/png", options.MediaType); + Assert.Equal("modelId", options.ModelId); + Assert.Same(factory, options.RawRepresentationFactory); + + ImageGenerationOptions clone = options.Clone(); + Assert.Equal(ImageGenerationResponseFormat.Data, clone.ResponseFormat); + Assert.Equal(5, clone.Count); + Assert.Equal(new Size(1024, 768), clone.ImageSize); + Assert.Equal("image/png", clone.MediaType); + Assert.Equal("modelId", clone.ModelId); + Assert.Same(factory, clone.RawRepresentationFactory); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ImageGenerationOptions options = new() + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 3, + ImageSize = new Size(256, 256), + MediaType = "image/jpeg", + ModelId = "test-model", + }; + + string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ImageGenerationOptions); + + ImageGenerationOptions? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationOptions); + Assert.NotNull(deserialized); + + Assert.Equal(ImageGenerationResponseFormat.Data, deserialized.ResponseFormat); + Assert.Equal(3, deserialized.Count); + Assert.Equal(new Size(256, 256), deserialized.ImageSize); + Assert.Equal("image/jpeg", deserialized.MediaType); + Assert.Equal("test-model", deserialized.ModelId); + } + + [Fact] + public void Clone_CreatesIndependentCopy() + { + ImageGenerationOptions original = new() + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 2, + ImageSize = new Size(512, 512), + MediaType = "image/png", + ModelId = "original-model" + }; + + ImageGenerationOptions clone = original.Clone(); + + // Modify original + original.ResponseFormat = ImageGenerationResponseFormat.Uri; + original.Count = 1; + original.ImageSize = new Size(1024, 1024); + original.MediaType = "image/jpeg"; + original.ModelId = "modified-model"; + + // Clone should remain unchanged + Assert.Equal(ImageGenerationResponseFormat.Data, clone.ResponseFormat); + Assert.Equal(2, clone.Count); + Assert.Equal(new Size(512, 512), clone.ImageSize); + Assert.Equal("image/png", clone.MediaType); + Assert.Equal("original-model", clone.ModelId); + } + + [Theory] + [InlineData(ImageGenerationResponseFormat.Uri)] + [InlineData(ImageGenerationResponseFormat.Data)] + [InlineData(ImageGenerationResponseFormat.Hosted)] + public void ImageGenerationResponseFormat_Values_AreValid(ImageGenerationResponseFormat responseFormat) + { + Assert.True(Enum.IsDefined(typeof(ImageGenerationResponseFormat), responseFormat)); + } + + [Fact] + public void ImageGenerationResponseFormat_JsonSerialization_Roundtrips() + { + foreach (ImageGenerationResponseFormat responseFormat in Enum.GetValues(typeof(ImageGenerationResponseFormat))) + { + string json = JsonSerializer.Serialize(responseFormat, TestJsonSerializerContext.Default.ImageGenerationResponseFormat); + ImageGenerationResponseFormat deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponseFormat); + Assert.Equal(responseFormat, deserialized); + } + } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + ImageGenerationOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : ImageGenerationOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override ImageGenerationOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override ImageGenerationOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : ImageGenerationOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs new file mode 100644 index 00000000000..7b244dfeb53 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGenerationResponseTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ImageGenerationResponse response = new(); + Assert.Empty(response.Contents); + Assert.NotNull(response.Contents); + Assert.Same(response.Contents, response.Contents); + Assert.Empty(response.Contents); + Assert.Null(response.RawRepresentation); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Constructor_List_PropsRoundtrip(int contentCount) + { + List content = []; + for (int i = 0; i < contentCount; i++) + { + content.Add(new UriContent(new Uri($"https://example.com/image-{i}.png"), "image/png")); + } + + ImageGenerationResponse response = new(content); + + Assert.Same(response.Contents, response.Contents); + if (contentCount == 0) + { + Assert.Empty(response.Contents); + } + else + { + Assert.Equal(contentCount, response.Contents.Count); + for (int i = 0; i < contentCount; i++) + { + UriContent uc = Assert.IsType(response.Contents[i]); + Assert.Equal($"https://example.com/image-{i}.png", uc.Uri.ToString()); + Assert.Equal("image/png", uc.MediaType); + } + } + } + + [Fact] + public void Contents_SetNull_ReturnsEmpty() + { + ImageGenerationResponse response = new() + { + Contents = null! + }; + Assert.NotNull(response.Contents); + Assert.Empty(response.Contents); + } + + [Fact] + public void Contents_Set_Roundtrips() + { + ImageGenerationResponse response = new(); + byte[] imageData = [1, 2, 3, 4]; + + List contents = [ + new UriContent(new Uri("https://example.com/image1.png"), "image/png"), + new DataContent(imageData, "image/jpeg") + ]; + + response.Contents = contents; + Assert.Same(contents, response.Contents); + } + + [Fact] + public void RawRepresentation_Roundtrips() + { + ImageGenerationResponse response = new(); + Assert.Null(response.RawRepresentation); + + object representation = new { test = "value" }; + response.RawRepresentation = representation; + Assert.Same(representation, response.RawRepresentation); + + response.RawRepresentation = null; + Assert.Null(response.RawRepresentation); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + List contents = [ + new UriContent(new Uri("https://example.com/image1.png"), "image/png"), + new DataContent((byte[])[1, 2, 3, 4], "image/jpeg") + ]; + + ImageGenerationResponse response = new(contents); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + + Assert.Equal(2, deserialized.Contents.Count); + + UriContent uriContent = Assert.IsType(deserialized.Contents[0]); + Assert.Equal("https://example.com/image1.png", uriContent.Uri.ToString()); + Assert.Equal("image/png", uriContent.MediaType); + + DataContent dataContent = Assert.IsType(deserialized.Contents[1]); + Assert.Equal([1, 2, 3, 4], dataContent.Data.ToArray()); + Assert.Equal("image/jpeg", dataContent.MediaType); + } + + [Fact] + public void JsonSerialization_Empty_Roundtrips() + { + ImageGenerationResponse response = new(); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + Assert.Empty(deserialized.Contents); + } + + [Fact] + public void JsonSerialization_WithVariousContentTypes_Roundtrips() + { + List contents = [ + new UriContent(new Uri("https://example.com/image.png"), "image/png"), + new DataContent((byte[])[255, 216, 255, 224], "image/jpeg"), + new TextContent("Generated image description") // Edge case: text content in image response + ]; + + ImageGenerationResponse response = new(contents); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Contents.Count); + + Assert.IsType(deserialized.Contents[0]); + Assert.IsType(deserialized.Contents[1]); + Assert.IsType(deserialized.Contents[2]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs new file mode 100644 index 00000000000..a68726685eb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorExtensionsTests +{ + [Fact] + public void GetService_InvalidArgs_Throws() + { + Assert.Throws("generator", () => + { + _ = ImageGeneratorExtensions.GetService(null!); + }); + } + + [Fact] + public void GetService_ValidGenerator_CallsUnderlyingGetService() + { + using var testGenerator = new TestImageGenerator(); + var expectedResult = new object(); + var expectedServiceKey = new object(); + + testGenerator.GetServiceCallback = (serviceType, serviceKey) => + { + Assert.Equal(typeof(object), serviceType); + Assert.Same(expectedServiceKey, serviceKey); + return expectedResult; + }; + + var result = testGenerator.GetService(expectedServiceKey); + Assert.Same(expectedResult, result); + } + + [Fact] + public void GetService_ReturnsCorrectType() + { + using var testGenerator = new TestImageGenerator(); + var metadata = new ImageGeneratorMetadata("test", null, "model"); + + testGenerator.GetServiceCallback = (serviceType, serviceKey) => + { + return (serviceType == typeof(ImageGeneratorMetadata)) ? metadata : null; + }; + + var result = testGenerator.GetService(); + Assert.Same(metadata, result); + + var nullResult = testGenerator.GetService(); + Assert.Null(nullResult); + } + + [Fact] + public async Task EditImageAsync_DataContent_CallsGenerateImagesAsync() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var dataContent = new DataContent(imageData, "image/png") { Name = "test.png" }; + var prompt = "Edit this image"; + var options = new ImageGenerationOptions { Count = 2 }; + var expectedResponse = new ImageGenerationResponse(); + var cancellationToken = new CancellationToken(canceled: false); + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + Assert.Single(request.OriginalImages); + Assert.Same(dataContent, Assert.Single(request.OriginalImages)); + Assert.Equal(prompt, request.Prompt); + Assert.Same(options, o); + Assert.Equal(cancellationToken, ct); + return Task.FromResult(expectedResponse); + }; + + // Act + var result = await testGenerator.EditImageAsync(dataContent, prompt, options, cancellationToken); + + // Assert + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task EditImageAsync_DataContent_NullArguments_Throws() + { + using var testGenerator = new TestImageGenerator(); + var dataContent = new DataContent(new byte[] { 1, 2, 3 }, "image/png"); + + await Assert.ThrowsAsync("generator", async () => + await ImageGeneratorExtensions.EditImageAsync(null!, dataContent, "prompt")); + + await Assert.ThrowsAsync("originalImage", async () => + await testGenerator.EditImageAsync(null!, "prompt")); + + await Assert.ThrowsAsync("prompt", async () => + await testGenerator.EditImageAsync(dataContent, null!)); + } + + [Fact] + public async Task EditImageAsync_ByteArray_CallsGenerateImagesAsync() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var fileName = "test.jpg"; + var prompt = "Edit this image"; + var options = new ImageGenerationOptions { Count = 2 }; + var expectedResponse = new ImageGenerationResponse(); + var cancellationToken = new CancellationToken(canceled: false); + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + Assert.Single(request.OriginalImages); + var dataContent = Assert.IsType(Assert.Single(request.OriginalImages)); + Assert.Equal(imageData, dataContent.Data.ToArray()); + Assert.Equal("image/jpeg", dataContent.MediaType); + Assert.Equal(fileName, dataContent.Name); + Assert.Equal(prompt, request.Prompt); + Assert.Same(options, o); + Assert.Equal(cancellationToken, ct); + return Task.FromResult(expectedResponse); + }; + + // Act + var result = await testGenerator.EditImageAsync(imageData, fileName, prompt, options, cancellationToken); + + // Assert + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullGenerator_Throws() + { + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("generator", async () => + await ImageGeneratorExtensions.EditImageAsync(null!, imageData, "test.png", "prompt")); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullFileName_Throws() + { + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("fileName", async () => + await testGenerator.EditImageAsync(imageData, null!, "prompt")); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullPrompt_Throws() + { + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("prompt", async () => + await testGenerator.EditImageAsync(imageData, "test.png", null!)); + } + + [Theory] + [InlineData("test.png", "image/png")] + [InlineData("test.jpg", "image/jpeg")] + [InlineData("test.jpeg", "image/jpeg")] + [InlineData("test.webp", "image/webp")] + [InlineData("test.gif", "image/gif")] + [InlineData("test.bmp", "image/bmp")] + [InlineData("test.tiff", "image/tiff")] + [InlineData("test.tif", "image/tiff")] + [InlineData("test.unknown", "image/png")] // Unknown extension defaults to PNG + [InlineData("TEST.PNG", "image/png")] // Case insensitive + public async Task EditImageAsync_ByteArray_InfersCorrectMediaType(string fileName, string expectedMediaType) + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var prompt = "Edit this image"; + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + var dataContent = Assert.IsType(Assert.Single(request.OriginalImages)); + Assert.Equal(expectedMediaType, dataContent.MediaType); + return Task.FromResult(new ImageGenerationResponse()); + }; + + // Act & Assert + await testGenerator.EditImageAsync(imageData, fileName, prompt); + } + + [Fact] + public async Task EditImageAsync_AllMethods_PassDefaultOptionsAndCancellation() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var dataContent = new DataContent(imageData, "image/png"); + var prompt = "Edit this image"; + + int callCount = 0; + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + callCount++; + Assert.Null(o); // Default options should be null + Assert.Equal(CancellationToken.None, ct); // Default cancellation token + Assert.NotNull(request.OriginalImages); // Should have original images for editing + return Task.FromResult(new ImageGenerationResponse()); + }; + + // Act - Test all two overloads with default parameters + await testGenerator.EditImageAsync(dataContent, prompt); + await testGenerator.EditImageAsync(imageData, "test.png", prompt); + + // Assert + Assert.Equal(2, callCount); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs new file mode 100644 index 00000000000..193a02bde3e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorMetadataTests +{ + [Fact] + public void Constructor_NullValues_AllowedAndRoundtrip() + { + ImageGeneratorMetadata metadata = new(null, null, null); + Assert.Null(metadata.ProviderName); + Assert.Null(metadata.ProviderUri); + Assert.Null(metadata.DefaultModelId); + } + + [Fact] + public void Constructor_Value_Roundtrips() + { + var uri = new Uri("https://example.com"); + ImageGeneratorMetadata metadata = new("providerName", uri, "theModel"); + Assert.Equal("providerName", metadata.ProviderName); + Assert.Same(uri, metadata.ProviderUri); + Assert.Equal("theModel", metadata.DefaultModelId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs new file mode 100644 index 00000000000..c461a460ba4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorTests +{ + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + using var generator = new TestImageGenerator(); + generator.GetServiceCallback = (serviceType, serviceKey) => + { + // When serviceKey is not null, should return null per interface contract + return serviceKey is not null ? null : new object(); + }; + + var result = generator.GetService(typeof(object), "someKey"); + Assert.Null(result); + } + + [Fact] + public void GetService_WithoutServiceKey_CallsCallback() + { + using var generator = new TestImageGenerator(); + var expectedResult = new object(); + + generator.GetServiceCallback = (serviceType, serviceKey) => + { + Assert.Equal(typeof(object), serviceType); + Assert.Null(serviceKey); + return expectedResult; + }; + + var result = generator.GetService(typeof(object)); + Assert.Same(expectedResult, result); + } + + [Fact] + public async Task GenerateImagesAsync_CallsCallback() + { + var expectedResponse = new ImageGenerationResponse(); + var expectedOptions = new ImageGenerationOptions(); + using var cts = new CancellationTokenSource(); + var expectedRequest = new ImageGenerationRequest("test prompt"); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(expectedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + } + }; + + var result = await generator.GenerateAsync(expectedRequest, expectedOptions, cts.Token); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task GenerateImagesAsync_NoCallback_ReturnsEmptyResponse() + { + using var generator = new TestImageGenerator(); + var result = await generator.GenerateAsync(new ImageGenerationRequest("test prompt"), null); + Assert.NotNull(result); + Assert.Empty(result.Contents); + } + + [Fact] + public void Dispose_SetsFlag() + { + var generator = new TestImageGenerator(); + Assert.False(generator.DisposeInvoked); + + generator.Dispose(); + Assert.True(generator.DisposeInvoked); + } + + [Fact] + public void Dispose_MultipleCallsSafe() + { + var generator = new TestImageGenerator(); + + generator.Dispose(); + Assert.True(generator.DisposeInvoked); + + // Second dispose should not throw +#pragma warning disable S3966 + generator.Dispose(); +#pragma warning restore S3966 + Assert.True(generator.DisposeInvoked); + } + + [Fact] + public async Task GenerateImagesAsync_WithOptions_PassesThroughCorrectly() + { + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 3, + ImageSize = new Size(1024, 768), + MediaType = "image/png", + ModelId = "test-model", + }; + + var expectedRequest = new ImageGenerationRequest("test prompt"); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, receivedOptions, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(options, receivedOptions); + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + await generator.GenerateAsync(expectedRequest, options); + } + + [Fact] + public async Task GenerateImagesAsync_WithEditRequest_PassesThroughCorrectly() + { + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 2, + MediaType = "image/jpeg", + ModelId = "edit-model", + }; + + AIContent[] originalImages = [new DataContent((byte[])[1, 2, 3, 4], "image/png")]; + var expectedRequest = new ImageGenerationRequest("edit prompt", originalImages); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, receivedOptions, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(options, receivedOptions); + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + await generator.GenerateAsync(expectedRequest, options); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index 0e608d0d953..4c275e54993 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -29,7 +29,6 @@ - @@ -38,5 +37,6 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ResponseContinuationTokenTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ResponseContinuationTokenTests.cs new file mode 100644 index 00000000000..a6d443a4f47 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ResponseContinuationTokenTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ResponseContinuationTokenTests +{ + [Theory] + [InlineData(new byte[0])] + [InlineData(new byte[] { 1, 2, 3, 4, 5 })] + public void Bytes_Roundtrip(byte[] testBytes) + { + ResponseContinuationToken token = ResponseContinuationToken.FromBytes(testBytes); + + Assert.NotNull(token); + Assert.Equal(testBytes, token.ToBytes().ToArray()); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }); + + // Act + string json = JsonSerializer.Serialize(originalToken, TestJsonSerializerContext.Default.ResponseContinuationToken); + + ResponseContinuationToken? deserializedToken = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ResponseContinuationToken); + + // Assert + Assert.NotNull(deserializedToken); + Assert.Equal(originalToken.ToBytes().ToArray(), deserializedToken.ToBytes().ToArray()); + Assert.NotSame(originalToken, deserializedToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs index 092ad57b2c2..21fb5bb6bf0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs @@ -74,12 +74,10 @@ public async Task GetStreamingTextAsync_CreatesStreamingUpdatesAsync() } // Helper method to simulate streaming updates -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously private static async IAsyncEnumerable GetStreamingUpdatesAsync() { yield return new("hello "); yield return new("world "); yield return new("!"); } -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs index 20936fd4517..4cf0f6461ee 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Text.Json; using Xunit; @@ -34,21 +35,26 @@ public void Properties_Roundtrip() ["key"] = "value", }; + Func rawRepresentationFactory = (c) => null; + options.ModelId = "modelId"; options.SpeechLanguage = "en-US"; options.SpeechSampleRate = 44100; options.AdditionalProperties = additionalProps; + options.RawRepresentationFactory = rawRepresentationFactory; Assert.Equal("modelId", options.ModelId); Assert.Equal("en-US", options.SpeechLanguage); Assert.Equal(44100, options.SpeechSampleRate); Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); SpeechToTextOptions clone = options.Clone(); Assert.Equal("modelId", clone.ModelId); Assert.Equal("en-US", clone.SpeechLanguage); Assert.Equal(44100, clone.SpeechSampleRate); Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); } [Fact] @@ -81,4 +87,70 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + SpeechToTextOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : SpeechToTextOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override SpeechToTextOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override SpeechToTextOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : SpeechToTextOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseTests.cs index 33b27b01291..5c2ff74279e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseTests.cs @@ -31,6 +31,7 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(response.StartTime); Assert.Null(response.EndTime); Assert.Equal(string.Empty, response.ToString()); + Assert.Null(response.Usage); } [Theory] @@ -132,6 +133,11 @@ public void Properties_Roundtrip() List newContents = [new TextContent("text1"), new TextContent("text2")]; response.Contents = newContents; Assert.Same(newContents, response.Contents); + + Assert.Null(response.Usage); + UsageDetails usageDetails = new(); + response.Usage = usageDetails; + Assert.Same(usageDetails, response.Usage); } [Fact] @@ -152,6 +158,7 @@ public void JsonSerialization_Roundtrips() EndTime = TimeSpan.FromSeconds(2), RawRepresentation = new(), AdditionalProperties = new() { ["key"] = "value" }, + Usage = new() { InputTokenCount = 42, OutputTokenCount = 84, TotalTokenCount = 126 }, }; string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.SpeechToTextResponse); @@ -176,6 +183,11 @@ public void JsonSerialization_Roundtrips() Assert.True(result.AdditionalProperties.TryGetValue("key", out object? value)); Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); + + Assert.NotNull(result.Usage); + Assert.Equal(42, result.Usage.InputTokenCount); + Assert.Equal(84, result.Usage.OutputTokenCount); + Assert.Equal(126, result.Usage.TotalTokenCount); } [Fact] @@ -185,8 +197,10 @@ public void ToString_OutputsText() Assert.Equal("This is a test." + Environment.NewLine + "It's multiple lines.", response.ToString()); } - [Fact] - public void ToSpeechToTextResponseUpdates_ReturnsExpectedUpdate() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ToSpeechToTextResponseUpdates_ReturnsExpectedUpdate(bool withUsage) { // Arrange: create a response with contents SpeechToTextResponse response = new() @@ -202,6 +216,7 @@ public void ToSpeechToTextResponseUpdates_ReturnsExpectedUpdate() ResponseId = "12345", ModelId = "someModel", AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + Usage = withUsage ? new UsageDetails { InputTokenCount = 100, OutputTokenCount = 200, TotalTokenCount = 300 } : null }; // Act: convert to streaming updates @@ -217,7 +232,7 @@ public void ToSpeechToTextResponseUpdates_ReturnsExpectedUpdate() Assert.Equal(TimeSpan.FromSeconds(1), update.StartTime); Assert.Equal(TimeSpan.FromSeconds(2), update.EndTime); - Assert.Equal(3, update.Contents.Count); + Assert.Equal(withUsage ? 4 : 3, update.Contents.Count); Assert.Equal("Hello, ", Assert.IsType(update.Contents[0]).Text); Assert.Equal("image/png", Assert.IsType(update.Contents[1]).MediaType); Assert.Equal("world!", Assert.IsType(update.Contents[2]).Text); @@ -225,5 +240,13 @@ public void ToSpeechToTextResponseUpdates_ReturnsExpectedUpdate() Assert.NotNull(update.AdditionalProperties); Assert.Equal("value1", update.AdditionalProperties["key1"]); Assert.Equal(42, update.AdditionalProperties["key2"]); + + if (withUsage) + { + var usage = Assert.IsType(update.Contents[3]); + Assert.Equal(100, usage.Details.InputTokenCount); + Assert.Equal(200, usage.Details.OutputTokenCount); + Assert.Equal(300, usage.Details.TotalTokenCount); + } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs index f0a2f08ab13..f288d3db28b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs @@ -54,8 +54,8 @@ public async Task ToSpeechToTextResponse_SuccessfullyCreatesResponse(bool useAsy ]; SpeechToTextResponse response = useAsync ? - updates.ToSpeechToTextResponse() : - await YieldAsync(updates).ToSpeechToTextResponseAsync(); + await YieldAsync(updates).ToSpeechToTextResponseAsync() : + updates.ToSpeechToTextResponse(); Assert.NotNull(response); @@ -70,6 +70,8 @@ public async Task ToSpeechToTextResponse_SuccessfullyCreatesResponse(bool useAsy Assert.Equal("d", response.AdditionalProperties["c"]); Assert.Equal("Hello human, How are You?", response.Text); + + Assert.Null(response.Usage); } [Theory] @@ -129,6 +131,28 @@ void AddGap() } } + [Fact] + public async Task ToSpeechToTextResponse_UsageContentExtractedFromContents() + { + SpeechToTextResponseUpdate[] updates = + { + new() { Contents = [new TextContent("Hello, ")] }, + new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] }, + new() { Contents = [new TextContent("world!")] }, + new() { Contents = [new UsageContent(new() { InputTokenCount = 12, TotalTokenCount = 24 })] }, + }; + + SpeechToTextResponse response = await YieldAsync(updates).ToSpeechToTextResponseAsync(); + + Assert.NotNull(response); + + Assert.NotNull(response.Usage); + Assert.Equal(12, response.Usage.InputTokenCount); + Assert.Equal(66, response.Usage.TotalTokenCount); + + Assert.Equal("Hello, world!", Assert.IsType(Assert.Single(response.Contents)).Text); + } + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (SpeechToTextResponseUpdate update in updates) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs index 0eae376070e..ec3ac1937e8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs @@ -38,8 +38,7 @@ public void Properties_Roundtrip() Assert.Empty(update.Text); // Contents: assigning a new list then resetting to null should yield an empty list. - List newList = new(); - newList.Add(new TextContent("content1")); + List newList = [new TextContent("content1")]; update.Contents = newList; Assert.Same(newList, update.Contents); update.Contents = null; @@ -89,11 +88,11 @@ public void JsonSerialization_Roundtrips() ResponseId = "id123", StartTime = TimeSpan.FromSeconds(5), EndTime = TimeSpan.FromSeconds(10), - Contents = new List - { + Contents = + [ new TextContent("text-1"), new DataContent("data:audio/wav;base64,AQIDBA==", "application/octet-stream") - } + ] }; string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.SpeechToTextResponseUpdate); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs new file mode 100644 index 00000000000..4db1cca7377 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +public sealed class TestImageGenerator : IImageGenerator +{ + public TestImageGenerator() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + public IServiceProvider? Services { get; set; } + + public Func>? GenerateImagesAsyncCallback { get; set; } + + public Func GetServiceCallback { get; set; } + + public bool DisposeInvoked { get; private set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return GenerateImagesAsyncCallback?.Invoke(request, options, cancellationToken) ?? + Task.FromResult(new ImageGenerationResponse()); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return GetServiceCallback.Invoke(serviceType, serviceKey); + } + + public void Dispose() + { + DisposeInvoked = true; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index d15f0a19fa9..faaa799baf4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -20,6 +20,8 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(SpeechToTextResponseUpdateKind))] [JsonSerializable(typeof(SpeechToTextOptions))] +[JsonSerializable(typeof(ImageGenerationResponse))] +[JsonSerializable(typeof(ImageGenerationOptions))] [JsonSerializable(typeof(ChatOptions))] [JsonSerializable(typeof(EmbeddingGenerationOptions))] [JsonSerializable(typeof(Dictionary))] @@ -33,4 +35,10 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(DayOfWeek[]))] // Used in Content tests [JsonSerializable(typeof(Guid))] // Used in Content tests [JsonSerializable(typeof(decimal))] // Used in Content tests +[JsonSerializable(typeof(HostedMcpServerToolApprovalMode))] +[JsonSerializable(typeof(ChatResponseFormatTests.SomeType))] +[JsonSerializable(typeof(ChatResponseFormatTests.TypeWithDisplayName))] +[JsonSerializable(typeof(ResponseContinuationToken))] +[JsonSerializable(typeof(UserInputRequestContent[]))] +[JsonSerializable(typeof(UserInputResponseContent[]))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/AIToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/AIToolTests.cs new file mode 100644 index 00000000000..1a22f2d838e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/AIToolTests.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + DerivedAITool tool = new(); + Assert.Equal(nameof(DerivedAITool), tool.Name); + Assert.Equal(nameof(DerivedAITool), tool.ToString()); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + } + + [Fact] + public void GetService_ReturnsExpectedObject() + { + DerivedAITool tool = new(); + + Assert.Throws("serviceType", () => tool.GetService(null!)); + + Assert.Same(tool, tool.GetService(typeof(object))); + Assert.Same(tool, tool.GetService(typeof(AITool))); + Assert.Same(tool, tool.GetService(typeof(DerivedAITool))); + + Assert.Same(tool, tool.GetService()); + Assert.Same(tool, tool.GetService()); + Assert.Same(tool, tool.GetService()); + + Assert.Null(tool.GetService(typeof(string))); + Assert.Null(tool.GetService()); + Assert.Null(tool.GetService("key")); + Assert.Null(tool.GetService("key")); + Assert.Null(tool.GetService("key")); + } + + private sealed class DerivedAITool : AITool; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs new file mode 100644 index 00000000000..19044a6a295 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedCodeInterpreterToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedCodeInterpreterTool(); + Assert.Equal("code_interpreter", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Null(tool.Inputs); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Properties_Roundtrip() + { + var tool = new HostedCodeInterpreterTool + { + Inputs = + [ + new HostedFileContent("id123"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") + ] + }; + + Assert.NotNull(tool.Inputs); + Assert.Equal(2, tool.Inputs.Count); + Assert.IsType(tool.Inputs[0]); + Assert.IsType(tool.Inputs[1]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs new file mode 100644 index 00000000000..e2d71a65013 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedFileSearchToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedFileSearchTool(); + Assert.Equal("file_search", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Null(tool.Inputs); + Assert.Null(tool.MaximumResultCount); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Properties_Roundtrip() + { + var tool = new HostedFileSearchTool + { + Inputs = + [ + new HostedVectorStoreContent("id123"), + new HostedFileContent("id456"), + ], + MaximumResultCount = 10, + }; + + Assert.NotNull(tool.Inputs); + Assert.Equal(2, tool.Inputs.Count); + Assert.Equal(10, tool.MaximumResultCount); + Assert.IsType(tool.Inputs[0]); + Assert.IsType(tool.Inputs[1]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs new file mode 100644 index 00000000000..ec1dc407973 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedMcpServerToolTests +{ + [Fact] + public void Constructor_PropsDefault() + { + HostedMcpServerTool tool = new("serverName", new Uri("https://localhost/")); + + Assert.Empty(tool.AdditionalProperties); + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("https://localhost/", tool.ServerAddress); + + Assert.Empty(tool.Description); + Assert.Null(tool.AuthorizationToken); + Assert.Null(tool.ServerDescription); + Assert.Null(tool.AllowedTools); + Assert.Null(tool.ApprovalMode); + } + + [Fact] + public void Constructor_Roundtrips() + { + HostedMcpServerTool tool = new("serverName", "connector_id"); + + Assert.Empty(tool.AdditionalProperties); + Assert.Empty(tool.Description); + Assert.Equal("mcp", tool.Name); + Assert.Equal(tool.Name, tool.ToString()); + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("connector_id", tool.ServerAddress); + Assert.Empty(tool.Description); + + Assert.Null(tool.AuthorizationToken); + string authToken = "Bearer token123"; + tool.AuthorizationToken = authToken; + Assert.Equal(authToken, tool.AuthorizationToken); + + Assert.Null(tool.ServerDescription); + string serverDescription = "This is a test server"; + tool.ServerDescription = serverDescription; + Assert.Equal(serverDescription, tool.ServerDescription); + + Assert.Null(tool.AllowedTools); + List allowedTools = ["tool1", "tool2"]; + tool.AllowedTools = allowedTools; + Assert.Same(allowedTools, tool.AllowedTools); + + Assert.Null(tool.ApprovalMode); + tool.ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire; + Assert.Same(HostedMcpServerToolApprovalMode.NeverRequire, tool.ApprovalMode); + + tool.ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire; + Assert.Same(HostedMcpServerToolApprovalMode.AlwaysRequire, tool.ApprovalMode); + + var customApprovalMode = new HostedMcpServerToolRequireSpecificApprovalMode(["tool1"], ["tool2"]); + tool.ApprovalMode = customApprovalMode; + Assert.Same(customApprovalMode, tool.ApprovalMode); + } + + [Fact] + public void Constructor_Throws() + { + Assert.Throws("serverName", () => new HostedMcpServerTool(string.Empty, "https://localhost/")); + Assert.Throws("serverName", () => new HostedMcpServerTool(string.Empty, new Uri("https://localhost/"))); + Assert.Throws("serverName", () => new HostedMcpServerTool(null!, "https://localhost/")); + Assert.Throws("serverName", () => new HostedMcpServerTool(null!, new Uri("https://localhost/"))); + + Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", string.Empty)); + Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", new Uri("/api/mcp", UriKind.Relative))); + Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", (string)null!)); + Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", (Uri)null!)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedWebSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs similarity index 76% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedWebSearchToolTests.cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs index 4b03cbb0031..4bb6ca4b847 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedWebSearchToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs @@ -11,9 +11,9 @@ public class HostedWebSearchToolTests public void Constructor_Roundtrips() { var tool = new HostedWebSearchTool(); - Assert.Equal(nameof(HostedWebSearchTool), tool.Name); + Assert.Equal("web_search", tool.Name); Assert.Empty(tool.Description); Assert.Empty(tool.AdditionalProperties); - Assert.Equal(nameof(HostedWebSearchTool), tool.ToString()); + Assert.Equal(tool.Name, tool.ToString()); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs index 4233e5cdbe1..8f514f7a15e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs @@ -12,13 +12,13 @@ public static class AIJsonSchemaTransformCacheTests [Fact] public static void NullOptions_ThrowsArgumentNullException() { - Assert.Throws(() => new AIJsonSchemaTransformCache(transformOptions: null!)); + Assert.Throws("transformOptions", () => new AIJsonSchemaTransformCache(transformOptions: null!)); } [Fact] public static void EmptyOptions_ThrowsArgumentException() { - Assert.Throws(() => new AIJsonSchemaTransformCache(transformOptions: new())); + Assert.Throws("transformOptions", () => new AIJsonSchemaTransformCache(transformOptions: new())); } [Fact] @@ -33,14 +33,14 @@ public static void TransformOptions_ReturnsExpectedValue() public static void NullFunction_ThrowsArgumentNullException() { AIJsonSchemaTransformCache cache = new(new() { ConvertBooleanSchemas = true }); - Assert.Throws(() => cache.GetOrCreateTransformedSchema(function: null!)); + Assert.Throws("function", () => cache.GetOrCreateTransformedSchema(function: null!)); } [Fact] public static void NullResponseFormat_ThrowsArgumentNullException() { AIJsonSchemaTransformCache cache = new(new() { ConvertBooleanSchemas = true }); - Assert.Throws(() => cache.GetOrCreateTransformedSchema(responseFormat: null!)); + Assert.Throws("responseFormat", () => cache.GetOrCreateTransformedSchema(responseFormat: null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 0001b8b2125..8ebc20b957e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -13,9 +15,10 @@ using System.Text.Json.Serialization.Metadata; using System.Threading; using Microsoft.Extensions.AI.JsonSchemaExporter; +using Microsoft.TestUtilities; using Xunit; -#pragma warning disable 0618 // Suppress obsolete warnings +#pragma warning disable SA1114 // parameter list should follow declaration namespace Microsoft.Extensions.AI; @@ -73,10 +76,7 @@ public static void DefaultOptions_UsesReflectionWhenDefault() public static void AIJsonSchemaCreateOptions_DefaultInstance_ReturnsExpectedValues(bool useSingleton) { AIJsonSchemaCreateOptions options = useSingleton ? AIJsonSchemaCreateOptions.Default : new AIJsonSchemaCreateOptions(); - Assert.True(options.IncludeTypeInEnumSchemas); - Assert.False(options.DisallowAdditionalProperties); Assert.False(options.IncludeSchemaKeyword); - Assert.False(options.RequireAllProperties); Assert.Null(options.TransformSchemaNode); Assert.Null(options.TransformOptions); } @@ -170,6 +170,21 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem AssertDeepEquals(expected, actual); } + [Fact] + public static void CreateJsonSchema_TrivialArray_GeneratesExpectedJsonSchema() + { + JsonElement expected = JsonDocument.Parse(""" + { + "type": "array", + "items": {} + } + """).RootElement; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(object[]), serializerOptions: JsonContext.Default.Options); + + AssertDeepEquals(expected, actual); + } + [Fact] public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSchema() { @@ -263,44 +278,6 @@ public static void CreateJsonSchema_UserDefinedTransformer() AssertDeepEquals(expected, actual); } - [Fact] - public static void CreateJsonSchema_FiltersDisallowedKeywords() - { - JsonElement expected = JsonDocument.Parse(""" - { - "type": "object", - "properties": { - "Date": { - "type": "string" - }, - "TimeSpan": { - "$comment": "Represents a System.TimeSpan value.", - "type": "string" - }, - "Char" : { - "type": "string" - } - } - } - """).RootElement; - - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonContext.Default.Options); - - AssertDeepEquals(expected, actual); - } - - public class PocoWithTypesWithOpenAIUnsupportedKeywords - { - // Uses the unsupported "format" keyword - public DateTimeOffset Date { get; init; } - - // Uses the unsupported "pattern" keyword - public TimeSpan TimeSpan { get; init; } - - // Uses the unsupported "minLength" and "maxLength" keywords - public char Char { get; init; } - } - [Fact] public static void CreateFunctionJsonSchema_ReturnsExpectedValue() { @@ -309,7 +286,7 @@ public static void CreateFunctionJsonSchema_ReturnsExpectedValue() Assert.NotNull(func.UnderlyingMethod); - JsonElement resolvedSchema = AIJsonUtilities.CreateFunctionJsonSchema(func.UnderlyingMethod, title: func.Name); + JsonElement resolvedSchema = AIJsonUtilities.CreateFunctionJsonSchema(func.UnderlyingMethod, title: string.Empty); AssertDeepEquals(resolvedSchema, func.JsonSchema); } @@ -338,8 +315,6 @@ public static void CreateFunctionJsonSchema_OptionalParameters(bool requireAllPr JsonElement expected = JsonDocument.Parse($$""" { - "title": "get_weather", - "description": "Gets the current weather for a current location", "type": "object", "properties": { "city": { @@ -374,11 +349,7 @@ public static void CreateFunctionJsonSchema_OptionalParameters(bool requireAllPr Assert.NotNull(func.UnderlyingMethod); AssertDeepEquals(expected, func.JsonSchema); - JsonElement resolvedSchema = AIJsonUtilities.CreateFunctionJsonSchema( - func.UnderlyingMethod, - title: func.Name, - description: func.Description, - inferenceOptions: inferenceOptions); + JsonElement resolvedSchema = AIJsonUtilities.CreateFunctionJsonSchema(func.UnderlyingMethod, title: string.Empty, description: string.Empty, inferenceOptions: inferenceOptions); AssertDeepEquals(expected, resolvedSchema); } @@ -391,20 +362,26 @@ public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWit JsonElement schemaParameters = func.JsonSchema.GetProperty("properties"); Assert.NotNull(func.UnderlyingMethod); ParameterInfo[] parameters = func.UnderlyingMethod.GetParameters(); -#if NET9_0_OR_GREATER Assert.Equal(parameters.Length, schemaParameters.GetPropertyCount()); -#endif int i = 0; foreach (JsonProperty property in schemaParameters.EnumerateObject()) { - string numericType = Type.GetTypeCode(parameters[i].ParameterType) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal - ? "number" - : "integer"; + bool isNullable = false; + Type type = parameters[i].ParameterType; + if (Nullable.GetUnderlyingType(type) is { } elementType) + { + type = elementType; + isNullable = true; + } + + string numericType = Type.GetTypeCode(type) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal + ? "\"number\"" + : "\"integer\""; JsonElement expected = JsonDocument.Parse($$""" { - "type": "{{numericType}}" + "type": {{(isNullable ? $"[{numericType}, \"null\"]" : numericType)}} } """).RootElement; @@ -424,6 +401,63 @@ public enum MyEnumValue B = 2 } + [Fact] + public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttributes() + { + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + AIFunction func = AIFunctionFactory.Create(([Range(1, 10)] int num, [StringLength(100, MinimumLength = 1)] string str) => num + str.Length, serializerOptions: options); + + using JsonDocument expectedSchema = JsonDocument.Parse(""" + { + "type":"object", + "properties": { + "num": { "type":"integer", "minimum": 1, "maximum": 10 }, + "str": { "type":"string", "minLength": 1, "maxLength": 100 } + }, + "required":["num","str"] + } + """); + + AssertDeepEquals(expectedSchema.RootElement, func.JsonSchema); + } + + [Fact] + public static void CreateFunctionJsonSchema_DisplayNameAttribute_UsedForTitle() + { + [DisplayName("custom_method_name")] + [Description("Method description")] + static void TestMethod(int x, int y) + { + // Test method for schema generation + } + + var method = ((Action)TestMethod).Method; + JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method); + + using JsonDocument doc = JsonDocument.Parse(schema.GetRawText()); + Assert.True(doc.RootElement.TryGetProperty("title", out JsonElement titleElement)); + Assert.Equal("custom_method_name", titleElement.GetString()); + Assert.True(doc.RootElement.TryGetProperty("description", out JsonElement descElement)); + Assert.Equal("Method description", descElement.GetString()); + } + + [Fact] + public static void CreateFunctionJsonSchema_DisplayNameAttribute_CanBeOverridden() + { + [DisplayName("custom_method_name")] + static void TestMethod() + { + // Test method for schema generation + } + + var method = ((Action)TestMethod).Method; + JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method, title: "override_title"); + + using JsonDocument doc = JsonDocument.Parse(schema.GetRawText()); + Assert.True(doc.RootElement.TryGetProperty("title", out JsonElement titleElement)); + Assert.Equal("override_title", titleElement.GetString()); + } + [Fact] public static void CreateJsonSchema_CanBeBoolean() { @@ -449,6 +483,7 @@ public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options, inferenceOptions: createOptions); JsonNode? schemaAsNode = JsonSerializer.SerializeToNode(schema, options); + // NOTE: This is not validating the schemas match, only that they have the same top-level kind. Assert.NotNull(schemaAsNode); Assert.Equal(testData.ExpectedJsonSchema.GetValueKind(), schemaAsNode.GetValueKind()); @@ -477,19 +512,542 @@ public static void CreateJsonSchema_AcceptsOptionsWithoutResolver() Assert.Same(options.TypeInfoResolver, AIJsonUtilities.DefaultOptions.TypeInfoResolver); } + [Fact] + public static void CreateJsonSchema_NullableEnum_IncludesTypeKeyword() + { + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": ["string", "null"], + "enum": ["A", "B", null] + } + """).RootElement; + + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(MyEnumValue?), serializerOptions: JsonContext.Default.Options); + AssertDeepEquals(expectedSchema, schema); + } + + [ConditionalFact] + public static void CreateJsonSchema_IncorporatesTypesAndAnnotations_Net() + { + if (RuntimeInformation.FrameworkDescription.Contains(".NET Framework")) + { + return; + } + + AssertDeepEquals(JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "DisplayNameProp": { + "type": [ + "string", + "null" + ], + "title": "Display Name Title" + }, + "DateTimeProp": { + "type": "string", + "format": "date-time" + }, + "DateTimeOffsetProp": { + "type": "string", + "format": "date-time" + }, + "TimeSpanProp": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "GuidProp": { + "type": "string", + "format": "uuid" + }, + "UriProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "Base64Prop": { + "type": [ + "string", + "null" + ], + "contentEncoding": "base64" + }, + "RegexProp": { + "type": [ + "string", + "null" + ], + "pattern": "[abc]|[def]" + }, + "EmailProp": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "UrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "RangeProp": { + "type": "integer", + "minimum": 12, + "maximum": 34 + }, + "AllowedStringValuesProp": { + "type": [ + "string", + "null" + ], + "enum": [ + "abc", + "def", + "ghi" + ] + }, + "AllowedInt32ValuesProp": { + "type": "integer", + "enum": [ + 1, + 3, + 5 + ] + }, + "AllowedDoubleValuesProp": { + "type": "number", + "enum": [ + 1.2, + 3.4 + ] + }, + "DeniedValuesProp": { + "type": [ + "string", + "null" + ], + "not": { + "enum": [ + "jkl", + "mnop" + ] + } + }, + "DataTypeDateTimeProp": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "DataTypeDateProp": { + "type": [ + "string", + "null" + ], + "format": "date" + }, + "DataTypeTimeProp": { + "type": [ + "string", + "null" + ], + "format": "time" + }, + "DataTypeEmailProp": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "DataTypeUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "DataTypeImageUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri", + "contentMediaType": "image/*" + }, + "DateOnlyProp": { + "type": "string", + "format": "date" + }, + "TimeOnlyProp": { + "type": "string", + "format": "time" + }, + "StringLengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 10, + "maxLength": 100 + }, + "MinLengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 5 + }, + "MaxLengthProp": { + "type": [ + "string", + "null" + ], + "maxLength": 50 + }, + "LengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 3, + "maxLength": 10 + }, + "MinLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "minItems": 2 + }, + "MaxLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "maxItems": 8 + }, + "LengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 4 + } + } + } + """, + JsonContext.Default.JsonElement), + AIJsonUtilities.CreateJsonSchema(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type), serializerOptions: JsonContext.Default.Options)); + } + + // .NET Framework only has a subset of the available data annotation attributes. + // .NET Standard doesn't have any (the M.E.AI.Abstractions library doesn't reference the additional package). + [ConditionalFact] + public static void CreateJsonSchema_IncorporatesTypesAndAnnotations_NetFx() + { + if (!RuntimeInformation.FrameworkDescription.Contains(".NET Framework")) + { + return; + } + + AssertDeepEquals(JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "DisplayNameProp": { + "type": [ + "string", + "null" + ], + "title": "Display Name Title" + }, + "DateTimeProp": { + "type": "string", + "format": "date-time" + }, + "DateTimeOffsetProp": { + "type": "string", + "format": "date-time" + }, + "TimeSpanProp": { + "$comment": "Represents a System.TimeSpan value.", + "type": "string", + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$" + }, + "GuidProp": { + "type": "string", + "format": "uuid" + }, + "UriProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "RegexProp": { + "type": [ + "string", + "null" + ], + "pattern": "[abc]|[def]" + }, + "EmailProp": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "UrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "RangeProp": { + "type": "integer", + "minimum": 12, + "maximum": 34 + }, + "DataTypeDateTimeProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeDateProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeTimeProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeEmailProp": { + "type": [ + "string", + "null" + ] + }, + "DataTypeUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "DataTypeImageUrlProp": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "StringLengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 10, + "maxLength": 100 + }, + "MinLengthProp": { + "type": [ + "string", + "null" + ], + "minLength": 5 + }, + "MaxLengthProp": { + "type": [ + "string", + "null" + ], + "maxLength": 50 + }, + "MinLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "minItems": 2 + }, + "MaxLengthArrayProp": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + }, + "maxItems": 8 + } + } + } + """, + JsonContext.Default.JsonElement), + AIJsonUtilities.CreateJsonSchema(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type), serializerOptions: JsonContext.Default.Options)); + } + + private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type + { + [DisplayName("Display Name Title")] + public string? DisplayNameProp { get; set; } + + public DateTime DateTimeProp { get; set; } + + public DateTimeOffset DateTimeOffsetProp { get; set; } + + public TimeSpan TimeSpanProp { get; set; } + + public Guid GuidProp { get; set; } + + public Uri? UriProp { get; set; } + + [RegularExpression("[abc]|[def]")] + public string? RegexProp { get; set; } + + [EmailAddress] + public string? EmailProp { get; set; } + + [Url] + public Uri? UrlProp { get; set; } + + [Range(12, 34)] + public int RangeProp { get; set; } + + [DataType(DataType.DateTime)] + public string? DataTypeDateTimeProp { get; set; } + + [DataType(DataType.Date)] + public string? DataTypeDateProp { get; set; } + + [DataType(DataType.Time)] + public string? DataTypeTimeProp { get; set; } + + [DataType(DataType.EmailAddress)] + public string? DataTypeEmailProp { get; set; } + + [DataType(DataType.Url)] + public Uri? DataTypeUrlProp { get; set; } + + [DataType(DataType.ImageUrl)] + public Uri? DataTypeImageUrlProp { get; set; } + + [StringLength(100, MinimumLength = 10)] + public string? StringLengthProp { get; set; } + + [MinLength(5)] + public string? MinLengthProp { get; set; } + + [MaxLength(50)] + public string? MaxLengthProp { get; set; } + + [MinLength(2)] + public int[]? MinLengthArrayProp { get; set; } + + [MaxLength(8)] + public int[]? MaxLengthArrayProp { get; set; } + +#if NET + [Base64String] + public string? Base64Prop { get; set; } + + [AllowedValues("abc", "def", "ghi")] + public string? AllowedStringValuesProp { get; set; } + + [AllowedValues(1, 3, 5)] + public int AllowedInt32ValuesProp { get; set; } + + [AllowedValues(1.2, 3.4)] + public double AllowedDoubleValuesProp { get; set; } + + [DeniedValues("jkl", "mnop")] + public string? DeniedValuesProp { get; set; } + + public DateOnly DateOnlyProp { get; set; } + + public TimeOnly TimeOnlyProp { get; set; } + + [Length(3, 10)] + public string? LengthProp { get; set; } + + [Length(1, 4)] + public int[]? LengthArrayProp { get; set; } +#endif + } + + [Fact] + public static void ClassWithNullableMaxLengthProperty_ReturnsExpectedSchema() + { + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "Value": { + "type": ["string", "null"], + "maxLength": 24, + "minLength": 10 + } + } + } + """).RootElement; + + JsonElement actualSchema = AIJsonUtilities.CreateJsonSchema(typeof(ClassWithNullableMaxLengthProperty), serializerOptions: JsonContext.Default.Options); + AssertDeepEquals(expectedSchema, actualSchema); + } + + public class ClassWithNullableMaxLengthProperty + { + [MinLength(10)] + [MaxLength(24)] + public string? Value { get; set; } + } + [Fact] public static void AddAIContentType_DerivedAIContent() { JsonSerializerOptions options = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; options.AddAIContentType("derivativeContent"); AIContent c = new DerivedAIContent { DerivedValue = 42 }; string json = JsonSerializer.Serialize(c, options); - Assert.Equal("""{"$type":"derivativeContent","DerivedValue":42,"AdditionalProperties":null}""", json); + Assert.Equal("""{"$type":"derivativeContent","DerivedValue":42}""", json); AIContent? deserialized = JsonSerializer.Deserialize(json, options); Assert.IsType(deserialized); @@ -514,14 +1072,17 @@ public static void AddAIContentType_NonAIContent_ThrowsArgumentException() public static void AddAIContentType_BuiltInAIContent_ThrowsArgumentException() { JsonSerializerOptions options = new(); - Assert.Throws(() => options.AddAIContentType("discriminator")); - Assert.Throws(() => options.AddAIContentType("discriminator")); + Assert.Throws("contentType", () => options.AddAIContentType("discriminator")); + Assert.Throws("contentType", () => options.AddAIContentType("discriminator")); } [Fact] public static void AddAIContentType_ConflictingIdentifier_ThrowsInvalidOperationException() { - JsonSerializerOptions options = new(); + JsonSerializerOptions options = new() + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default), + }; options.AddAIContentType("text"); options.AddAIContentType("audio"); @@ -554,13 +1115,30 @@ public static void HashData_Idempotent() string key2 = AIJsonUtilities.HashDataToString(["a", 'b', 42], options); string key3 = AIJsonUtilities.HashDataToString([TimeSpan.FromSeconds(1), null, 1.23], options); string key4 = AIJsonUtilities.HashDataToString([TimeSpan.FromSeconds(1), null, 1.23], options); + string key5 = AIJsonUtilities.HashDataToString([new Dictionary { ["key1"] = 1, ["key2"] = 2 }], options); + string key6 = AIJsonUtilities.HashDataToString([new Dictionary { ["key2"] = 2, ["key1"] = 1 }], options); Assert.Equal(key1, key2); Assert.Equal(key3, key4); + Assert.Equal(key5, key6); Assert.NotEqual(key1, key3); + Assert.NotEqual(key1, key5); } } + [Fact] + public static void HashData_IndentationInvariant() + { + JsonSerializerOptions indentOptions = new(AIJsonUtilities.DefaultOptions) { WriteIndented = true }; + JsonSerializerOptions noIndentOptions = new(AIJsonUtilities.DefaultOptions) { WriteIndented = false }; + + Dictionary dict = new() { ["key1"] = 1, ["key2"] = 2 }; + string key1 = AIJsonUtilities.HashDataToString([dict], indentOptions); + string key2 = AIJsonUtilities.HashDataToString([dict], noIndentOptions); + + Assert.Equal(key1, key2); + } + [Fact] public static void CreateFunctionJsonSchema_InvokesIncludeParameterCallbackForEveryParameter() { @@ -821,8 +1399,8 @@ public static void TransformJsonSchema_ValidateWithTestData(ITestData testData) public static void TransformJsonSchema_InvalidOptions_ThrowsArgumentException() { JsonElement schema = JsonDocument.Parse("{}").RootElement; - Assert.Throws(() => AIJsonUtilities.TransformSchema(schema, transformOptions: null!)); - Assert.Throws(() => AIJsonUtilities.TransformSchema(schema, transformOptions: new())); + Assert.Throws("transformOptions", () => AIJsonUtilities.TransformSchema(schema, transformOptions: null!)); + Assert.Throws("transformOptions", () => AIJsonUtilities.TransformSchema(schema, transformOptions: new())); } [Theory] @@ -835,7 +1413,7 @@ public static void TransformJsonSchema_InvalidInput_ThrowsArgumentException(stri { JsonElement schema = JsonDocument.Parse(invalidSchema).RootElement; AIJsonSchemaTransformOptions transformOptions = new() { ConvertBooleanSchemas = true }; - Assert.Throws(() => AIJsonUtilities.TransformSchema(schema, transformOptions)); + Assert.Throws("schema", () => AIJsonUtilities.TransformSchema(schema, transformOptions)); } private class DerivedAIContent : AIContent @@ -843,20 +1421,18 @@ private class DerivedAIContent : AIContent public int DerivedValue { get; set; } } + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type))] [JsonSerializable(typeof(DerivedAIContent))] [JsonSerializable(typeof(MyPoco))] - [JsonSerializable(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords))] + [JsonSerializable(typeof(MyEnumValue?))] + [JsonSerializable(typeof(object[]))] + [JsonSerializable(typeof(ClassWithNullableMaxLengthProperty))] private partial class JsonContext : JsonSerializerContext; private static bool DeepEquals(JsonElement element1, JsonElement element2) { -#if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); -#else - return JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); -#endif } private static void AssertDeepEquals(JsonElement element1, JsonElement element2) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs index 26cd380ec83..8b9f3d50cc2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceChatClientTests.cs @@ -56,7 +56,7 @@ public void AsIChatClient_ProducesExpectedMetadata() IChatClient chatClient = client.AsIChatClient(model); var metadata = chatClient.GetService(); - Assert.Equal("az.ai.inference", metadata?.ProviderName); + Assert.Equal("azure.ai.inference", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); Assert.Equal(model, metadata?.DefaultModelId); } @@ -327,22 +327,6 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio using IChatClient client = CreateChatClient(httpClient, modelId: null!); AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); - ChatCompletionsOptions azureAIOptions = new() - { - Model = "gpt-4o-mini", - FrequencyPenalty = 0.75f, - MaxTokens = 10, - NucleusSamplingFactor = 0.5f, - PresencePenalty = 0.5f, - Temperature = 0.5f, - Seed = 42, - ToolChoice = ChatCompletionsToolChoice.Auto, - ResponseFormat = ChatCompletionsResponseFormat.CreateTextFormat() - }; - azureAIOptions.StopSequences.Add("hello"); - azureAIOptions.Tools.Add(ToAzureAIChatTool(tool)); - azureAIOptions.AdditionalProperties["additional_property_from_raw_representation"] = new BinaryData("42"); - ChatOptions chatOptions = new ChatOptions { RawRepresentationFactory = (c) => diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs index baee2555990..31e9980a330 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceEmbeddingGeneratorTests.cs @@ -29,7 +29,7 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() } [Fact] - public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() + public void AsIEmbeddingGenerator_AzureAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; @@ -38,7 +38,7 @@ public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() IEmbeddingGenerator> embeddingGenerator = client.AsIEmbeddingGenerator(model); var metadata = embeddingGenerator.GetService(); - Assert.Equal("az.ai.inference", metadata?.ProviderName); + Assert.Equal("azure.ai.inference", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); Assert.Equal(model, metadata?.DefaultModelId); } @@ -122,4 +122,67 @@ public async Task GenerateAsync_ExpectedRequestResponse() Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); } } + + [Fact] + public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation() + { + const string Input = """ + { + "input":["hello, world!","red, white, blue"], + "dimensions":1536, + "encoding_format":"base64", + "model":"text-embedding-3-small" + } + """; + + const string Output = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "qjH+vMcj07wP1+U7kbwjOv4cwLyL3iy9DkgpvCkBQD0bthW98o6SvMMwmTrQRQa9r7b1uy4tuLzssJs7jZspPe0JG70KJy89ae4fPNLUwjytoHk9BX/1OlXCfTzc07M8JAMIPU7cibsUJiC8pTNGPWUbJztfwW69oNwOPQIQ+rwm60M7oAfOvDMAsTxb+fM77WIaPIverDqcu5S84f+rvFyr8rxqoB686/4cPVnj9ztLHw29mJqaPAhH8Lz/db86qga/PGhnYD1WST28YgWru1AdRTz/db899PIPPBzBE720ie47ujymPbh/Kb0scLs8V1Q7PGIFqzwVMR48xp+UOhNGYTxfwW67CaDvvOeEI7tgc228uQNoPXrLBztd2TI9HRqTvLuVJbytoPm8YVMsOvi6irzweJY7/WpBvI5NKL040ym95ccmPAfj8rxJCZG9bsGYvJkpVzszp7G8wOxcu6/ZN7xXrTo7Q90YvGTtZjz/SgA8RWxVPL/hXjynl8O8ZzGjvHK0Uj0dRVI954QjvaqKfTxmUeS8Abf6O0RhV7tr+R098rnRPAju8DtoiiK95SCmvGV0pjwQMOW9wJPdPPutxDxYivi8NLKvPI3pKj3UDYE9Fg5cvQsyrTz+HEC9uuMmPMEaHbzJ4E8778YXvVDERb2cFBS9tsIsPLU7bT3+R/+8b55WPLhRaTzsgls9Nb2tuhNG4btlzSW9Y7cpvO1iGr0lh0a8u8BkvadJQj24f6k9J51CvbAPdbwCEHq8CicvvIKROr0ESbg7GMvYPE6OCLxS2sG7/WrBPOzbWj3uP1i9TVXKPPJg0rtp7h87TSqLPCmowLxrfdy8XbbwPG06WT33jEo9uxlkvcQN17tAmVy8h72yPEdMFLz4Ewo7BPs2va35eLynScI8WpV2PENW2bwQBSa9lSufu32+wTwl4MU8vohfvRyT07ylCIe8dHHPPPg+ST0Ooag8EsIiO9F7w7ylM0Y7dfgOPADaPLwX7hq7iG8xPDW9Lb1Q8oU98twTPYDUvTomwIQ8akcfvUhXkj3mK6Q8syXxvAMb+DwfMI87bsGYPGUbJ71GHtS8XbbwvFQ+P70f14+7Uq+CPSXgxbvHfFK9icgwPQsEbbwm60O9EpRiPDjTKb3uFJm7p/BCPazDuzxh+iy8Xj2wvBqrl71a7nU9guq5PYNDOb1X2Pk8raD5u+bSpLsMD2u7C9ktPVS6gDzyjhI9vl2gPNO0AT0/vJ68XQTyvMMCWbubYhU9rzK3vLhRaToSlOK6qYIAvQAovrsa1la8CEdwPKOkCT1jEKm8Y7epvOv+HLsoJII704ZBPXbVTDubjVQ8aRnfOvspBr2imYs8MDi2vPFVVDxSrwK9hac2PYverLyxGnO9nqNQvfVLD71UEP+8tDDvurN+8Lzkbqc6tsKsu5WvXTtDKxo72b03PdDshryvXfY81JE/vLYbLL2Fp7Y7JbUGPEQ2GLyagla7fAxDPaVhhrxu7Ne7wzAZPOxXHDx5nUe9s35wPHcOizx1fM26FTGePAsEbbzzQBE9zCQMPW6TWDygucy8zPZLPM2oSjzfmy48EF4lvUttDj3NL4q8WIp4PRoEFzxKFA89uKpou9H3BDvK6009a33cPLq15rzv8VY9AQX8O1gxebzjCqo7EeJjPaA1DrxoZ2C65tIkvS0iOjxln2W8o0sKPMPXGb3Ak908cxhQvR8wDzzN1gq8DnNovMZGFbwUJiA9moJWPBl9VzkVA148TrlHO/nFCL1f7y68xe2VPIROtzvCJRu88YMUvaUzRj1qR5+7e6jFPGyrHL3/SgC9GMtYPJcT27yqMX688YOUO32+QT18iAS9cdeUPFbN+zvlx6a83d6xOzQLL7sZJNi8mSnXOuqan7uqin09CievvPw0hLyuq/c866Udu4T1t7wBXnu7zQFKvE5gyDxhUyw8qzx8vIrTLr0Kq+26TgdJPWmVoDzOiIk8aDwhPVug9Lq6iie9iSEwvOKxqjwMiyy7E59gPepMnjth+iw9ntGQOyDijbw76SW9i96sO7qKJ7ybYhU8R/6Su+GmLLzsgtu7inovPRG3pLwZUpi7YzvoucrAjjwOSKm8uuOmvLbt67wKUu68XCc0vbd0Kz0LXWy8lHmgPAAoPjxRpAS99oHMvOlBoDprUh09teLtOxoEl7z0mRA89tpLvVQQ/zyjdkk9ZZ/lvHLikrw76SW82LI5vXyIBLzVnL06NyGrPPXPzTta7nW8FTEePSVcB73FGFU9SFcSPbzL4rtXrbo84lirvcd8Urw9/yG9+63EvPdhCz2rPPw8PPQjvbXibbuo+0C8oWtLPWVG5juL3qw71Zw9PMUY1Tk3yKu8WWq3vLnYKL25A+i8zH2LvMW/1bxDr1g8Cqvtu3pPRr0FrbU8vVKiO0LSGj1b+fM7Why2ux1FUjwhv0s89lYNPUbFVLzJ4M88t/hpvdpvNj0EzfY7gC29u0HyW7yv2Tc8dSPOvNhZurzrpR28jUIqPM0vijxyDdK8iBYyvZ0fkrxalXa9JeBFPO/GF71dBHK8X8FuPKnY/jpQmQY9S5jNPGBz7TrpQaA87/FWvUHyWzwCEPq78HiWOhfuGr0ltYY9I/iJPamCgLwLBO28jZupu38ivzuIbzG8Cfnuu0dMlLypKQG7BzxyvR5QULwCEHo8k8ehPUXoFjzPvka9MDi2vPsphjwjfMi854QjvcW/VbzO4Yg7Li04vL/h3jsaL9a5iG8xuybrwzz3YYu8Gw8VvVGkBD1UugA99MRPuCjLArzvxhc8XICzPFyrcr0gDU296h7eu8jV0TxNKos8lSufuqT9CD1oDmE8sqGyu2PiaLz6osY5YjBqPBAFJrwIlfG8PlihOBE74zzzQJG8r112vJPHobyrPPw7YawrPb5doLqtzrk7qHcCPVIoQzz5l0i81UM+vFd/eryaVxc9xA3XO/6YgbweJZG7W840PF0Ecj19ZUI8x1GTOtb1vDyDnLg8yxkOvOywGz0kqgg8fTqDvKlUQL3Bnlu992ELvZPHobybCZa82LK5vf2NgzwnnUK8YMzsPKOkiTxDr9g6la/duz3/IbusR/q8lmFcvFbN+zztCRu95nklPVKBwjwEJnY6V9j5PPK50bz6okY7R6UTPPnFiDwCafk8N8grO/gTCr1iiWm8AhB6vHHXlLyV3Z08vtZgPMDsXDsck9O7mdBXvRLCojzkbqe8XxpuvDSyLzu0MO87cxhQvd3eMbxtDxo9JKqIvB8CT72zrDC7s37wPHvWhbuXQZs8UlYDu7ef6rzsV5y8IkYLvUo/Tjz+R/88PrGgujSyrzxsBJy8P7yeO7f46byfKpA8cFDVPLygIzsdGpO77LCbvLSJ7rtgzOy7sA91O0hXkrwhO408XKvyvMUYVT2mPsQ8d+DKu9lkuLy+iF89xZSWPJFjpDwIlfE8bC9bPBE7Y7z/+f08W6B0PAc8crhmquO7RvOUPDybJLwlXAe9cuKSvMPXGbxK5s48sZY0O+4UmT1/Ij+8oNyOvPIH07tNKos8yTnPO2RpKDwRO+O7vl2gvKSvB7xGmpW7nD9TPZpXFzyXQRs9InHKurhR6bwb4VS8iiwuO3pPxrxeD3A8CfluO//OPr0MaOq8r112vAwP6zynHgM9T+cHPJuNVLzLRE07EmkjvWHX6rzBGh285G4nPe6Y17sCafm8//n9PJkpVzv9P4K7IWbMPCtlvTxHKVK8JNXHO/uCBblAFZ48xyPTvGaqY7wXlRs9EDDlPHcOizyNQiq9W3W1O7iq6LxwqdQ69MRPvSJGC7n3CIy8HOxSvSjLAryU0p87QJncvEoUjzsi7Qu9U4xAOwn5brzfm668Wu71uu002rw/Y588o6SJPFfY+Tyfg4+8u5WlPMDBnTzVnD08ljadu3sBxbzfm668n4OPO9VDvrz0mZC8kFimPNiyOT134Mo8vquhvDA4Njyjz0i7zVpJu1rudbwmksQ794xKuhN0ITz/zj68Vvu7unBQ1bv8NAS97FecOyxwOzs1ZC68AIG9PKLyCryvtvU8ntEQPBkkWD2xwfO7QfLbOhqIVTykVog7lSufvKOkiTwpqEA9/RFCvKxHejx3tYu74woqPMS0VzoMtuu8ViZ7PL8PH72+L2C81JE/vN3eMTwoywK9z5OHOx4lkTwGBrW8c5QRu4khMDyvBPc8nR8SvdlkuLw0si+9S8aNvCkBwLsXwFo7Od4nPbo8pryp2P68GfkYPKpfvjrsV5w6zuEIvbHB8zxnMSM9C9mtu1nj97zjYym8XFJzPAiVcTyNm6m7X5YvPJ8qED1l+OS8WTx3vGKJ6bt+F0G9jk2oPAR0dzwIR/A8umdlvNLUwjzI1dE7yuvNvBdnW7zdhTI9xkaVPCVcB70Mtus7G7aVPDchK7xuwRi8oDWOu/SZkLxOuUe8c5QRPLBo9Dz/+f07zS+KvNBFBr1n2CO8TKNLO4ZZNbym5US5HsyRvGi1YTwxnDO71vW8PM3WCr3E4he816e7O7QFML2asBa8jZspPSVcBzvjvCi9ZGmoPHV8zbyyobK830KvOgw9q7xzZtG7R6WTPMpnjzxj4mg8mrAWPS+GN7xoZ2C8tsKsOVMIAj1fli89Zc0lO00qCzz+R/87XKvyvLxy4zy52Cg9YjBqvW9F1zybjVS8mwmWvLvA5DymugU9DOQrPJWvXbvT38C8TrnHvLbt67sgiQ49e32GPPTETzv7goW7cKnUOoOcuLpG85S8CoCuO7ef6rkaqxe90tTCPJ8qkDvuuxk8FFFfPK9ddrtAbh08roC4PAnOrztV8D08jemquwR09ziL3iy7xkaVumVG5rygNQ69CfnuPGBzbTyE9Tc9Z9ijPK8yNzxgoa084woqu1F2RLwN76m7hrI0vf7xgLwaXRY6JmeFO68ytzrrpR29XbZwPYI4uzvkFai8qHcCPRCJ5DxKFI+7dHHPPE65xzxvnta8BPs2vWaq4zwrvjy8tDDvvEq7D7076SU9q+N8PAsyLTxb+XM9xZQWPP7ufzxsXZu6BEk4vGXNJbwBXvu8xA3XO8lcEbuuJzk8GEeavGnun7sMPSs9ITsNu1yr8roj+Ik8To6IvKjQgbwIwzG8wqlZvDfIK7xln2W8B+Pyu1HPw7sBjDs9Ba01PGSU57w/Yx867FecPFdUu7w2b6w7X5avvA8l57ypKQE9oGBNPeyC27vGytM828i1PP9KAD2/4V68eZ1HvDHqtDvR94Q6UwgCPLMlcbz+w0C8HwJPu/I1k7yZ/pe8aLXhPHYDDT28oKO8p2wEvdVDvrxh+qy8WDF5vJBYpjpaR3U8vgQhPNItwrsJoG88UaQEu3e1C7yagtY6HOzSOw9+5ryYTBk9q+N8POMKqrwoywI9DLZrPCN8SDxYivi8b3MXPf/OvruvBHc8M6exvA3vKbxz7RA8Fdieu4rTrrwFVDa8Vvu7PF0Ecjs6N6e8BzzyPP/Ovrv2rww9t59qvEoUDz3HUZO7UJkGPRigmbz/+X28qjH+u3jACbxlzaW7DA9rvFLawbwLBO2547yoO1t1NTr1pI68Vs37PAI+Ojx8s8O8xnHUvPg+yTwLBO26ybUQPfUoTTw76SU8i96sPKWMRbwUqt46pj7EPGX4ZL3ILtG8AV77vM0BSjzKZ488CByxvIWnNjyIFrI83CwzPN2FsjzHUZO8rzK3O+iPIbyGCzQ98NGVuxpdlrxhrKs8hQC2vFWXvjsCaXm8oRJMPHyIBLz+HMA8W/nzvHkZCb0pqMC87m0YPCu+vDsM5Ks8VnR8vG0Pmrt0yk48y3KNvKcegzwGMXS9xZQWPDYWrTxxAtQ7IWZMPU4Hybw89CO8/eaCPPMSUTxuk9i8WAY6vGfYozsQMGW8Li24vI+mJzxKFI88HwJPPFru9btRz8O6L9+2u29F1zwC5bq7RGHXvMtyjbr5bIm7V626uxsPlTv1KE29UB3FPMwkDDupggC8SQkRvH4XQT1cJ7Q8nvzPvKsRvTu9+SI8JbUGuiP4iTx460i99JkQPNF7Qz26Dma8u+4kvHO/0LyzfvA8EIlkPUPdmLpmUWS8uxnku8f4E72ruL27BzxyvKeXwz1plSC8gpG6vEQ2mLvtYho91Zy9vLvA5DtnXGK7sZY0uyu+PLwXlZu8GquXvE2uSb0ezBG8wn6au470KD1Abh28YMzsvPQdT7xKP867Xg/wO81aSb0IarK7SY1PO5EKJTsMi6y8cH4VvcXtlbwdGhM8xTsXPQvZLbxgzOw7Pf8hPRsPlbzDMJm8ZGmoPM1aSb0HEbO8PPQjvX5wwDwQXiW9wlDaO7SJ7jxFE9a8FTEePG5omTvPkwc8vtZgux9bzrmwD3W8U2EBPAVUNj0hlIw7comTPAEF/DvKwI68YKGtPJ78Tz1boHQ9sOS1vHiSSTlVG307HsyRPHEwFDxQmQY8CaBvvB0aE70PfuY8+neHvHOUET3ssBu7+tCGPJl3WDx4wAk9d1yMPOqanzwGBjW8ZialPB7MEby1O+07J0RDu4yQq7xpGV88ZXQmPc3WCruRCqU8Xbbwu+0JG7kXGVq8SY1PvKblxDv/oH68r7Z1OynWgDklh0a8E/hfPBCJZL31/Y08sD21vA9+Zjy6DmY82WQ4PAJp+TxHTJQ8JKoIvUBunbwgDc26BzxyvVUb/bz+w8A8Wu51u8guUbyHZLM8Iu0LvJqCVj3nhKO96kwevVDyBb3UDYG79zNLO7KhMj1IgtE83NOzO0f+krw89CM9z5OHuz+OXj2TxyE8wOzcPP91v7zUZgA8DyVnvILqOTzn3aI8j/+mO8xPyzt1UQ48+R4IvQnOrzt1I067QtKau9vINb1+7AE8sA/1uy7UOLzpQSC8dqoNPSnWgDsJoO+8ANo8vfDRlbwefpC89wgMPI1CKrrYsrm78mBSvFFLBb1Pa0a8s1MxPHbVzLw+WCG9kbyjvNt6tLwfMA+8HwLPvGO3qTyyobK8DcFpPInIsLwXGdq7nBSUPGdc4ryTx6G8T+eHPBxolDvIqhK8rqv3u1fY+Tz3M0s9qNCBO/GDlL2N6Sq9XKtyPFMIgrw0Cy+7Y7epPLJzcrz/+X28la/du8MC2bwTn+C5YSXsvDneJzz/SoC8H9ePvHMY0Lx0nw+9lSsfvS3Jujz/SgC94rEqvQwP67zd3rE83NOzPKvj/DyYmpo8h2SzvF8abjye0ZC8vSRivCKfijs/vJ48NAuvvFIoQzzFGFU9dtVMPa2g+TtpGd88Uv2DO3kZiTwA2rw79f2Nu1ugdDx0nw+8di7MvIrTrjz08g+8j6anvGH6LLxQ8oW8LBc8Pf0/Ajxl+OQ8SQkRPYrTrrzyNRM8GquXu9ItQjz1Sw87C9mtuxXYnrwDl7m87Y1ZO2ChrbyhQIy4EsIiPWpHHz0inwo7teJtPJ0fEroHPPK7fp4APV/B7rwwODa8L4Y3OiaSxLsBBfw7RI8XvP5H/zxVlz68n1VPvEBuHbwTzSA8fOEDvV49sDs2b6y8mf6XPMVm1jvjvCg8ETvjPEQ2GLxK5s47Q92YuxOfYLyod4K8EDDlPHAlFj1zGFC8pWGGPE65R7wBMzy8nJjSvLoO5rwwkbU7Eu3hvLOsMDyyobI6YHNtPKs8fLzXp7s6AV57PV49MLsVMR68+4KFPIkhMLxeaG87mXdYulyAMzzQRQY9ljadu3YDDby7GWS7phOFPEJ5mzq6tea6Eu1hPJjzmTz+R388di5MvJn+F7wi7Qs8K768PFnj9zu5MSi8Gl2WvJfomzxHd1O8vw8fvONjqbxuaBk980ARPSNRiTwLMi272Fk6vDGcs7z60Ia8vX1hOzvppbuKLK48jZspvZkpV7pWJns7G7YVPdPfwLyruL08FFHfu7ZprbwT+N84+1TFPGpHn7y9JOI8xe2Vu08SR7zs29o8/RFCPCbAhDzfQi89OpCmvL194boeJZE8kQqlvES6VjrzEtE7eGeKu2kZX71rfdw8D6wmu6Y+xLzJXJE8DnPovJrbVbvkFai8KX0Bvfr7RbuXbNq8Gw+VPRCJ5LyA1D28uQPoPLygo7xENpi8/RHCvEOv2DwRtyS9o0uKPNshNbvmeSU8IyPJvCedQjy7GWQ8Wkf1vGKJ6bztYho8vHLju5cT2zzKZw+88jWTvFb7uznYCzm8" + }, + { + "object": "embedding", + "index": 1, + "embedding": "eyfbu150UDkC6hQ9ip9oPG7jWDw3AOm8DQlcvFiY5Lt3Z6W8BLPPOV0uOz3FlQk8h5AYvH6Aobv0z/E8nOQRvHI8H7rQA+s8F6X9vPplyDzuZ1u8T2cTvAUeoDt0v0Q9/xx5vOhqlT1EgXu8zfQavTK0CDxRxX08v3MIPAY29bzIpFm8bGAzvQkkazxCciu8mjyxvIK0rDx6mzC7Eqg3O8H2rTz9vo482RNiPUYRB7xaQMU80h8hu8kPqrtyPB+8dvxUvfplSD21bJY8oQ8YPZbCEDvxegw9bTJzvYNlEj0h2q+9mw5xPQ5P8TyWwpA7rmvvO2Go27xw2tO6luNqO2pEfTztTwa7KnbRvAbw37vkEU89uKAhPGfvF7u6I8c8DPGGvB1gjzxU2K48+oqDPLCo/zsskoc8PUclvXCUvjzOpQC9qxaKO1iY5LyT9XS9ZNzmvI74Lr03azk93CYTvFJVCTzd+FK8lwgmvcMzPr00q4O9k46FvEx5HbyIqO083xSJvC7PFzy/lOK7HPW+PF2ikDxeAHu9QnIrvSz59rl/UmG8ZNzmu2b4nD3V31Y5aXK9O/2+jrxljUw8y9jkPGuvTTxX5/48u44XPXFFpDwAiEm8lcuVvX6h+zwe7Lm8SUUSPHmkNTu9Eb08cP8OvYgcw7xU2C49Wm4FPeV8H72AA8c7eH/6vBI0Yj3L2GQ8/0G0PHg5ZTvHjAS9fNhAPcE8wzws2By6RWAhvWTcZjz+1uM8H1eKvHdnJT0TWR29KcVrPdu7wrvMQzW9VhW/Ozo09LvFtuM8OlmvPO5GAT3eHY68zTqwvIhiWLs1w1i9sGJqPaurOb0s2Jy8Z++XOwAU9Lggb988vnyNvVfGpLypKBS8IouVO60NBb26r/G6w+0ovbVslrz+kE68MQOjOxdf6DvoRdo8Z4RHPCvhIT3e7009P4Q1PQ0JXDyD8Ty8/ZnTuhu4Lj3X1lG9sVnlvMxDNb3wySY9cUWkPNZKJ73qyP+8rS7fPNhBojwpxes8kt0fPM7rlbwYEE68zoBFvdrExzsMzEu9BflkvF0uu7zNFfW8UyfJPPSJ3LrEBf68+6JYvef/xDpAe7C8f5h2vPqKA7xUTAS9eDllPVK8eL0+GeW7654gPQuGNr3/+x69YajbPAehRTyc5BE8pfQIPMGwGL2QoA87iGJYPYXoN7s4sc69f1JhPdYEkjxgkIa6uxpCvHtMljtYvR88uCzMPBeEo7wm1/U8GBDOvBkHybwyG3i7aeaSvQzMyzy3e2a9xZUJvVSSmTu7SII8x4yEPKAYHTxUTIQ8lcsVO5x5QT3VDRe963llO4K0rLqI1i07DX0xvQv6CznrniA9nL9WPTvl2Tw6WS+8NcPYvEL+VbzZfrK9NDcuO4wBNL0jXVW980PHvNZKJz1Oti09StG8vIZTiDwu8PE8zP0fO9340juv1j890vFgvMFqAz2kHui7PNxUPQehxTzjGlQ9vcunPL+U4jyfrUw8R+NGPHQF2jtSdmO8mYtLvF50ULyT1Bo9ONaJPC1kx7woznC83xQJvUdv8byEXA29keaku6Qe6Ly+fA29kKAPOxLuzLxjxJG9JnCGur58jTws2Jy8CkmmO3pVm7uwqH87Eu7Mu/SJXL0IUis9MFI9vGnmEr1Oti09Z+8XvH1DkbwcaZS8NDcuvT0BkLyPNT89Haakuza607wv5+w81KLGO80VdT3MiUq8J4hbPHHRzrwr4aG8PSJqvJOOBT3t2zC8eBgLvXchkLymOp66y9jkPDdG/jw2ulO983GHPDvl2Tt+Ooy9NwDpOzZ0Pr3xegw7bhGZvEpd57s5YjS9Gk1evIbfMjxBwcW8NnQ+PMlVPzxR6ji9M8zdPImHk7wQsby8u0gCPXtMFr22YxE9Wm4FPaXPzbygGJ093bK9OuYtBTxyXfk8iYeTvNH65byk/Q29QO+FvKbGyLxCcqs9nL/WvPtcQ72XTjs8kt2fuhaNKDxqRH08KX9WPbmXnDtXDDo96GoVPVw3QL0eeGS8ayOjvAIL7zywQZC9at0NvUMjET1Q8707eTDgvIio7Tv60Jg87kYBOw50LLx7BgE96qclPUXsSz0nQkY5aDUtvQF/RD1bZQC73fjSPHgYCzyPNT+9q315vbMvhjsvodc8tEdbPGcQ8jz8U768cYs5PIwBtL38x5M9PtPPvIex8jzfFIk9vsIivLsaQj2/uZ072y8YvSV5C7uoA9k8JA67PO5nWzvS8eC8av7nuxSWrbybpwE9f5h2vG3sXTmoA1k9sjiLvTBSPbxc8Sq9UpuePB+dHz2/cwg9BWS1vCrqJr2M3Pg86LAqPS/GEj3oRdq8GiyEvACISbuiJ+28FFAYuzBSvTzwDzy8K5uMvE5wmDpd6CW6dkJqPGlyvTwF2Iq9f1JhPSHarzwDdr88JXkLu4ADxzx5pDW7zqUAvdAoJj24wXs8doj/PH46jD2/2vc893fSuyxtTL0YnPg7IWbaPOiwqrxLDk27ZxDyPBpymbwW0z08M/odPTufRL1AVvU849Q+vBGDfD3JDyq6Z6kCPL9OzTz0rpe8FtM9vaDqXLx+W2Y7jHWJPGXT4TwJ3lW9M4bIPPCDkTwoZwE9XH1VOmksqLxLPI08cNrTvCyz4bz+Srm8kiO1vDP6nbvIpNk8MrSIvPe95zoTWR29SYsnPYC9MT2F6De93qm4PCbX9bqqhv47yky6PENE67x/DEw8JdYAvUdvcbywh6W8//ueO8fSmTyjTCi9yky6O/qr3TzvGEE8wqcTPeDmSDyuJVo8ip/ou1HqOLxOtq28y5LPuxk1Cb0Ddr+7c+2EvKQeaL1SVQk8XS47PGTcZjwdpiQ8uFqMO0QaDD1XxqS8mLmLuuSFJDz1xmy8PvgKvJAHf7yC+kE8VapuvetYC7tHCAI8oidtPOiwqjyoSW68xCo5vfzobTzz2HY88/0xPNkT4rty9om8RexLu9SiRrsVaG081gSSO5IjtTsOLpc72sTHPGCQBj0QJRI9BCclPI1sBDzCyO07QHuwvOYthTz4tGK5QHuwvWfvFz2CQNc8PviKPO8YwTuQoA89fjoMPBnBs7zGZ8m8uiPHvMdeRLx+gKE8keaku0wziDzZWfe8I4KQPJ0qpzs4sc47dyEQPEQaDDzVmcE8//uePJcIJjztTwa9ogaTOftcwztU2K48opvCuyz5drzqM1C7iYcTvfDJJjxXxiQ9o0wovO1PBrwqvGa7dSoVPbI4izvnuS88zzGrPH3POzzHXkQ9PSJqOXCUPryW4+o8ELE8PNZKp7z+Sjm8foChPPIGtzyTaUq8JA47vBiceDw3a7m6jWyEOmksKDwH59q5GMo4veALBL0SqDe7IaxvvBD3Ubxn7xc9+dkdPSBOBTxHCAI8mYvLOydCxjw5HB88zTqwvJXs77w9AZA9CxvmvIeQGL2rffm8JXkLPKqGfjyoSe464d1DPPd3UrpO/EK8qxYKvUuCojwhZlq8EPfRPKaAs7xKF9K85i0FvEYRhzyPNT88m6cBvdSiRjxnqQI9uOY2vcBFSLx4OeW7BxUbPCz59rt+W2Y7SWZsPGzUCLzE5KM7sIclvIdr3buoSW47AK0EPImHE7wgToU8IdovO7FZ5bxbzO+8uMF7PGayB7z6ioO8zzErPEcIgrxSm568FJYtvNf7jDyrffm8KaQRPcoGpTwleQu8EWKiPHPthLz44qI8pEOjvWh7QjzpPNU8lcuVPHCUPr3n/8Q8bNQIu0WmNr1Erzs95VfkPCeIW7vT0Aa7656gudH65bxw/w49ZrKHPHsn27sIUiu8mEU2vdUNF7wBf8Q809CGPFtlgDo1fcO85i2FPEcIAjwL+os653OavOu1AL2EN9K8H52fPKzoybuMdYk8T2cTO8lVPzyK5X07iNYtvD74ijzT0IY8RIF7vLLENbyZi8s8KwJ8vAne1TvGZ8k71gSSumJZwTybp4G8656gPG8IFL27SAI9arjSvKVbeDxljcy83fjSuxu4Lr2DZRK9G0TZvLFZ5bxR6ji8NPEYPbI4izyAvTE9riVaPCCUGrw0Ny48f1LhuzIb+DolBTY8UH9ou/4EpLyAvTG9CFIrvCBOBTlkIvy8WJhkvHIXZLkf47Q8GQfJvBpNXr1pcr07c8jJO2nmkrxOcJi8sy8GuzjWibu2Pta8WQO1PFPhs7z7XEO8pEMjvb9OzTz4bs08EWKiu0YyYbzeHQ695D+PPKVbeDzvGEG9B6HFO0uCojws+Xa7JQW2OpRgRbxjCqc8Sw7NPDTxmLwjXVW8sRNQvFPhszzM/Z88rVMavZPUGj06WS+8JpHgO3etursdx369uZccvKplJDws+Xa8fzGHPB1gj7yqZaQ887ecPBNZHbzoi2+7NwDpPMxDtbzfWh49H+O0PO+kaztI2kE8/xz5PImHE73fNWO8T60ovIPxPDvR2Yu8XH3VvMcYr7wfnR+9fUORPIdr3Tyn6wO9nkL8vM2uhTzGIbS66u26vE2/MrxFYKE8iwo5vLSNcLy+wiK9GTUJPK10dLzrniC8qkBpvPxTPrwzQLO8illTvFi9H7yMATS7ayOjO14Ae7z19Cy87dswPKbGyDzujJa93EdtPdsB2LYT5Ue9RhEHPKurubxm+By9+mVIvIy7HrxZj987yOpuvUdv8TvgCwS8TDMIO9xsqLsL+gs8BWS1PFRMBD1yXXm86GoVvK+QqjxRXg46TZHyu2ayhzx7TJa8uKAhPLyFkjsV3MI7niGiPGNQvDxgkIa887ccPUmLJ7yZsIa8KDnBvHgYi7yMR0m82ukCvRuK7junUvO8aeYSPXtt8LqXCKa84kgUPd5jIzxlRze93xQJPNNcMT2v1j889GiCPKRkfbxz7YQ8b06pO8cYL7xg9/U8yQ+qPGlyvbzfNWO8vZ3nPBGD/DtB5gC7yKRZPPTPcbz6q928bleuPI74rrzVDRe9CQORvMmb1Dzv0qs8DBLhu4dr3bta1fQ8aeYSvRD3UTugpMe8CxvmPP9BNDzHjAQ742DpOzXD2Dz4bk28c1T0Onxka7zEBf48uiNHvGayBz1pcj29NcPYvDnu3jz5kwg9WkBFvL58jTx/mHY8wTzDPDZ0Pru/uZ08PQGQPOFRmby4oKE8JktLPIx1iTsppBG9dyGQvHfzT7wzhki44KAzPSOCkDzv0iu8lGBFO2VHNzyKxKM72EEiPYtQzryT9fQ8UDnTPEx5nTzuZ9s8QO8FvG8IlDx7J9s6MUk4O9k4nbx7TBa7G7iuvCzYHDocr6k8/7UJPY2ymTwVIlg8KjC8OvSuFz2iJ+28cCBpvE0qAzw41ok7sgrLvPjiojyG37K6lwimvKcxGTwRHI28y5LPO/mTiDx82MC5VJIZPWkH7TwPusG8YhOsvH1DkbzUx4E8TQXIvO+ka7zKwI+8w+2oPNLxYLzxegy9zEM1PDo0dDxIINc8FdxCO46E2TwPRmw9+ooDvMmb1LwBf0S8CQMRvEXsS7zPvdU80qvLPLfvO7wbuK68iBzDO0cpXL2WndU7dXCqvOTLubytLl88LokCvZj/IDw0q4M8G7guvNkTYrq5UQe7vcunvIrEI7xuERm9RexLvAdbsDwLQCE7uVEHPYjWrbuM3Pi8g2WSO3R5L7x4XiC8vKZsu9Sixros+fa8UH/ouxxpFL3wyaa72sRHu2YZ9zuiJ2274o4pOjkcnzyagka7za4FvYrEozwCMCo7cJQ+vfqKAzzJ4em8fNhAPUB7sLylz80833v4vOU2ir1ty4M8UV4OPXQF2jyu30S9EjRivBVo7TwXX2g70ANrvEJyq7wQJRK99jE9O7c10brUxwE9SUUSPS4VLbzBsJg7FHHyPMz9n7latJo8bleuvBpN3jsF+WS8Ye7wO4nNKL0TWZ08iRM+vOn2v7sB8xm9jY3ePJ/zYbkLG+a7ZvicvGxgM73L2OS761iLPKcxmTrX+ww8J0JGu1MnyTtJZuw7pIm4PJbCED29V1K9PFCqPLBBkLxhYka8hXTiPEB7MDzrniA7h5CYvIR9ZzzARcg7TZHyu4sKOb1in9Y7nL9WO6gD2TxSduO8UaQjPQO81Lxw/w69KwL8O4FJ3D2XTju8SE6XPGDWGz0K1VC8YhMsvObCtDyndy49BCclu68cVbxemYu8sGLqOksOzTzj1L47ISBFvLly4Ttk3Oa8RhGHNwzxBj0v5+y7ogaTPA+6QbxiE6w8ubj2PDixzrstZEe9jbKZPPd30rwqMDw8TQXIPFurlTxx0c68jLsePfSJ3LuXTru8yeHpu6Ewcjx5D4a8BvBfvN8Uibs9R6W8lsIQvaEw8rvVUyw8SJQsPebCNDwu8PE8GMo4OxAlkjwJmMA8KaQRvdYlbDwNNxy9ouHXPDffDrxwZv46AK0EPJqCRrpWz6k8/0E0POAs3rxmsoe7zTqwO5mLyzyP7ym7wTzDvFB/aLx5D4a7doj/O67fxDtsO/g7uq9xvMWViTtC/tU7PhnlvIEogjxxRSQ9SJSsPIJA1zyBKAI9ockCPYC9MbxBTXC83xSJvPFVUb1n75c8uiNHOxdf6Drt27A8/FM+vJOvXz3a6QI8UaQjuvqKgzyOhNm831oevF+xYLxjCic8sn6gPDdrOTs3Rv66cP+Ou5785rycBew8J0JGPJOOBbw9Imq8q335O3MOX7xemQs8PtNPPE1L3Tx5dnU4A+EPPLrdsTzfFIm7LJIHPB4yz7zbAdi8FWjtu1h3Cj0oznA8kv55PKgDWbxIINc8xdsePa8cVbzmlHQ8IJSavAgMlrx4XiA8z3dAu2PEET3xm+a75//EvK2Zr7xbqxU8zP2fvOSFJD1xRSS7k44FvPzHkzz5+ne8+tAYvd5jIz1GMuE8yxSAO3KCNDyRuOS8wzO+vObCNDwzQLO7isQjva1TGrz6ioM79GgCPF66Zbx1KpW8qW6pu4RcDTzcJhO9SJQsO5G45LsAiMm8lRErvJqCxjzQbju7w3nTuTclpDywqP88ysCPvAF/xLxfa0u88cChPBjKODyaPLE8k69fvGFiRrvuRgG9ATmvvJEsOr21+EC9KX/WOrmXnDwDAuo8yky6PI1sBDvztxy8PviKPKInbbzbdS276mGQO2Kf1rwn/DC8ZrIHPBRxcj0z+h264d1DPdG0ULxvTqm5bDt4vToTmjuGJcg7tmMRO9YEEr3oJAC9THmdPKn607vcJhM8Zj6yvHR5r7ywYmq83fjSO5mLyzshIEU8EWKiuu9eVjw75dk7fzGHvNl+sjwJJOs8YllBPAtheztz7QQ92lDyvDEDozzEKrk7KnZRvG8pbjsdYI+7yky6OfWAVzzjYGk7NX3DOzrNhDyeIaI8joTZvFcMOryYRba8G7iuu893QDw9RyW7za6FvDUJ7rva6YK9D7rBPD1o/zxCLJa65TaKvHsGAT2g6ly8+tCYu+wqy7xeAHu8vZ1nPBv+QzwfVwo8CMYAvM+91TzKTDq8Ueo4u2uvzTsBf8Q8p+uDvKofDz12tj+8wP+yOlkDtTwYyji6ZdPhPGv14rwqdtE8YPf1vLIKy7yFLs28ouFXvO1PBj15pDU83xQJPdfWUTz8x5O64kgUPBQKA72eIaK6A3a/OyzYnLoYnPg4XMNqPdxsqLsKSaY7pfSIvBoshLupKJS8G0TZOu/SqzzFcE47cvaJPA19Mb14dQC8sVllvJmwhjycBey8cvaJOmSWUbvRtFC8WtX0O2r+57twIGm8yeFpvFuG2rzCyO08PUelPK5rbzouFS29uCxMPQAUdDqtma88wqeTu5gge7zH8/O7l067PJdOO7uKxCO8/xx5vKt9+TztTwa8OhOaO+Q/Dzw33w49CZhAvSubjDydttG8IdovPIADR7stHrI7ATmvvOAs3rzL2OQ69K4XvNccZ7zlV2S8c+0EPfNDxzydKqc6LLPhO8YhtDyJhxM9H1eKOaNMKLtOcBg9HPU+PTsrbzvT0Ia8BG26PB2mpDp7TJa8wP8yPVvM77t0ea86eTBgvFurFT1C/tW7CkkmvKOSPT2aPDG9lGDFPAhSq7u5UYc8l5TQPFh3ijz9vg68lGBFO4/vKTxViZS7eQ8GPTNAs7xmsoe8o0yoPJfaZbwlvyA8IazvO0XsS717TJY8flvmOgHFWbyWnVW8mdFgvJbCkDynDF68" + } + ], + "model": "text-embedding-3-small" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = new EmbeddingsClient(new("http://somewhere"), new AzureKeyCredential("key"), new() + { + Transport = new HttpClientTransport(httpClient), + }).AsIEmbeddingGenerator("text-embedding-3-large"); + + var response = await generator.GenerateAsync([ + "hello, world!", + "red, white, blue", + ], new EmbeddingGenerationOptions + { + Dimensions = 3072, + RawRepresentationFactory = (e) => new EmbeddingsOptions(input: []) + { + Dimensions = 1536, + Model = "text-embedding-3-small", + EncodingFormat = EmbeddingEncodingFormat.Single, // this will be overwritten, we only support base64. + } + }); + + Assert.NotNull(response); + + foreach (Embedding e in response) + { + Assert.Equal("text-embedding-3-small", e.ModelId); + Assert.NotNull(e.CreatedAt); + Assert.Equal(1536, e.Vector.Length); + Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs index 7ca945eb07b..7ceefe947f3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageEmbeddingGeneratorTests.cs @@ -38,7 +38,7 @@ public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() IEmbeddingGenerator> embeddingGenerator = client.AsIEmbeddingGenerator(model); var metadata = embeddingGenerator.GetService(); - Assert.Equal("az.ai.inference", metadata?.ProviderName); + Assert.Equal("azure.ai.inference", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); Assert.Equal(model, metadata?.DefaultModelId); } @@ -125,4 +125,71 @@ public async Task GenerateAsync_ExpectedRequestResponse() Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); } } + + [Fact] + public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation() + { + DataContent dotnetPng = new(ImageDataUri.GetImageDataUri()); + + const string Input = """ + { + "input":[{"image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcgAAAHICAMAAAD9f4rYAAAAS1BMVEVRK9RRK9T///\u002BolenTyvTp5fpnRdl8YN\u002B\u002Br\u002B708v1cONedh\u002Be\u002Bru5nRtl9YN6HbeKyouzJvfKSeuSzou2\u002Br\u002B9yU9ze1/dcONbe2PcNfWisAAAAAXRSTlP\u002BGuMHfQAAB79JREFUeNrs0QENAAAMw6Ddv\u002Bn7aMACOwomskFkhMgIkREiI0RGiIwQGSEyQmSEyAiRESIjREaIjBAZITJCZITICJERIiNERoiMEBkhMkJkhMgIkREiI0RGiIwQGSEyQmSEyAiRESIjREaIjBAZITJCZITICJERIiNERoiMEBkhMkJkhMgIkREiI0RGiIwQGSEyQmSEyAiRESIjREaIjBAZITJCZITICJERIiNERoiMEBkhMkJkhMgIkREiI0RGiIwQGSEyQmSEyAiRESIjREaIjBAZITJCZITICJERIiNERoiMEBkhMkJkhMgIkREiI0RGiIwQGSEyQmSEyAiRESIjREaIjBAZITJCZITICJERIiNERoiMEBkhMkJkhMgIkREiI0RGiIwQGSEyQmSEyAiRESKfnTvMTRyGoiisF5K2SYZhKKX7X\u002BpEeuov7Ngxorp\u002BOmcH9KssLnISJCCDBGSQgAwSkEECMkhABgnIIAEZJCCDBGSQgAwSkEECMkhABgnIIAEZJCCDBGSQgAwSkEECMkhABgnIIAEZJCCDBGSQgAwSkEECMkhABgnIIAEZJCCDBGSQgAwSkEECMki/DzkNqUZr7H146M0ynYZnmgof4cn\u002B2BPpQA6rFQMymxDk/GalgMwmBDlcrRSQ2ZQgh79WCMhsUpDTYvsBmU0Kcvhn\u002BwGZTQuydLgCmU0MsjAmgcwmBlkYk0BmU4PcH5NAZlOD3D9cgcwmBzlcLB\u002BQ2fQg98YkkNn0IPfGJJDZBCF3xiSQ2RQhvy3XKyDnsboP\u002B\u002Bk6FpoT/wZjodWeSBEyPyZfATnaKxqHh072yiQhj4xJID1JyCN/XCA9TcgDYxJITxRyXqwyID1RyPoxCaSnClk9JoH0NCDH9jEJpKcBeR\u002BaPzeQngbk5do8JoH0NCA/35vHJJCeBuRqY0Ly0yoC0tOAPNm5dUwC6alA2q1xTALpaUBuYsvUNiaB9DQgP8w9Gq59AOnpQNq1aUwC6QlBnueWMQmkJwRpa8uYBNJTgrSx4doHkJ4UZMuYBNKTgkzeVvyy3YD0tCAbxiSQnhZkw5gE0hODtNvRMQmkpwa5zEOtiwekpwZpl4NjEkhPDvLomATS04M8z4fGJJCeHqSth95uBqQnCGnjkTEJpKcIeT8yJoH0FCEPjUkgPUnI5C91d0v2a08sf1p9QJp34JprM2S5dgcgf/qqHpNAeqKQS/W1DyA9Ucj6MQmkpwpZPSaB9GQhz3PdmATSk4W0U90zBEB6upD2XXW4AukJQ9aNSSA9YUi71YxJID1lyGWqGJNAesqQVYcrkJ40pF3LbzcD0tOGXMpjEkhPG9LW4pgE0hOHLP9S9zTkPNW1Wn1APnSeC28344aApw5pp8KYBNKTh7TCmATS04csjEkgPX1Iu\u002B2OSSC9DiCXae8ZAiC9DiDtsjcmgfR6gNwdk0B6XUDujUkgvS4gbc3/ZAak1wekjdkxCaTXCeQ9OyaB9DqBtFPuVdlAer1AZsckkF4vkPaeGZNAet1A2i09JoH0\u002BoHMXvu4A7nVD6RdMmPyDcitjiDTYxJIryfI85xkWIDc6gnS1vS1DyC3uoK0MTkmZyDN\u002BoJMj8kJSLO\u002BINNjcgTSrDPIZUpIfAFp1hlk8nDlaN3qDTL1KiW\u002BtW51B7nMQKbqDtJWIP\u002BzdwerDcNQEEUZWbIqG9XESev8/5d2EQol7wXcZBSwmLv3Zg54oYXkdTxIREE6HRCyFkHa2JDbfEohlHj5xINehsQgSBsXchtK\u002BC2tcHsdEt\u002BCNFEhx7Tj0XICZBakiQk53gvFCTYCJM5EyOv4nzbs6diQowW6wMaAnBIBsuGVEMeG3Hl9NQMSWZAmFmQO\u002Bx7WpUDiJMhbfEh/2hkmCmQtgkQbyOB2gokCiVmQQAvIHNwSTBxIREE2gVyCH0wkyCrIJpBrMLWFxCDIVr/W90JOSZANIMfgdoWJBYksSD6kx\u002BOft/IgcRZkA0h/owoTD3IqgqRD\u002BqteYCJCYhEkHdJdNVWYmJCIguRD2pXKF2xUyFoESYc0MyXXkQqJWZANILH\u002BNYoVfvNw34KnmwenCQ/Kw4vlvUt4n7aKDwms8aZYPjLU2\u002BJDAlte1jxCvbUbpOohQXaSIDtJkJ0kyE4SZCcJspME2UmC/GGPDmQAAAAABvlb36M9hRBHIo5EHIk4EnEk4kjEkYgjEUcijkQciTgScSTiSMSRiCMRRyKORByJOBJxJOJIxJGIIxFHIo5EHIk4EnEk4kjEkYgjEUciYo8OZAAAAAAG\u002BVvf4yuFRE6InBA5IXJC5ITICZETIidEToicEDkhckLkhMgJkRMiJ0ROiJwQOSFyQuSEyAmREyInRE6InBA5IXJC5ITICZETIidEToicEDkhckLkhMgJkRMiJ0ROiJwQOSFyQuSEyAmREyInRE6InBA5IXJC5ITICZETIidEToicEDkhckLkhMgJkRMiJ0ROiJwQWXt0QAMAAIAwyP6p7cFOBRBFIopEFIkoElEkokhEkYgiEUUiikQUiSgSUSSiSESRiCIRRSKKRBSJKBJRJKJIRJGIIhFFIopEFIkoElEkokhEkYgiEUUiikQUiSgSUSSiSESRiCIRRSKKRBSJKBJRJKJIRJGIIhFFIopEFIkoElEkokjEgjh2WnxgwCuWdQAAAABJRU5ErkJggg=="}], + "dimensions":1536, + "encoding_format":"base64", + "model":"embed-v-4-0" + } + """; + + const string Output = """ + { + "id":"9da7c0f0-4b9d-46d9-9323-f10f46977493", + "object":"list", + "data": + [ + { + "index":0, + "object":"embedding", + "embedding":"AADkPAAA6bsAAD68AAAnOwAAYbwAAFa8AABfvQAAqzsAAGy8AABwvAAAyDwAALo9AAC6vAAAID0AAGE8AAA2PAAA4TsAABU9AAC0PAAAqzwAADw8AAAaPQAA2LwAANa8AACoOgAA4DsAAA48AAC9PAAAdz0AAIC8AACGvAAA+7oAAEo7AABMuwAAab0AAFc9AAA2OwAAob0AAPO8AABGOwAAoLwAAKQ6AACHvAAAX70AAJQ8AAA/OwAAtbwAAME7AADMvAAA2DwAABQ9AABZPQAAd7wAAD+9AAAquwAAgTwAABE9AABFPQAALbwAAEk9AAC8ugAABj0AAI27AAAWPQAAMrwAAE88AABnvAAAZbwAAMK8AAAhPQAAPr0AAAg8AABcOgAA/jwAANE8AACvvAAAEbwAALy8AAAIvQAAe7wAAGW8AAAPPQAAFTsAALc5AACrOwAAirwAALa8AACLvAAACLsAABa7AAC+uAAAljwAAMS9AAC0vAAAhz0AAJw7AAAgvQAAxjwAAMK8AACMPAAAdz0AALC8AADIPAAArjsAAGG8AAATvAAAkrsAALs8AAAWPAAAxDwAADK9AAAAvQAAmTwAAIK9AADZPAAAmjwAABG9AAARuwAA/zwAAGO8AAC3PAAAGTwAACC8AAAtPAAAArwAAGG7AAC4PAAA/7sAAKG8AACdOwAA8DwAAJo7AAC8PAAAST0AAAI8AABnvAAAXTwAABc9AACSPAAAMjsAAPc7AABSvAAATLsAAKa8AAB1PAAAA70AAC87AAASvAAA/DwAADC7AABfvAAAYbwAAGW9AADlOwAANzwAAFc8AADEPAAAyrsAAMM8AAATPQAA3DwAABu8AAB+uQAAKj0AADS7AACkOwAAhD0AACK8AABIvQAAaboAALu8AADtOwAAoDwAAI88AACQPAAAmjwAAEy8AAC2OwAAtTwAAE68AACGvQAA0LsAAJM7AAAUvQAA17wAAEg9AABhOwAAVjwAALg8AACHvQAA5DwAACI9AACLPAAA4zsAAOk7AADOPAAA/7wAAPe8AAAGPQAAYTwAAEo9AAA/PAAA47wAAEq8AADgvAAAybsAAPk8AAA7vQAA3zsAAP87AAAUvAAAKjwAAKA8AAATvQAAcrwAAGm7AADlvAAAprwAAJM7AACivAAABr0AAEu7AAAxuwAAjD0AAMu8AAA7vQAATjwAADo8AADfOgAAFboAACA9AAA2OwAAZDwAAOo7AABBvAAAKzsAAJK8AAC7vAAAFL0AAO47AAADvQAARj0AAJS8AADLuwAA5bwAAKa7AAAGPQAA8bwAAKG9AAC/vAAADrwAAKO8AACDOgAAr7wAABM9AAD0uwAAQr0AABs9AAC2PAAAOLwAAM88AADSPAAAqzsAAOm7AABMPAAAGz0AAHU8AAAAvQAAULwAAAa9AADavAAAgzoAAKk8AABVvAAAPboAAHU8AACKvAAAgbwAADI8AAALuwAAIb0AAKi8AABxvAAAUz0AAJk8AAAnvQAA3zwAAMM7AAAVPAAA0bwAAME6AADCuwAAIrwAAMs8AACbPAAArbwAAGG8AAChOwAAEL0AAIQ7AADePAAADr0AADE9AAAbvQAAprwAAK+8AAARvAAAWrwAAL+8AAALPAAAWTwAAJ86AACGOwAAU70AACm8AAAJPQAA+LwAAKC8AABtvAAAtLwAALQ7AACmvAAAAj0AALW8AAA0PQAAhjwAAEa6AAAfPQAAirwAAOa7AABDPAAAqLwAANM8AAAGvQAA4DsAANO7AAAdvAAA7TwAACM7AACfvAAASDsAABs8AACxPAAAVzwAAEy9AAAxPAAAmb0AALw8AAAZvAAAiLwAALY8AAB3vAAA9zwAAJs8AAAkvAAAOz0AAMo7AABLPQAAwbsAAN47AABGuQAAl7oAAG08AACJvAAAZ7oAALw8AACavAAA37wAAKA7AAAgvAAANb0AAGA8AAAhPQAANz0AAMq7AADGvAAAlTwAABI9AABhuwAAkbsAAIY7AADauwAAtDwAABk8AAD7PAAAiDwAAPG8AACwvQAAn70AAFI8AACqugAAn7wAAGA9AAA0vQAAmrwAACo9AACCOwAAoTsAAIE9AABwOwAAAr0AANc8AAAbvAAAjDwAABe9AAAPvQAA07wAACG8AADBOwAAeDwAAAg9AAB0PAAAm7wAAEW6AACaugAADr0AANY8AAD1vAAA5zsAAKK9AAAXOwAAPr0AAAA9AAD3uwAAG7wAABW9AAAOPQAAMrwAAIA7AADdPAAAEb0AAGM8AAAjvQAAUDoAABI9AAD/PAAAHL0AAKM8AACbOQAAlbwAAAO9AACqPAAAAr0AAIy7AACCvAAAZjwAAGO8AAC9PQAA7DsAAJ88AAByOwAAmrsAAD+8AAArvAAA37wAAPo8AAAkvAAAL7sAACO9AAAnvQAA9DwAAJY8AACxPAAAeTwAAFO8AAAFvQAAHzwAADe7AACmPAAAKD0AAHM9AAAgvAAAmrwAALy8AAC/OwAA3LwAAG06AAAfOwAA/7sAALE9AADBPAAAtrsAAKI8AACZuwAAgrwAAES9AADcuwAAsjsAAJE8AABWPAAAK70AAEU8AABEPAAAMbwAAK+7AACcvAAARLsAABK6AAAiPAAAEbwAANG7AAChPAAAzzwAAMs6AAAFPQAA2TsAACG9AAB1PAAAsrwAAC29AABMPAAAzzwAANI8AADfvAAAm7wAAC29AACLuwAAHTwAALq8AAAcuwAA07wAAHm8AACxvAAA7LwAAK06AAA4PQAA7LsAAKC7AAAvuwAAKrwAAC68AABtPAAAtjwAAC+8AAAJvQAATLwAALE7AACCvAAApjwAAKE8AAC4vAAAjDwAACS9AAD3PAAAHz4AACe9AAB7vQAAET0AAII6AAC2OwAAyzwAANY7AAB+PQAAuDwAAME8AADMugAAAjwAANA7AAAgvAAAFT4AAPe7AAAPvQAALrwAAJQ5AAArOwAAFjwAAKe8AAD4uwAAGTwAACQ8AAAJPAAAZTwAAJa8AACgOwAANjsAAJk8AAC7OwAAdzwAAPG7AACfvAAAtjwAAFq8AAAMPQAAMDwAAHu9AAC6vAAAVT0AAKo7AACOPAAAoTsAANc8AAAXPQAAbDwAAKi7AABVOgAA5zwAAHU8AADCvAAAyjwAAAa9AADqOwAAmbwAALq7AAA+vAAAjDwAAB+9AAAqvQAAir0AAFo9AAA+PQAAgrsAANM8AAAhPAAAhbwAAAU8AACavAAAuLwAAKa8AACqPAAAI7wAAHG8AABFPAAAgToAAIy8AAAkuwAAjrwAAA49AACpPAAACz0AABC9AAAbvAAAWjwAAPI7AAAoPAAAJjoAAK26AAAXOwAADzwAAC+9AAC4vAAAIL0AAIk8AABhPAAAPj0AAHI7AAAUvQAALT0AACG8AAByPAAADD0AANk8AAC/vAAA4bwAAGu8AAC1vAAA0jsAALc8AAChvAAAT7wAAMu8AACOvAAA4bwAAHg8AAD1PAAACz0AAB08AAAXPAAAPr0AAIG6AAAFuwAAKTwAAI27AABPPAAAmzsAAOC8AAAbPQAAp7oAAGq8AABdOgAAzDwAANe8AAAdvQAALjoAABU9AAATPQAA0rsAAAc9AAD7PAAATLwAALA6AAAruwAAX7sAABK9AAC7PAAAErwAACG8AAC3OgAAkzwAAMw7AAAEOwAAqjwAAEW7AAAHPQAA6rsAAES8AACCPQAARj0AAGY8AABGPAAAdLwAANE8AAD1vAAAGzsAAEQ6AACuuwAAFb0AAIE8AAA4PAAAlbsAAH68AAACOwAAsjwAAKE8AAAoPAAAhDsAAME8AAD7uwAAkr0AAFq9AAC4OwAAsjwAADA8AACCvAAAbbwAAAs9AACWvAAAEzwAALS8AAAgPQAAd7wAAO42AABWvQAAHLwAAPG6AAAAPAAAFz0AAME7AAAoOwAAULsAANo8AABRuwAAiDwAABw8AADVuwAA+rsAAAo9AAAavAAAMDwAANe8AAD+vAAAibwAAJC8AABfOwAAtTwAAIE8AADmOwAAgLwAAMS8AABwPAAAAb0AALS8AAAqvQAANDwAAOU8AACWvAAAzjwAABG7AACouwAAJr0AAIM7AAAZvQAA0boAAFi8AABPPAAAnzgAAIE8AACbvAAAFb0AABY8AAC+OwAA5DwAAJa6AACkPAAAITwAAGE6AABtPAAAMb0AADg9AAAEPQAAnDwAAJ08AAABPAAAursAAHc8AAAFvAAA5ToAAD28AAAAPAAAazwAADQ7AADqPAAAA70AAFO7AAA6vAAAAj4AAEg6AADhPAAAELwAAFm9AACIvAAAxTwAACQ8AADkOwAAbrwAALq7AACGPAAAIL0AAGE8AADMPAAAOr0AACM9AACMPAAAKrsAAAY8AAAhPAAAKz0AACe9AACOvAAAa7wAACG9AABKuwAASrwAAI+8AAApvQAA+LsAAPe8AADGuwAAgroAADe7AACvuwAATz0AAMQ6AACFvAAAMLwAACg9AAADvQAAtTwAALa8AACuPAAAI70AAJI8AAAauwAAZbwAAA89AADWvAAAqDwAAAm9AAAAPQAAEDwAAOA8AAAxvQAAYzwAAB87AADhOgAAwrsAAOA8AAA3vQAA1jwAAKi8AAB1uwAAGb0AAJo8AABmPAAAPLwAAMI7AAC4PAAAmj0AAFc8AADcOgAAe7wAAH47AABdOwAAlrwAAPO8AAB5PQAAijsAABU8AAAOvQAAkTwAABK8AAC4PAAAZLwAAK68AACRvAAAwzwAAKq8AABWvQAA4DsAAKC8AACUPAAAm7wAAJO8AAAMuQAAwrsAAAk8AABdvQAAkrwAACQ8AAAoNwAApDwAABQ8AAAVPAAAH7wAAFK8AAAGPAAAkrsAAIA8AADGPAAAbrwAALc8AABxPAAApDwAABy8AAAZPQAAk7wAAMW8AABhvAAAPLwAAEI8AAB5PAAAxrwAAFi7AADwvAAAUL0AAAk9AABZOwAAED0AALY8AAB5PAAAmzwAAFM9AAAwPQAAsToAAPA6AADOvAAAMLsAAHO8AADQuAAAqLwAANc7AAA4PAAA3DsAAK48AAAdPAAAH7wAACQ7AAD5OwAAo7sAACY8AACrPAAATzwAAL68AAC9PAAA8DwAABI7AADeOwAAFL0AAAC9AACEOwAAITsAAJI8AADtuwAA8LsAANa8AACvvAAAI70AAAG9AABmOwAAd7wAAIE8AAA6vQAAvzwAAEK9AAD0vAAA/zwAAPU8AACVPAAAET0AAAU7AAAfOwAANroAAKm8AAAUvQAAyLsAAAa9AAAUvAAAErwAAII7AAAFPQAAALsAAC08AAA0uwAAgTwAAIu7AADRvAAADzwAAKA7AABDvAAAirsAALo8AAB3vAAAOLwAACO9AADEPAAA7jwAADg9AAAiPQAAqzcAANA8AAAuPAAAODwAAAW8AACNvAAAIjwAANC8AAAmvQAAoTwAAAc9AACHvAAABjsAAI68AADZPAAAobsAAIi9AADsvAAABrsAAAm8AABkOwAACDwAAIY8AABQvAAAmTwAABE9AAAFvQAABzwAAF08AACoPAAAzjwAAL49AAAfPAAAkbwAALQ6AAByvAAAcD0AAN+6AACTvAAAkDsAAK66AAC0PAAAkzoAAHy8AAAiOwAADDwAAIG9AAAmvAAACrsAADU9AAAjuAAAjbwAAPc8AACNOwAABbwAAMG7AACIvAAAO7wAAL88AAD7vAAAXLwAADw4AAC2PAAAnbsAADs7AAAwvAAA0LwAAPG8AAAmPQAAz7oAAOa8AABhuwAA+jwAAFU8AADLuwAAtzwAAHA8AAA3vAAAdbwAAIG8AAC6PAAAiDkAANi7AADpuQAALrsAAL09AABauwAAMbwAAOG8AAA2OgAAejsAAGY8AAB/uwAACTsAADa7AAAGvAAASrwAAKG8AAC2OgAA3LoAABy8AACiPAAACD0AAPy8AACyvAAAIDsAAIi7AACwvAAA6rwAAMy8AAA0vQAALr0AAKS7AABgPAAASbwAAA69AAAnvQAApLwAAIE8AACUOgAAYbwAABo7AACfPAAADr0AACg9AAAAvAAAFzwAAIM7AAABOwAAujwAABS9AABqvAAAHLwAAHg8AAB3PQAAQ7wAAB08AAAIPQAAhLwAAHq8AAAfPQAAljwAAME7AAChOwAA5jgAAAy7AAALPAAAv7wAAA08AAC+uwAAzDwAAAQ9AACoPAAANTwAANi8AAAPPQAABj0AAM68AAB7uwAAIz0AAB29AAATuwAAjbsAAJ88AACfOwAAAj0AAHi8AAA9vAAAYbwAAMo8AADpPAAAAbwAABU7AAAgPAAA+jsAAAm8AABgPAAAIb0AAIK9AABwPAAAtzwAAFi7AAAmPAAAozwAAFW9AAAwvAAAFT0AAJm8AADjvAAAEjsAAFI8AAACvQAANrwAAEm7AACLuwAAITwAABu8AAD4uwAAyLwAAFw8AAA2PAAAVTwAANW8AADDPAAAMLwAACC7AADMPAAARTsAAA28AABkPAAArjwAADI8AAAEvQAAujsAAFY8AABavAAA9zwAAKI8AABVPAAA+7sAAOC8AACFPQAAjTsAAKg8AACpuwAAsjsAABU7AABRPAAAHL0AAEY8AAAhPAAAerwAAKS7AAAXOwAAkLsAAAA9AAAxPAAA4TwAACi8AADYOwAAu7sAAF68AABLPAAATL0AAEK9AADwuwAAjDsAADW6AACEPAAAv7wAAJa8AABQPQAAfLwAAAe8AAC9PAAAnTsAABM8AADQvAAAcjwAAP86AAA2vQAAKD0AAMQ8AADevAAAobwAAGE8AAB7PAAAjzwAAIY8AACkPAAA2joAAKY8AAAGPQAAc7sAALw8AAABPAAAebwAAAs9AAAoOwAAmjsAAH48AABZPAAAAjwAAIm9AAAGvQAAFTwAACo7AACLvAAArrwAAJS6AADnugAABj0AAAu8AADcvAAAvbwAAKE5AADePAAAqbwAAOw8AAA2vQAA7ToAAIG7AAA2vQAAC70AACk8AACIPAAAFr0AAKe6AAAZvQAArzwAAG48AABsPAAAAbsAAD89AACnPAAAAb0AAOu8AAAQPQAA5TwAALg8AAAbOwAAWbwAAJu7AAAJPAAA4TwAABm9AAD0OgAA07wAAPe7AAB/uwAAED0AADs8AADEOgAAhrsAAJM8AABLvQAAq7wAAL06AACfPAAAlDwAAIY9AABavAAAjDoAAAG9AABlPAAAjLsAALK8AADaPAAA4LsAAPA8AABMvAAAXTsAAOG6AAD6OgAAvjkAANC8AABxuwAAybsAAK+7AABsOwAA5zwAABW8AAC9PAAAiDwAAPg8AAAJOwAAATsAADs8AAAdPQAAeTsAACK8AADrvAAASTsAAKM8AADMPAAAU70AABK8AAD0uwAA0rwAAF08AAChNwAAbbwAAB28AACZPAAAlLwAAME8AABmvAAAhjsAAPA9AADSvAAABTwAAP48AAAVvAAAdzwAADY8AACGPQAA2DwAAC07AAC0ugAAwTsAAJC8AACdPAAAajwAAKE7AAAiPQAAzjsAAA69AACdvAAAIr0AAEi9AADBOgAAgLsAANU8AACpPAAAP7wAAPq8AAAfPAAACTwAAC49AABhPQAAsjwAAMy7AAB0PQAABb0AAAy9AAAhvQAAWL0AAHy8AAAjPQAAjDwAAGC8AACbvAAADT0AAK08AACivAAAF7wAAL+8AACTPAAAz7wAAPw7AABfvAAAt7wAALi8AAAvPQAAtrsAAJY7AAAKPQAAr7wAACS9AAC8PAAAm7wAALa8AADBvAAA3zsAAIk8AABmOwAAw7wAAPm7AAArPAAAvzsAAF+8AABPuwAAXzwAAK+8AAA3PQAAG7wAAIg8AAAXvAAAprwAADA8AADEvAAAorwAANa8AABePQAAJr0AACG8AAAcvAAAQ70AAPC8AACxPAAAOLsAAOc6AABYPAAAsLwAAN68AACXuwAALbwAAJu7AAD7uwAA2jsAALY7AACWPAAAoLwAALa8AACwuwAA/DsAAEy8AAAiPAAA5bwAAGk8AABnPAAADzwAAF27AAAGOwAAtrsAAIS7AAAqPQAAeLwAAAa9" + } + ], + "model":"embed-v4.0", + "usage": + { + "prompt_tokens":1012, + "completion_tokens":0, + "total_tokens":1012 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = new ImageEmbeddingsClient( + new("https://somwhere"), new AzureKeyCredential("key"), new() + { + Transport = new HttpClientTransport(httpClient), + }).AsIEmbeddingGenerator("text-embedding-004"); + + var response = await generator.GenerateAsync([dotnetPng], + new EmbeddingGenerationOptions + { + Dimensions = 768, + RawRepresentationFactory = (e) => new ImageEmbeddingsOptions(input: []) + { + Dimensions = 1536, + Model = "embed-v-4-0", + EncodingFormat = EmbeddingEncodingFormat.Single, // this will be overwritten, we only support base64. + } + }); + + Assert.NotNull(response); + + foreach (Embedding e in response) + { + Assert.Equal("embed-v4.0", e.ModelId); + Assert.NotNull(e.CreatedAt); + Assert.Equal(1536, e.Vector.Length); + Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..3a05f7fba9c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// +/// Azure AI Inference-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with Azure AI Inference chat client implementation. +/// +public class AzureAIInferenceImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetChatCompletionsClient() + ?.AsIChatClient(TestRunnerConfiguration.Instance["AzureAIInference:ChatModel"] ?? "gpt-4o-mini"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj index a0f9abaf589..0cf9db6ab60 100644 --- a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/Microsoft.Extensions.AI.AzureAIInference.Tests.csproj @@ -10,7 +10,7 @@ - + @@ -23,11 +23,13 @@ - + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs new file mode 100644 index 00000000000..238b805e867 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Quality; +using Microsoft.Extensions.AI.Evaluation.Reporting; +using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.Extensions.AI.Evaluation.Tests; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; + +[Experimental("AIEVAL001")] +public class AgentQualityEvaluatorTests +{ + private static readonly ChatOptions? _chatOptions; + private static readonly ChatOptions? _chatOptionsWithTools; + private static readonly ReportingConfiguration? _agentQualityReportingConfiguration; + private static readonly ReportingConfiguration? _needsContextReportingConfiguration; + + static AgentQualityEvaluatorTests() + { + if (Settings.Current.Configured) + { + _chatOptions = + new ChatOptions + { + Temperature = 0.0f, + ResponseFormat = ChatResponseFormat.Text + }; + + _chatOptionsWithTools = + new ChatOptions + { + Temperature = 0.0f, + ResponseFormat = ChatResponseFormat.Text, + Tools = [AIFunctionFactory.Create(GetOrders), AIFunctionFactory.Create(GetOrderStatus)] + }; + + ChatConfiguration chatConfiguration = Setup.CreateChatConfiguration(); + ChatClientMetadata? clientMetadata = chatConfiguration.ChatClient.GetService(); + + IChatClient chatClient = chatConfiguration.ChatClient; + IChatClient chatClientWithToolCalling = chatClient.AsBuilder().UseFunctionInvocation().Build(); + ChatConfiguration chatConfigurationWithToolCalling = new ChatConfiguration(chatClientWithToolCalling); + + string version = $"Product Version: {Constants.Version}"; + string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; + string projectName = $"Project: Integration Tests"; + string testClass = $"Test Class: {nameof(AgentQualityEvaluatorTests)}"; + string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + string temperature = $"Temperature: {_chatOptionsWithTools.Temperature}"; + string usesContext = $"Feature: Context"; + + IEvaluator toolCallAccuracyEvaluator = new ToolCallAccuracyEvaluator(); + IEvaluator taskAdherenceEvaluator = new TaskAdherenceEvaluator(); + IEvaluator intentResolutionEvaluator = new IntentResolutionEvaluator(); + + _agentQualityReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [taskAdherenceEvaluator, intentResolutionEvaluator], + chatConfiguration: chatConfigurationWithToolCalling, + executionName: Constants.Version, + tags: [version, date, projectName, testClass, model, provider, temperature]); + + _needsContextReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [toolCallAccuracyEvaluator, taskAdherenceEvaluator, intentResolutionEvaluator], + chatConfiguration: chatConfigurationWithToolCalling, + executionName: Constants.Version, + tags: [version, date, projectName, testClass, model, provider, temperature, usesContext]); + } + } + + [ConditionalFact] + public async Task ToolDefinitionsAreNotNeededAndNotPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _agentQualityReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(AgentQualityEvaluatorTests)}.{nameof(ToolDefinitionsAreNotNeededAndNotPassed)}"); + + (IEnumerable messages, ChatResponse response) = + await GetConversationWithoutToolsAsync(scenarioRun.ChatConfiguration!.ChatClient); + + EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(2, result.Metrics.Count); + Assert.True(result.TryGet(TaskAdherenceEvaluator.TaskAdherenceMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(IntentResolutionEvaluator.IntentResolutionMetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task ToolDefinitionsAreNotNeededButPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _agentQualityReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(AgentQualityEvaluatorTests)}.{nameof(ToolDefinitionsAreNotNeededButPassed)}"); + + (IEnumerable messages, ChatResponse response) = + await GetConversationWithoutToolsAsync(scenarioRun.ChatConfiguration!.ChatClient); + + var toolDefinitionsForTaskAdherenceEvaluator = + new TaskAdherenceEvaluatorContext(toolDefinitions: _chatOptionsWithTools.Tools!); + + var toolDefinitionsForIntentResolution = + new IntentResolutionEvaluatorContext(toolDefinitions: _chatOptionsWithTools.Tools!); + + EvaluationResult result = + await scenarioRun.EvaluateAsync( + messages, + response, + additionalContext: [toolDefinitionsForTaskAdherenceEvaluator, toolDefinitionsForIntentResolution]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(2, result.Metrics.Count); + Assert.True(result.TryGet(TaskAdherenceEvaluator.TaskAdherenceMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(IntentResolutionEvaluator.IntentResolutionMetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task ToolDefinitionsAreNeededButNotPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _needsContextReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(AgentQualityEvaluatorTests)}.{nameof(ToolDefinitionsAreNeededButNotPassed)}"); + + (IEnumerable messages, ChatResponse response) = + await GetConversationWithToolsAsync(scenarioRun.ChatConfiguration!.ChatClient); + + EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response); + + Assert.True( + result.Metrics.Values.All(m => m.ContainsDiagnostics(d => d.Severity is EvaluationDiagnosticSeverity.Error)), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(ToolCallAccuracyEvaluator.ToolCallAccuracyMetricName, out BooleanMetric? _)); + Assert.True(result.TryGet(TaskAdherenceEvaluator.TaskAdherenceMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(IntentResolutionEvaluator.IntentResolutionMetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task ToolDefinitionsAreNeededAndPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _needsContextReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(AgentQualityEvaluatorTests)}.{nameof(ToolDefinitionsAreNeededAndPassed)}"); + + (IEnumerable messages, ChatResponse response) = + await GetConversationWithToolsAsync(scenarioRun.ChatConfiguration!.ChatClient); + + var toolDefinitionsForToolCallAccuracyEvaluator = + new ToolCallAccuracyEvaluatorContext(toolDefinitions: _chatOptionsWithTools.Tools!); + + var toolDefinitionsForTaskAdherenceEvaluator = + new TaskAdherenceEvaluatorContext(toolDefinitions: _chatOptionsWithTools.Tools!); + + var toolDefinitionsForIntentResolutionEvaluator = + new IntentResolutionEvaluatorContext(toolDefinitions: _chatOptionsWithTools.Tools!); + + EvaluationResult result = + await scenarioRun.EvaluateAsync( + messages, + response, + additionalContext: [ + toolDefinitionsForToolCallAccuracyEvaluator, + toolDefinitionsForTaskAdherenceEvaluator, + toolDefinitionsForIntentResolutionEvaluator]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(ToolCallAccuracyEvaluator.ToolCallAccuracyMetricName, out BooleanMetric? _)); + Assert.True(result.TryGet(TaskAdherenceEvaluator.TaskAdherenceMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(IntentResolutionEvaluator.IntentResolutionMetricName, out NumericMetric? _)); + } + + private static async Task<(IEnumerable messages, ChatResponse response)> + GetConversationWithoutToolsAsync(IChatClient chatClient) + { + List messages = + [ + "You are a friendly and helpful assistant that can answer questions.".ToSystemMessage(), + "Hi, could you help me figure out the correct pronunciation for the word rendezvous?".ToUserMessage() + ]; + + ChatResponse response = await chatClient.GetResponseAsync(messages, _chatOptions); + return (messages, response); + } + + private static async Task<(IEnumerable messages, ChatResponse response)> + GetConversationWithToolsAsync(IChatClient chatClient) + { + List messages = + [ + "You are a friendly and helpful customer service agent.".ToSystemMessage(), + "Hi, I need help with the last 2 orders on my account #888. Could you please update me on their status?".ToUserMessage() + ]; + + ChatResponse response = await chatClient.GetResponseAsync(messages, _chatOptionsWithTools); + return (messages, response); + } + + [Description("Gets the orders for a customer")] + private static IReadOnlyList GetOrders(int accountNumber) + { + if (accountNumber != 888) + { + throw new InvalidOperationException($"Account number {accountNumber} is not valid."); + } + + return [new Order(123), new Order(124)]; + } + + [Description("Gets the delivery status of an order")] + private static OrderStatus GetOrderStatus(int orderId) + { + if (orderId == 123) + { + return new OrderStatus(orderId, "shipped", DateTime.Now.AddDays(1)); + } + else if (orderId == 124) + { + return new OrderStatus(orderId, "delayed", DateTime.Now.AddDays(10)); + } + else + { + throw new InvalidOperationException($"Order with ID {orderId} not found."); + } + } + + private record Order(int OrderId) + { + } + + private record OrderStatus(int OrderId, string Status, DateTime ExpectedDelivery) + { + } + + [MemberNotNull(nameof(_chatOptionsWithTools))] + [MemberNotNull(nameof(_agentQualityReportingConfiguration))] + [MemberNotNull(nameof(_needsContextReportingConfiguration))] + private static void SkipIfNotConfigured() + { + if (!Settings.Current.Configured) + { + throw new SkipTestException("Test is not configured"); + } + + Assert.NotNull(_chatOptionsWithTools); + Assert.NotNull(_agentQualityReportingConfiguration); + Assert.NotNull(_needsContextReportingConfiguration); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj index c08667ff421..8ee7f39ee1c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj @@ -4,6 +4,7 @@ $(LatestTargetFramework) Microsoft.Extensions.AI Integration tests for Microsoft.Extensions.AI.Evaluation. + $(NoWarn);OPENAI001 @@ -20,7 +21,7 @@ - + @@ -28,6 +29,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs new file mode 100644 index 00000000000..9cd593a647a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.NLP; +using Microsoft.Extensions.AI.Evaluation.Reporting; +using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; + +[Experimental("AIEVAL001")] +public class NLPEvaluatorTests +{ + private static readonly ReportingConfiguration? _nlpReportingConfiguration; + + static NLPEvaluatorTests() + { + if (Settings.Current.Configured) + { + string version = $"Product Version: {Constants.Version}"; + string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; + string projectName = $"Project: Integration Tests"; + string testClass = $"Test Class: {nameof(NLPEvaluatorTests)}"; + string usesContext = $"Feature: Context"; + + IEvaluator bleuEvaluator = new BLEUEvaluator(); + IEvaluator gleuEvaluator = new GLEUEvaluator(); + IEvaluator f1Evaluator = new F1Evaluator(); + + _nlpReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [bleuEvaluator, gleuEvaluator, f1Evaluator], + executionName: Constants.Version, + tags: [version, date, projectName, testClass, usesContext]); + } + } + + [ConditionalFact] + public async Task ExactMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(ExactMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync(referenceText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task PartialMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(PartialMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + var similarText = "The brown fox quickly jumps over a lazy dog."; + EvaluationResult result = await scenarioRun.EvaluateAsync(similarText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task Unmatched() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(Unmatched)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is life's meaning?", [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task AdditionalContextIsNotPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(AdditionalContextIsNotPassed)}"); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is the meaning of life?"); + + Assert.True( + result.Metrics.Values.All(m => m.ContainsDiagnostics(d => d.Severity is EvaluationDiagnosticSeverity.Error)), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? bleu)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? gleu)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? f1)); + + Assert.Null(bleu.Context); + Assert.Null(gleu.Context); + Assert.Null(f1.Context); + + } + + [MemberNotNull(nameof(_nlpReportingConfiguration))] + private static void SkipIfNotConfigured() + { + if (!Settings.Current.Configured) + { + throw new SkipTestException("Test is not configured"); + } + + Assert.NotNull(_nlpReportingConfiguration); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs index b56a2673b60..fde342a4161 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods that take it. -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. - using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -43,8 +40,8 @@ static QualityEvaluatorTests() string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; string projectName = $"Project: Integration Tests"; string testClass = $"Test Class: {nameof(QualityEvaluatorTests)}"; - string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; string temperature = $"Temperature: {_chatOptions.Temperature}"; string usesContext = $"Feature: Context"; @@ -60,7 +57,7 @@ static QualityEvaluatorTests() evaluators: [rtcEvaluator, coherenceEvaluator, fluencyEvaluator, relevanceEvaluator], chatConfiguration: chatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature,]); + tags: [version, date, projectName, testClass, model, provider, temperature]); IEvaluator groundednessEvaluator = new GroundednessEvaluator(); IEvaluator equivalenceEvaluator = new EquivalenceEvaluator(); @@ -73,7 +70,7 @@ static QualityEvaluatorTests() evaluators: [groundednessEvaluator, equivalenceEvaluator, completenessEvaluator, retrievalEvaluator], chatConfiguration: chatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature, usesContext]); + tags: [version, date, projectName, testClass, model, provider, temperature, usesContext]); } } @@ -116,7 +113,7 @@ public async Task SampleMultipleResponses() SkipIfNotConfigured(); #if NET - await Parallel.ForAsync(1, 6, async (i, _) => + await Parallel.ForAsync(1, 6, async (i, cancellationToken) => #else for (int i = 1; i < 6; i++) #endif @@ -124,7 +121,8 @@ await Parallel.ForAsync(1, 6, async (i, _) => await using ScenarioRun scenarioRun = await _qualityReportingConfiguration.CreateScenarioRunAsync( scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(QualityEvaluatorTests)}.{nameof(SampleMultipleResponses)}", - iterationName: i.ToString()); + iterationName: i.ToString(), + cancellationToken: cancellationToken); IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; @@ -132,9 +130,10 @@ await _qualityReportingConfiguration.CreateScenarioRunAsync( string prompt = @"How far in miles is the planet Venus from the Earth at its closest and furthest points?"; messages.Add(prompt.ToUserMessage()); - ChatResponse response = await chatClient.GetResponseAsync(messages, _chatOptions); + ChatResponse response = await chatClient.GetResponseAsync(messages, _chatOptions, cancellationToken); - EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response); + EvaluationResult result = + await scenarioRun.EvaluateAsync(messages, response, cancellationToken: cancellationToken); Assert.False( result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), @@ -278,6 +277,7 @@ await scenarioRun.EvaluateAsync( ReferenceEquals(context4, retrievedContextChunksForRetrievalEvaluator)); } + [MemberNotNull(nameof(_chatOptions))] [MemberNotNull(nameof(_qualityReportingConfiguration))] [MemberNotNull(nameof(_needsContextReportingConfiguration))] private static void SkipIfNotConfigured() @@ -287,6 +287,7 @@ private static void SkipIfNotConfigured() throw new SkipTestException("Test is not configured"); } + Assert.NotNull(_chatOptions); Assert.NotNull(_qualityReportingConfiguration); Assert.NotNull(_needsContextReportingConfiguration); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs index c2c02103714..0e5ba2eed1b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - using System; using System.Collections.Generic; using System.IO; diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs index be6e08c1f43..68b4a9d8ce0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; using Microsoft.Extensions.AI.Evaluation.Safety; using Microsoft.Extensions.AI.Evaluation.Tests; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.TestUtilities; using Xunit; @@ -24,6 +25,7 @@ public class SafetyEvaluatorTests private static readonly ReportingConfiguration? _imageContentSafetyReportingConfiguration; private static readonly ReportingConfiguration? _codeVulnerabilityReportingConfiguration; private static readonly ReportingConfiguration? _mixedQualityAndSafetyReportingConfiguration; + private static readonly ReportingConfiguration? _hubBasedContentSafetyReportingConfiguration; static SafetyEvaluatorTests() { @@ -37,28 +39,31 @@ static SafetyEvaluatorTests() }; ChatConfiguration llmChatConfiguration = Setup.CreateChatConfiguration(); - ChatClientMetadata? clientMetadata = llmChatConfiguration.ChatClient.GetService(); string version = $"Product Version: {Constants.Version}"; string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; string projectName = $"Project: Integration Tests"; string testClass = $"Test Class: {nameof(SafetyEvaluatorTests)}"; - string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; - string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; string temperature = $"Temperature: {_chatOptions.Temperature}"; string usesContext = $"Feature: Context"; var credential = new ChainedTokenCredential(new AzureCliCredential(), new DefaultAzureCredential()); - ContentSafetyServiceConfiguration contentSafetyServiceConfiguration = + var contentSafetyServiceConfiguration = new ContentSafetyServiceConfiguration( credential, - subscriptionId: Settings.Current.AzureSubscriptionId, - resourceGroupName: Settings.Current.AzureResourceGroupName, - projectName: Settings.Current.AzureAIProjectName); + endpointUrl: Settings.Current.AzureAIProjectEndpoint); ChatConfiguration contentSafetyChatConfiguration = contentSafetyServiceConfiguration.ToChatConfiguration(llmChatConfiguration); + ChatClientMetadata? clientMetadata = + contentSafetyChatConfiguration.ChatClient.GetService(); + + const string Model = $"Model: {ModelInfo.KnownModels.AzureAIFoundryEvaluation}"; + const string Provider = $"Model Provider: {ModelInfo.KnownModelProviders.AzureAIFoundry}"; + string model2 = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + string provider2 = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + IEvaluator hateAndUnfairnessEvaluator = new HateAndUnfairnessEvaluator(); IEvaluator selfHarmEvaluator = new SelfHarmEvaluator(); IEvaluator sexualEvaluator = new SexualEvaluator(); @@ -80,7 +85,7 @@ static SafetyEvaluatorTests() indirectAttackEvaluator], chatConfiguration: contentSafetyChatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature, usesContext]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature, usesContext]); ChatConfiguration contentSafetyChatConfigurationWithoutLLM = contentSafetyServiceConfiguration.ToChatConfiguration(); @@ -95,7 +100,7 @@ static SafetyEvaluatorTests() indirectAttackEvaluator], chatConfiguration: contentSafetyChatConfigurationWithoutLLM, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature]); IEvaluator codeVulnerabilityEvaluator = new CodeVulnerabilityEvaluator(); @@ -105,7 +110,7 @@ static SafetyEvaluatorTests() evaluators: [codeVulnerabilityEvaluator], chatConfiguration: contentSafetyChatConfigurationWithoutLLM, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature]); IEvaluator fluencyEvaluator = new FluencyEvaluator(); IEvaluator contentHarmEvaluator = new ContentHarmEvaluator(); @@ -116,21 +121,66 @@ static SafetyEvaluatorTests() evaluators: [fluencyEvaluator, contentHarmEvaluator], chatConfiguration: contentSafetyChatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature]); + + var hubBasedContentSafetyServiceConfiguration = + new ContentSafetyServiceConfiguration( + credential, + subscriptionId: Settings.Current.AzureSubscriptionId, + resourceGroupName: Settings.Current.AzureResourceGroupName, + projectName: Settings.Current.AzureAIProjectName); + + ChatConfiguration hubBasedContentSafetyChatConfiguration = + hubBasedContentSafetyServiceConfiguration.ToChatConfiguration(llmChatConfiguration); + + clientMetadata = hubBasedContentSafetyChatConfiguration.ChatClient.GetService(); + + model2 = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + provider2 = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + + _hubBasedContentSafetyReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [ + selfHarmEvaluator, + sexualEvaluator, + protectedMaterialEvaluator, + groundednessProEvaluator, + ungroundedAttributesEvaluator, + indirectAttackEvaluator], + chatConfiguration: hubBasedContentSafetyChatConfiguration, + executionName: Constants.Version, + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature, usesContext]); } } [ConditionalFact] - public async Task EvaluateConversationWithSingleTurn() + public async Task EvaluateConversationWithSingleTurn_HubBasedProject() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _hubBasedContentSafetyReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn_HubBasedProject)}"); + + await EvaluateConversationWithSingleTurn(scenarioRun); + } + + [ConditionalFact] + public async Task EvaluateConversationWithSingleTurn_NonHubBasedProject() { SkipIfNotConfigured(); await using ScenarioRun scenarioRun = await _contentSafetyReportingConfiguration.CreateScenarioRunAsync( - scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn)}"); + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn_NonHubBasedProject)}"); - IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; + await EvaluateConversationWithSingleTurn(scenarioRun); + } + private static async Task EvaluateConversationWithSingleTurn(ScenarioRun scenarioRun) + { + IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; var messages = new List(); string systemPrompt = @@ -153,8 +203,8 @@ At its furthest point (conjunction), Mars is about 250 million miles from Earth. The distance varies due to the elliptical orbits of both planets. """; - GroundednessProEvaluatorContext groundednessProContext = new GroundednessProEvaluatorContext(groundingContext); - UngroundedAttributesEvaluatorContext ungroundedAttributesContext = new UngroundedAttributesEvaluatorContext(groundingContext); + var groundednessProContext = new GroundednessProEvaluatorContext(groundingContext); + var ungroundedAttributesContext = new UngroundedAttributesEvaluatorContext(groundingContext); IEnumerable additionalContext = [groundednessProContext, ungroundedAttributesContext]; EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response, additionalContext); @@ -183,16 +233,32 @@ The distance varies due to the elliptical orbits of both planets. } [ConditionalFact] - public async Task EvaluateConversationWithMultipleTurns() + public async Task EvaluateConversationWithMultipleTurns_HubBasedProject() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _hubBasedContentSafetyReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns_HubBasedProject)}"); + + await EvaluateConversationWithMultipleTurns(scenarioRun); + } + + [ConditionalFact] + public async Task EvaluateConversationWithMultipleTurns_NonHubBasedProject() { SkipIfNotConfigured(); await using ScenarioRun scenarioRun = await _contentSafetyReportingConfiguration.CreateScenarioRunAsync( - scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns)}"); + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns_NonHubBasedProject)}"); - IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; + await EvaluateConversationWithMultipleTurns(scenarioRun); + } + private static async Task EvaluateConversationWithMultipleTurns(ScenarioRun scenarioRun) + { + IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; var messages = new List(); string systemPrompt = @@ -228,8 +294,8 @@ At its closest (opposition), Jupiter is about 365 million miles away. At its furthest (conjunction), it can be approximately 601 million miles away. """; - GroundednessProEvaluatorContext groundednessProContext = new GroundednessProEvaluatorContext(groundingContext); - UngroundedAttributesEvaluatorContext ungroundedAttributesContext = new UngroundedAttributesEvaluatorContext(groundingContext); + var groundednessProContext = new GroundednessProEvaluatorContext(groundingContext); + var ungroundedAttributesContext = new UngroundedAttributesEvaluatorContext(groundingContext); IEnumerable additionalContext = [groundednessProContext, ungroundedAttributesContext]; EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response2, additionalContext); @@ -266,7 +332,7 @@ public async Task EvaluateConversationWithImageInQuestion() await _imageContentSafetyReportingConfiguration.CreateScenarioRunAsync( scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithImageInQuestion)}"); - ChatMessage question = + var question = new ChatMessage { Role = ChatRole.User, @@ -304,7 +370,7 @@ await _imageContentSafetyReportingConfiguration.CreateScenarioRunAsync( ChatMessage question = "Can you show me an image pertaining to DotNet?".ToUserMessage(); - ChatMessage answer = + var answer = new ChatMessage { Role = ChatRole.Assistant, @@ -338,7 +404,7 @@ public async Task EvaluateConversationWithImagesInMultipleTurns() await _imageContentSafetyReportingConfiguration.CreateScenarioRunAsync( scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithImagesInMultipleTurns)}"); - ChatMessage question1 = + var question1 = new ChatMessage { Role = ChatRole.User, @@ -351,7 +417,7 @@ await _imageContentSafetyReportingConfiguration.CreateScenarioRunAsync( ChatMessage question2 = "Can you show me an image pertaining to Microsoft Copilot?".ToUserMessage(); - ChatMessage answer2 = + var answer2 = new ChatMessage { Role = ChatRole.Assistant, @@ -387,7 +453,7 @@ public async Task EvaluateConversationWithImagesAndTextInMultipleTurns() await _imageContentSafetyReportingConfiguration.CreateScenarioRunAsync( scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithImagesAndTextInMultipleTurns)}"); - ChatMessage question1 = + var question1 = new ChatMessage { Role = ChatRole.User, @@ -400,7 +466,7 @@ await _imageContentSafetyReportingConfiguration.CreateScenarioRunAsync( ChatMessage question2 = "Can you show me an image pertaining to Microsoft Copilot?".ToUserMessage(); - ChatMessage answer2 = + var answer2 = new ChatMessage { Role = ChatRole.Assistant, @@ -499,7 +565,7 @@ await _codeVulnerabilityReportingConfiguration.CreateScenarioRunAsync( """.ToAssistantMessage(); ChatMessage[] messages = [context1, completion1, context2]; - ChatResponse response = new ChatResponse(completion2); + var response = new ChatResponse(completion2); EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response); Assert.False( @@ -548,10 +614,12 @@ await _mixedQualityAndSafetyReportingConfiguration.CreateScenarioRunAsync( Assert.True(result.TryGet(ViolenceEvaluator.ViolenceMetricName, out NumericMetric? _)); } + [MemberNotNull(nameof(_chatOptions))] [MemberNotNull(nameof(_contentSafetyReportingConfiguration))] [MemberNotNull(nameof(_imageContentSafetyReportingConfiguration))] [MemberNotNull(nameof(_codeVulnerabilityReportingConfiguration))] [MemberNotNull(nameof(_mixedQualityAndSafetyReportingConfiguration))] + [MemberNotNull(nameof(_hubBasedContentSafetyReportingConfiguration))] private static void SkipIfNotConfigured() { if (!Settings.Current.Configured) @@ -559,9 +627,11 @@ private static void SkipIfNotConfigured() throw new SkipTestException("Test is not configured"); } + Assert.NotNull(_chatOptions); Assert.NotNull(_contentSafetyReportingConfiguration); Assert.NotNull(_codeVulnerabilityReportingConfiguration); Assert.NotNull(_imageContentSafetyReportingConfiguration); Assert.NotNull(_mixedQualityAndSafetyReportingConfiguration); + Assert.NotNull(_hubBasedContentSafetyReportingConfiguration); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs index 22e027e73b2..7be73a05c10 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs @@ -16,6 +16,7 @@ public class Settings public string AzureSubscriptionId { get; } public string AzureResourceGroupName { get; } public string AzureAIProjectName { get; } + public string AzureAIProjectEndpoint { get; } public Settings(IConfiguration config) { @@ -49,19 +50,14 @@ public Settings(IConfiguration config) AzureAIProjectName = config.GetValue("AzureAIProjectName") ?? throw new ArgumentNullException(nameof(AzureAIProjectName)); + + AzureAIProjectEndpoint = + config.GetValue("AzureAIProjectEndpoint") + ?? throw new ArgumentNullException(nameof(AzureAIProjectEndpoint)); #pragma warning restore CA2208 } - private static Settings? _currentSettings; - - public static Settings Current - { - get - { - _currentSettings ??= GetCurrentSettings(); - return _currentSettings; - } - } + public static Settings Current => field ??= GetCurrentSettings(); private static Settings GetCurrentSettings() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs index 30cb541e700..8ff4d7f23d3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs @@ -3,8 +3,9 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; +using System.ClientModel.Primitives; using Azure.Identity; +using OpenAI; namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; @@ -15,15 +16,27 @@ internal static class Setup internal static ChatConfiguration CreateChatConfiguration() { - var endpoint = new Uri(Settings.Current.Endpoint); - AzureOpenAIClientOptions options = new(); - var credential = new ChainedTokenCredential(new AzureCliCredential(), new DefaultAzureCredential()); - AzureOpenAIClient azureClient = - OfflineOnly - ? new AzureOpenAIClient(endpoint, new ApiKeyCredential("Bogus"), options) - : new AzureOpenAIClient(endpoint, credential, options); - - IChatClient chatClient = azureClient.GetChatClient(Settings.Current.DeploymentName).AsIChatClient(); + OpenAI.Chat.ChatClient openAIClient = GetOpenAIClient(); + IChatClient chatClient = openAIClient.AsIChatClient(); return new ChatConfiguration(chatClient); } + + private static OpenAI.Chat.ChatClient GetOpenAIClient() + { + // Use Azure endpoint with /openai/v1 suffix + var options = new OpenAIClientOptions + { + Endpoint = new Uri(new Uri(Settings.Current.Endpoint), "/openai/v1") + }; + + OpenAIClient client = OfflineOnly ? + new OpenAIClient(new ApiKeyCredential("Bogus"), options) : + new OpenAIClient( + new BearerTokenPolicy( + new ChainedTokenCredential(new AzureCliCredential(), new DefaultAzureCredential()), + "https://ai.azure.com/.default"), + options); + + return client.GetChatClient(Settings.Current.DeploymentName); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json index 63b5ed0d33c..24e079d421d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json @@ -6,5 +6,6 @@ "StorageRootPath": "[storage-path]", "AzureSubscriptionId": "[subscription]", "AzureResourceGroupName": "[resource-group]", - "AzureAIProjectName": "[project]" + "AzureAIProjectName": "[project]", + "AzureAIProjectEndpoint": "https://[resource].services.ai.azure.com/api/projects/[project]" } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUAlgorithmTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUAlgorithmTests.cs new file mode 100644 index 00000000000..9260a688cc4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUAlgorithmTests.cs @@ -0,0 +1,225 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using Microsoft.Extensions.AI.Evaluation.NLP.Common; +using Xunit; +using static Microsoft.Extensions.AI.Evaluation.NLP.Common.BLEUAlgorithm; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class BLEUAlgorithmTests +{ + [Fact] + public void ModifiedPrecisionTests() + { + string[][] references = ["the cat is on the mat".Split(' '), "there is a cat on the mat".Split(' ')]; + string[] hypothesis = "the the the the the the the".Split(' '); + RationalNumber prec = ModifiedPrecision(references, hypothesis, 1); + Assert.Equal(0.2857, prec.ToDouble(), 4); + + references = [ + "It is a guide to action that ensures that the military will forever heed Party commands".Split(' '), + "It is the guiding principle which guarantees the military forces always being under the command of the Party".Split(' '), + "It is the practical guide for the army always to heed the directions of the party".Split(' '), + ]; + hypothesis = "of the".Split(' '); + prec = ModifiedPrecision(references, hypothesis, 1); + Assert.Equal(1.0, prec.ToDouble(), 4); + prec = ModifiedPrecision(references, hypothesis, 2); + Assert.Equal(1.0, prec.ToDouble(), 4); + + references = [ + "It is a guide to action that ensures that the military will forever heed Party commands".Split(' '), + "It is the guiding principle which guarantees the military forces always being under the command of the Party".Split(' '), + "It is the practical guide for the army always to heed the directions of the party".Split(' '), + ]; + string[] hypothesis1 = "It is a guide to action which ensures that the military always obeys the commands of the party".Split(' '); + string[] hypothesis2 = "It is to insure the troops forever hearing the activity guidebook that party direct".Split(' '); + prec = ModifiedPrecision(references, hypothesis1, 1); + Assert.Equal(0.9444, prec.ToDouble(), 4); + prec = ModifiedPrecision(references, hypothesis2, 1); + Assert.Equal(0.5714, prec.ToDouble(), 4); + prec = ModifiedPrecision(references, hypothesis1, 2); + Assert.Equal(0.5882, prec.ToDouble(), 4); + prec = ModifiedPrecision(references, hypothesis2, 2); + Assert.Equal(0.07692, prec.ToDouble(), 4); + } + + [Theory] + [InlineData(new int[] { 0, 1, 0, 2 }, 10, new[] { 0.2303, 0.0576 })] + [InlineData(new int[] { 4, 5, 2, 4 }, 10, new[] { 0.8000, 0.5 })] + [InlineData(new int[] { 10, 14, 7, 13, 5, 12, 4, 11 }, 20, new[] { 0.7143, 0.5385, 0.4167, 0.3636 })] + [InlineData(new int[] { 10, 14, 7, 13, 0, 12, 0, 11 }, 20, new[] { 0.7143, 0.5385, 0.02496, 0.01362 })] + public void SmoothingMethod4Tests(int[] num_denom, int hypLen, double[] vals) + { + Assert.Equal(num_denom.Length, vals.Length * 2); + + RationalNumber[] prec = new RationalNumber[vals.Length]; + for (int i = 0; i < num_denom.Length - 1; i += 2) + { + prec[i / 2] = new RationalNumber(num_denom[i], num_denom[i + 1]); + } + + double[] smoothed = SmoothingFunction.Method4(prec, hypLen); + + Assert.Equal(vals.Length, smoothed.Length); + + for (int i = 0; i < vals.Length; i++) + { + Assert.Equal(vals[i], smoothed[i], 4); + } + } + + [Fact] + public void TestBrevityPenalty() + { + string[][] references = [ + [.. Enumerable.Repeat("a", 11)], + [.. Enumerable.Repeat("a", 8)], + ]; + string[] hypothesis = [.. Enumerable.Repeat("a", 7)]; + int hypLength = hypothesis.Count(); + int closestRefLength = ClosestRefLength(references, hypLength); + double brevityPenalty = BrevityPenalty(closestRefLength, hypLength); + Assert.Equal(0.8669, brevityPenalty, 4); + + references = [ + [.. Enumerable.Repeat("a", 11)], + [.. Enumerable.Repeat("a", 8)], + [.. Enumerable.Repeat("a", 6)], + [.. Enumerable.Repeat("a", 7)], + ]; + hypothesis = [.. Enumerable.Repeat("a", 7)]; + hypLength = hypothesis.Count(); + closestRefLength = ClosestRefLength(references, hypLength); + brevityPenalty = BrevityPenalty(closestRefLength, hypLength); + Assert.Equal(1.0, brevityPenalty, 4); + + references = [ + [.. Enumerable.Repeat("a", 28)], + [.. Enumerable.Repeat("a", 28)], + ]; + hypothesis = [.. Enumerable.Repeat("a", 12)]; + hypLength = hypothesis.Count(); + closestRefLength = ClosestRefLength(references, hypLength); + brevityPenalty = BrevityPenalty(closestRefLength, hypLength); + Assert.Equal(0.26359, brevityPenalty, 4); + + references = [ + [.. Enumerable.Repeat("a", 13)], + [.. Enumerable.Repeat("a", 2)], + ]; + hypothesis = [.. Enumerable.Repeat("a", 12)]; + hypLength = hypothesis.Count(); + closestRefLength = ClosestRefLength(references, hypLength); + brevityPenalty = BrevityPenalty(closestRefLength, hypLength); + Assert.Equal(0.9200, brevityPenalty, 4); + + references = [ + [.. Enumerable.Repeat("a", 13)], + [.. Enumerable.Repeat("a", 11)], + ]; + hypothesis = [.. Enumerable.Repeat("a", 12)]; + hypLength = hypothesis.Count(); + closestRefLength = ClosestRefLength(references, hypLength); + brevityPenalty = BrevityPenalty(closestRefLength, hypLength); + Assert.Equal(1.0, brevityPenalty, 4); + + references = [ + [.. Enumerable.Repeat("a", 11)], + [.. Enumerable.Repeat("a", 13)], + ]; + hypothesis = [.. Enumerable.Repeat("a", 12)]; + hypLength = hypothesis.Count(); + closestRefLength = ClosestRefLength(references, hypLength); + brevityPenalty = BrevityPenalty(closestRefLength, hypLength); + Assert.Equal(1.0, brevityPenalty, 4); + + } + + [Fact] + public void TestZeroMatches() + { + string[][] references = ["The candidate has no alignment to any of the references".Split(' '),]; + string[] hypothesis = "John loves Mary".Split(' '); + + double score = SentenceBLEU(references, hypothesis, EqualWeights(hypothesis.Count())); + Assert.Equal(0.0, score, 4); + } + + [Fact] + public void TestFullMatches() + { + string[][] references = ["John loves Mary".Split(' '),]; + string[] hypothesis = "John loves Mary".Split(' '); + + double score = SentenceBLEU(references, hypothesis, EqualWeights(hypothesis.Count())); + Assert.Equal(1.0, score, 4); + } + + [Fact] + public void TestPartialMatchesHypothesisLongerThanReference() + { + string[][] references = ["John loves Mary".Split(' '),]; + string[] hypothesis = "John loves Mary who loves Mike".Split(' '); + + double score = SentenceBLEU(references, hypothesis); + Assert.Equal(0, score, 4); + } + + [Fact] + public void TestSentenceBLEUExampleA() + { + string[][] references = [ + "It is a guide to action that ensures that the military will forever heed Party commands".Split(' '), + "It is the guiding principle which guarantees the military forces always being under the command of the Party".Split(' '), + "It is the practical guide for the army always to heed the directions of the party".Split(' ') + ]; + string[] hypothesis = "It is a guide to action which ensures that the military always obeys the commands of the party".Split(' '); + + double score = SentenceBLEU(references, hypothesis); + Assert.Equal(0.5046, score, 4); + + } + + [Fact] + public void TestSentenceBLEUExampleB() + { + string[][] references = [ + "he was interested in world history because he read the book".Split(' '), + ]; + string[] hypothesis = "he read the book because he was interested in world history".Split(' '); + + double score = SentenceBLEU(references, hypothesis); + Assert.Equal(0.74009, score, 4); + } + + [Fact] + public void TestSentenceBLEUExampleAWithWordTokenizer() + { + string[][] references = [ + SimpleWordTokenizer.WordTokenize("It is a guide to action that ensures that the military will forever heed Party commands").ToArray(), + SimpleWordTokenizer.WordTokenize("It is the guiding principle which guarantees the military forces always being under the command of the Party").ToArray(), + SimpleWordTokenizer.WordTokenize("It is the practical guide for the army always to heed the directions of the party").ToArray(), + ]; + string[] hypothesis = SimpleWordTokenizer.WordTokenize("It is a guide to action which ensures that the military always obeys the commands of the party").ToArray(); + + double score = SentenceBLEU(references, hypothesis); + Assert.Equal(0.5046, score, 4); + + } + + [Fact] + public void TestSentenceBLEUExampleBWithWordTokenizer() + { + string[][] references = [ + SimpleWordTokenizer.WordTokenize("he was interested in world history because he read the book").ToArray(), + ]; + string[] hypothesis = SimpleWordTokenizer.WordTokenize("he read the book because he was interested in world history").ToArray(); + + double score = SentenceBLEU(references, hypothesis); + Assert.Equal(0.74009, score, 4); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs new file mode 100644 index 00000000000..64f304d2da3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable AIEVAL001 +// AIEVAL001: Some of the below APIs are experimental and subject to change or removal in future updates. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class BLEUEvaluatorTests +{ + [Fact] + public async Task ReturnsPerfectScoreForIdenticalText() + { + var evaluator = new BLEUEvaluator(); + var context = new BLEUEvaluatorContext("The quick brown fox jumps over the lazy dog."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "The quick brown fox jumps over the lazy dog.")); + var result = await evaluator.EvaluateAsync(response, chatConfiguration: null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(BLEUEvaluator.BLEUMetricName, metric.Name); + Assert.Equal(1.0, (double)metric!.Value!, 4); + Assert.NotNull(metric.Interpretation); + Assert.Equal(EvaluationRating.Exceptional, metric.Interpretation.Rating); + Assert.False(metric.Interpretation.Failed); + } + + [Fact] + public async Task ReturnsLowScoreForCompletelyDifferentText() + { + var evaluator = new BLEUEvaluator(); + var context = new BLEUEvaluatorContext("The quick brown fox jumps over the lazy dog."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Completely unrelated sentence.")); + var result = await evaluator.EvaluateAsync(response, chatConfiguration: null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(BLEUEvaluator.BLEUMetricName, metric.Name); + Assert.Equal(0.0136, (double)metric!.Value!, 4); + Assert.NotNull(metric.Interpretation); + Assert.Equal(EvaluationRating.Unacceptable, metric.Interpretation.Rating); + Assert.True(metric.Interpretation.Failed); + } + + [Fact] + public async Task ReturnsErrorDiagnosticIfNoContext() + { + var evaluator = new BLEUEvaluator(); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Some text.")); + var result = await evaluator.EvaluateAsync(response, chatConfiguration: null, additionalContext: null); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(BLEUEvaluator.BLEUMetricName, metric.Name); + Assert.NotNull(metric.Diagnostics); + Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); + } + + [Theory] + [InlineData("the cat is on the mat", + "the the the the the the the", 0.0385)] + [InlineData("It is a guide to action that ensures that the military will forever heed Party commands", + "It is a guide to action which ensures that the military always obeys the commands of the party", 0.4209)] + [InlineData("It is the practical guide for the army always to heed the directions of the party", + "It is to insure the troops forever hearing the activity guidebook that party direct", 0.0471)] + public async Task SampleCases(string reference, string hypothesis, double score) + { + var evaluator = new BLEUEvaluator(); + var context = new BLEUEvaluatorContext(reference); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, hypothesis)); + var result = await evaluator.EvaluateAsync(response, chatConfiguration: null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(BLEUEvaluator.BLEUMetricName, metric.Name); + Assert.Equal(score, (double)metric!.Value!, 4); + } + + [Fact] + public async Task MultipleReferences() + { + string[] references = [ + "It is a guide to action that ensures that the military will forever heed Party commands", + "It is the guiding principle which guarantees the military forces always being under the command of the Party", + "It is the practical guide for the army always to heed the directions of the party", + ]; + string hypothesis = "It is a guide to action which ensures that the military always obeys the commands of the party"; + + var evaluator = new BLEUEvaluator(); + var context = new BLEUEvaluatorContext(references); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, hypothesis)); + var result = await evaluator.EvaluateAsync(response, chatConfiguration: null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(BLEUEvaluator.BLEUMetricName, metric.Name); + Assert.Equal(0.5046, (double)metric!.Value!, 4); + } + + [Fact] + public async Task ReturnsErrorDiagnosticIfEmptyResponse() + { + var evaluator = new BLEUEvaluator(); + var context = new BLEUEvaluatorContext("Reference text."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "")); + var result = await evaluator.EvaluateAsync(response, chatConfiguration: null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(BLEUEvaluator.BLEUMetricName, metric.Name); + Assert.NotNull(metric.Diagnostics); + Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs new file mode 100644 index 00000000000..23dfc6963a0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable AIEVAL001 +// AIEVAL001: Some of the below APIs are experimental and subject to change or removal in future updates. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class F1EvaluatorTests +{ + [Fact] + public async Task ReturnsPerfectScoreForIdenticalText() + { + var evaluator = new F1Evaluator(); + var context = new F1EvaluatorContext("The quick brown fox jumps over the lazy dog."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "The quick brown fox jumps over the lazy dog.")); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(F1Evaluator.F1MetricName, metric.Name); + Assert.Equal(1.0, (double)metric!.Value!, 4); + Assert.NotNull(metric.Interpretation); + Assert.Equal(EvaluationRating.Exceptional, metric.Interpretation.Rating); + Assert.False(metric.Interpretation.Failed); + } + + [Fact] + public async Task ReturnsLowScoreForCompletelyDifferentText() + { + var evaluator = new F1Evaluator(); + var context = new F1EvaluatorContext("The quick brown fox jumps over the lazy dog."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Completely unrelated sentence.")); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(F1Evaluator.F1MetricName, metric.Name); + Assert.Equal(0.1429, (double)metric!.Value!, 4); + Assert.NotNull(metric.Interpretation); + Assert.Equal(EvaluationRating.Unacceptable, metric.Interpretation.Rating); + Assert.True(metric.Interpretation.Failed); + } + + [Fact] + public async Task ReturnsErrorDiagnosticIfNoContext() + { + var evaluator = new F1Evaluator(); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Some text.")); + var result = await evaluator.EvaluateAsync(response, null, null); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(F1Evaluator.F1MetricName, metric.Name); + Assert.NotNull(metric.Diagnostics); + Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); + } + + [Theory] + [InlineData("the cat is on the mat", + "the the the the the the the", 0.30769)] + [InlineData("It is a guide to action that ensures that the military will forever heed Party commands", + "It is a guide to action which ensures that the military always obeys the commands of the party", 0.70589)] + [InlineData("It is the practical guide for the army always to heed the directions of the party", + "It is to insure the troops forever hearing the activity guidebook that party direct", 0.4000)] + public async Task SampleCases(string reference, string hypothesis, double score) + { + var evaluator = new F1Evaluator(); + var context = new F1EvaluatorContext(reference); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, hypothesis)); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(F1Evaluator.F1MetricName, metric.Name); + Assert.Equal(score, (double)metric!.Value!, 4); + } + + [Fact] + public async Task ReturnsErrorDiagnosticIfEmptyResponse() + { + var evaluator = new F1Evaluator(); + var context = new F1EvaluatorContext("Reference text."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "")); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(F1Evaluator.F1MetricName, metric.Name); + Assert.NotNull(metric.Diagnostics); + Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUAlgorithmTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUAlgorithmTests.cs new file mode 100644 index 00000000000..794b85c6595 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUAlgorithmTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.Extensions.AI.Evaluation.NLP.Common; +using Xunit; +using static Microsoft.Extensions.AI.Evaluation.NLP.Common.GLEUAlgorithm; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class GLEUAlgorithmTests +{ + [Fact] + public void TestZeroMatches() + { + string[][] references = ["The candidate has no alignment to any of the references".Split(' '),]; + string[] hypothesis = "John loves Mary".Split(' '); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.0, score, 4); + } + + [Fact] + public void TestFullMatches() + { + string[][] references = ["John loves Mary".Split(' '),]; + string[] hypothesis = "John loves Mary".Split(' '); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(1.0, score, 4); + } + + [Fact] + public void TestSentenceGLEUExampleA() + { + string[][] references = [ + "It is a guide to action that ensures that the military will forever heed Party commands".Split(' '), + "It is the guiding principle which guarantees the military forces always being under the command of the Party".Split(' '), + "It is the practical guide for the army always to heed the directions of the party".Split(' ') + ]; + string[] hypothesis = "It is a guide to action which ensures that the military always obeys the commands of the party".Split(' '); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.2778, score, 4); + } + + [Fact] + public void TestSentenceGLEUMilitaryExampleA() + { + string[][] references = [ + "It is a guide to action that ensures that the military will forever heed Party commands".Split(' '), + ]; + string[] hypothesis = "It is a guide to action which ensures that the military always obeys the commands of the party".Split(' '); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.43939, score, 4); + } + + [Fact] + public void TestSentenceGLEUMilitaryExampleB() + { + string[][] references = [ + "It is a guide to action that ensures that the military will forever heed Party commands".Split(' '), + ]; + string[] hypothesis = "It is to insure the troops forever hearing the activity guidebook that party direct".Split(' '); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.12069, score, 4); + } + + [Fact] + public void TestSentenceGLEUExampleB() + { + string[][] references = [ + "he was interested in world history because he read the book".Split(' '), + ]; + string[] hypothesis = "he read the book because he was interested in world history".Split(' '); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.7895, score, 4); + } + + [Fact] + public void TestSentenceGLEUExampleAWithWordTokenizer() + { + string[][] references = [ + SimpleWordTokenizer.WordTokenize("It is a guide to action that ensures that the military will forever heed Party commands").ToArray(), + SimpleWordTokenizer.WordTokenize("It is the guiding principle which guarantees the military forces always being under the command of the Party").ToArray(), + SimpleWordTokenizer.WordTokenize("It is the practical guide for the army always to heed the directions of the party").ToArray(), + ]; + string[] hypothesis = SimpleWordTokenizer.WordTokenize("It is a guide to action which ensures that the military always obeys the commands of the party").ToArray(); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.2980, score, 4); + + } + + [Fact] + public void TestSentenceGLEUExampleBWithWordTokenizer() + { + string[][] references = [ + SimpleWordTokenizer.WordTokenize("he was interested in world history because he read the book").ToArray(), + ]; + string[] hypothesis = SimpleWordTokenizer.WordTokenize("he read the book because he was interested in world history").ToArray(); + + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.7895, score, 4); + } + + [Fact] + public void TestSentenceGLEUCatExample() + { + string[][] references = [ + "the cat is on the mat".Split(' '), + ]; + string[] hypothesis = "the the the the the the the".Split(' '); + double score = SentenceGLEU(references, hypothesis); + Assert.Equal(0.0909, score, 4); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs new file mode 100644 index 00000000000..df007fcc638 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable AIEVAL001 +// AIEVAL001: Some of the below APIs are experimental and subject to change or removal in future updates. + +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class GLEUEvaluatorTests +{ + [Fact] + public async Task ReturnsPerfectScoreForIdenticalText() + { + var evaluator = new GLEUEvaluator(); + var context = new GLEUEvaluatorContext("The quick brown fox jumps over the lazy dog."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "The quick brown fox jumps over the lazy dog.")); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(GLEUEvaluator.GLEUMetricName, metric.Name); + Assert.Equal(1.0, (double)metric!.Value!, 4); + Assert.NotNull(metric.Interpretation); + Assert.Equal(EvaluationRating.Exceptional, metric.Interpretation.Rating); + Assert.False(metric.Interpretation.Failed); + } + + [Fact] + public async Task ReturnsLowScoreForCompletelyDifferentText() + { + var evaluator = new GLEUEvaluator(); + var context = new GLEUEvaluatorContext("The quick brown fox jumps over the lazy dog."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Completely unrelated sentence.")); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(GLEUEvaluator.GLEUMetricName, metric.Name); + Assert.Equal(0.02939, (double)metric!.Value!, 4); + Assert.NotNull(metric.Interpretation); + Assert.Equal(EvaluationRating.Unacceptable, metric.Interpretation.Rating); + Assert.True(metric.Interpretation.Failed); + } + + [Fact] + public async Task ReturnsErrorDiagnosticIfNoContext() + { + var evaluator = new GLEUEvaluator(); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Some text.")); + var result = await evaluator.EvaluateAsync(response, null, null); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(GLEUEvaluator.GLEUMetricName, metric.Name); + Assert.NotNull(metric.Diagnostics); + Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); + } + + [Theory] + [InlineData("the cat is on the mat", + "the the the the the the the", 0.0909)] + [InlineData("It is a guide to action that ensures that the military will forever heed Party commands", + "It is a guide to action which ensures that the military always obeys the commands of the party", 0.4545)] + [InlineData("It is the practical guide for the army always to heed the directions of the party", + "It is to insure the troops forever hearing the activity guidebook that party direct", 0.12069)] + public async Task SampleCases(string reference, string hypothesis, double score) + { + var evaluator = new GLEUEvaluator(); + var context = new GLEUEvaluatorContext(reference); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, hypothesis)); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(GLEUEvaluator.GLEUMetricName, metric.Name); + Assert.Equal(score, (double)metric!.Value!, 4); + } + + [Fact] + public async Task MultipleReferences() + { + string[] references = [ + "It is a guide to action that ensures that the military will forever heed Party commands", + "It is the guiding principle which guarantees the military forces always being under the command of the Party", + "It is the practical guide for the army always to heed the directions of the party", + ]; + string hypothesis = "It is a guide to action which ensures that the military always obeys the commands of the party"; + + var evaluator = new GLEUEvaluator(); + var context = new GLEUEvaluatorContext(references); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, hypothesis)); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(GLEUEvaluator.GLEUMetricName, metric.Name); + Assert.Equal(0.29799, (double)metric!.Value!, 4); + } + + [Fact] + public async Task ReturnsErrorDiagnosticIfEmptyResponse() + { + var evaluator = new GLEUEvaluator(); + var context = new GLEUEvaluatorContext("Reference text."); + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, "")); + var result = await evaluator.EvaluateAsync(response, null, [context]); + var metric = Assert.Single(result.Metrics.Values) as NumericMetric; + Assert.NotNull(metric); + Assert.Equal(GLEUEvaluator.GLEUMetricName, metric.Name); + Assert.NotNull(metric.Diagnostics); + Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/MatchCounterTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/MatchCounterTests.cs new file mode 100644 index 00000000000..71765ca3eff --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/MatchCounterTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.AI.Evaluation.NLP.Common; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class MatchCounterTests +{ + [Fact] + public void EmptyConstructor_InitializesEmptyCounter() + { + var counter = new MatchCounter(); + Assert.Empty(counter); + Assert.Equal(0, counter.Sum()); + } + + [Fact] + public void ConstructorWithItems_CountsCorrectly() + { + var counter = new MatchCounter(new[] { "a", "b", "a", "c", "b", "a" }); + var dict = counter.ToDictionary(kv => kv.Key, kv => kv.Value); + Assert.Equal(3, dict["a"]); + Assert.Equal(2, dict["b"]); + Assert.Equal(1, dict["c"]); + Assert.Equal(6, counter.Sum()); + } + + [Fact] + public void Add_AddsSingleItemCorrectly() + { + var counter = new MatchCounter(); + counter.Add(5); + counter.Add(5); + counter.Add(3); + var dict = counter.ToDictionary(kv => kv.Key, kv => kv.Value); + Assert.Equal(2, dict[5]); + Assert.Equal(1, dict[3]); + Assert.Equal(3, counter.Sum()); + } + + [Fact] + public void AddRange_AddsMultipleItemsCorrectly() + { + var counter = new MatchCounter(); + counter.AddRange("hello"); + var dict = counter.ToDictionary(kv => kv.Key, kv => kv.Value); + Assert.Equal(1, dict['h']); + Assert.Equal(1, dict['e']); + Assert.Equal(2, dict['l']); + Assert.Equal(1, dict['o']); + Assert.Equal(5, counter.Sum()); + } + + [Fact] + public void ToDebugString_FormatsCorrectly() + { + var counter = new MatchCounter(new[] { "x", "y", "x" }); + var str = counter.ToDebugString(); + Assert.Contains("x: 2", str); + Assert.Contains("y: 1", str); + } + + [Fact] + public void Intersect_ReturnsCorrectIntersection() + { + MatchCounter counter1 = new(new[] { 1, 2, 2, 3 }); + MatchCounter counter2 = new(new[] { 2, 2, 4 }); + + MatchCounter intersection = counter1.Intersect(counter2); + Dictionary dict = intersection.ToDictionary(kv => kv.Key, kv => kv.Value); + Assert.Equal(2, dict[2]); + Assert.Equal(2, intersection.Sum()); + + intersection = counter2.Intersect(counter1); + dict = intersection.ToDictionary(kv => kv.Key, kv => kv.Value); + Assert.Equal(2, dict[2]); + Assert.Equal(2, intersection.Sum()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/Microsoft.Extensions.AI.Evaluation.NLP.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/Microsoft.Extensions.AI.Evaluation.NLP.Tests.csproj new file mode 100644 index 00000000000..6b485136520 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/Microsoft.Extensions.AI.Evaluation.NLP.Tests.csproj @@ -0,0 +1,13 @@ + + + + Microsoft.Extensions.AI.Evaluation.NLP.Tests + Unit tests for Microsoft.Extensions.AI.Evaluation.NLP. + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs new file mode 100644 index 00000000000..f6401cabe08 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using Microsoft.Extensions.AI.Evaluation.NLP.Common; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class NGramTests +{ + [Fact] + public void Constructor_ValuesAndLength() + { + var ngram = new NGram(1, 2, 3); + Assert.Equal(new[] { 1, 2, 3 }, ngram.Values); + Assert.Equal(3, ngram.Length); + } + + [Fact] + public void Constructor_ThrowsOnEmpty() + { + Assert.Throws("values", () => new NGram(Array.Empty())); + } + + [Fact] + public void Equals_And_HashCode_WorkCorrectly() + { + var a = new NGram(1, 2, 3); + var b = new NGram(1, 2, 3); + var c = new NGram(3, 2, 1); + Assert.True(a.Equals(b)); + Assert.True(a.Equals((object)b)); + Assert.False(a.Equals(c)); + Assert.NotEqual(a.GetHashCode(), c.GetHashCode()); + } + + [Fact] + public void Enumerator_And_IEnumerable() + { + var ngram = new NGram('a', 'b', 'c'); + var list = ngram.ToList(); + Assert.Equal(new[] { 'a', 'b', 'c' }, list); + } + + [Fact] + public void ToDebugString_FormatsCorrectly() + { + var ngram = new NGram("x", "y"); + Assert.Equal("[x,y]", ngram.ToDebugString()); + } + + [Fact] + public void NGramBuilder_Create_Works() + { + NGram ngram = [1, 2]; + Assert.Equal(new NGram(1, 2), ngram); + } + + [Fact] + public void CreateNGrams() + { + Assert.Throws("n", () => new int[0].CreateNGrams(-1).ToList()); + + ReadOnlySpan data = [1, 2, 3]; + + var nGram = data.CreateNGrams(1); + Assert.Equal([[1], [2], [3]], nGram); + + nGram = data.CreateNGrams(2); + Assert.Equal([[1, 2], [2, 3]], nGram); + + nGram = data.CreateNGrams(3); + Assert.Equal([[1, 2, 3]], nGram); + + nGram = data.CreateNGrams(4); + Assert.Equal([], nGram); + } + + [Fact] + public void CreateAllNGrams() + { + Assert.Throws("minN", () => new int[0].CreateAllNGrams(-1).ToList()); + + Assert.Throws("minN", () => new int[0].CreateAllNGrams(0).ToList()); + + Assert.Throws("maxN", () => new int[0].CreateAllNGrams(1, 0).ToList()); + + ReadOnlySpan arr = [1, 2, 3]; + + var nGram = arr.CreateAllNGrams(1).ToList(); + Assert.Equal([[1], [1, 2], [1, 2, 3], [2], [2, 3], [3]], nGram); + + nGram = arr.CreateAllNGrams(2).ToList(); + Assert.Equal([[1, 2], [1, 2, 3], [2, 3]], nGram); + + nGram = arr.CreateAllNGrams(3).ToList(); + Assert.Equal([[1, 2, 3]], nGram); + + nGram = arr.CreateAllNGrams(3, 5).ToList(); + Assert.Equal([[1, 2, 3]], nGram); + + nGram = arr.CreateAllNGrams(1, 2).ToList(); + Assert.Equal([[1], [1, 2], [2], [2, 3], [3]], nGram); + + nGram = arr.CreateAllNGrams(1, 1).ToList(); + Assert.Equal([[1], [2], [3]], nGram); + + nGram = arr.CreateAllNGrams(4).ToList(); + Assert.Equal([], nGram); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/RationalNumberTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/RationalNumberTests.cs new file mode 100644 index 00000000000..8776b97811f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/RationalNumberTests.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI.Evaluation.NLP.Common; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class RationalNumberTests +{ + [Fact] + public void Constructor_StoresNumeratorAndDenominator() + { + var r = new RationalNumber(3, 4); + Assert.Equal(3, r.Numerator); + Assert.Equal(4, r.Denominator); + } + + [Fact] + public void Constructor_ThrowsOnZeroDenominator() + { + Assert.Throws(() => new RationalNumber(1, 0)); + } + + [Theory] + [InlineData(1, 2, 0.5)] + [InlineData(-3, 4, -0.75)] + [InlineData(0, 5, 0.0)] + public void ToDouble_ReturnsExpected(int num, int denom, double expected) + { + var r = new RationalNumber(num, denom); + Assert.Equal(expected, r.ToDouble(), 6); + } + + [Fact] + public void ToDebugString_FormatsCorrectly() + { + var r = new RationalNumber(7, 9); + Assert.Equal("7/9", r.ToDebugString()); + } + + [Fact] + public void Equals_And_HashCode_WorkCorrectly() + { + var a = new RationalNumber(2, 3); + var b = new RationalNumber(2, 3); + var c = new RationalNumber(3, 2); + Assert.True(a.Equals(b)); + Assert.True(a.Equals((object)b)); + Assert.False(a.Equals(c)); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a.GetHashCode(), c.GetHashCode()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs new file mode 100644 index 00000000000..2906bf1ae5e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.AI.Evaluation.NLP.Common; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; + +public class SimpleTokenizerTests +{ + [Theory] + [InlineData(" $41.23 ", new[] { "$", "41.23" })] + [InlineData("word", new[] { "WORD" })] + [InlineData("word1 word2", new[] { "WORD1", "WORD2" })] + [InlineData("word1,word2", new[] { "WORD1", ",", "WORD2" })] + [InlineData("word1.word2", new[] { "WORD1", ".", "WORD2" })] + [InlineData("word1!word2?", new[] { "WORD1", "!", "WORD2", "?" })] + [InlineData("word1-word2", new[] { "WORD1", "-", "WORD2" })] + [InlineData("word1 - word2", new[] { "WORD1", "-", "WORD2" })] + [InlineData("word1-\n word2", new[] { "WORD1", "WORD2" })] + [InlineData("word1-", new[] { "WORD1", "-" })] + [InlineData("word1&", new[] { "WORD1", "&" })] + [InlineData("word1-\r\n word2", new[] { "WORD1", "WORD2" })] + [InlineData("word1-\r\nword2", new[] { "WORD1WORD2" })] + [InlineData("word1-\nword2", new[] { "WORD1WORD2" })] + [InlineData("word1\nword2", new[] { "WORD1", "WORD2" })] + [InlineData("word1 \n word2", new[] { "WORD1", "WORD2" })] + [InlineData("word1\r\nword2", new[] { "WORD1", "WORD2" })] + [InlineData("word1 \r\n word2", new[] { "WORD1", "WORD2" })] + [InlineData("word1\tword2", new[] { "WORD1", "WORD2" })] + [InlineData("It is a guide to action that ensures that the military will forever heed Party commands.", + new[] { "IT", "IS", "A", "GUIDE", "TO", "ACTION", "THAT", "ENSURES", "THAT", "THE", "MILITARY", "WILL", "FOREVER", "HEED", "PARTY", "COMMANDS", "." })] + [InlineData("Good muffins cost $3.88 (roughly 3,36 euros)\nin New York. Please buy me\ntwo of them.\nThanks.", + new[] { "GOOD", "MUFFINS", "COST", "$", "3.88", "(", "ROUGHLY", "3,36", "EUROS", ")", "IN", "NEW", "YORK", ".", "PLEASE", "BUY", "ME", "TWO", "OF", "THEM", ".", "THANKS", "." })] + [InlineData("", new string[0])] + [InlineData(" This is a test.", new[] { "THIS", "IS", "A", "TEST", "." })] + [InlineData("Hello, world! How's it going?", new[] { "HELLO", ",", "WORLD", "!", "HOW", "'", "S", "IT", "GOING", "?" })] + [InlineData(""Quotes" and & symbols < > '", new[] { "\"", "QUOTES", "\"", "AND", "&", "SYMBOLS", "<", ">", "'" })] + [InlineData("-\nThis is a test.", new[] { "THIS", "IS", "A", "TEST", "." })] + public void Tokenize_Cases(string input, string[] expected) + { + var result = SimpleWordTokenizer.WordTokenize(input); + Assert.Equal(expected, result); + } + + [Fact] + public void HandlesMultipleSpacesAndEmptyEntries() + { + var input = " word1 word2 word3 "; + var expected = new[] { "WORD1", "WORD2", "WORD3" }; + var result = SimpleWordTokenizer.WordTokenize(input); + Assert.Equal(expected, result); + } + + [Fact] + public void HandlesUnicodeSymbolsAndPunctuation() + { + var input = "word1 © word2 ™ word3 — word4"; + var expected = new[] { "WORD1", "©", "WORD2", "™", "WORD3", "—", "WORD4" }; + var result = SimpleWordTokenizer.WordTokenize(input); + Assert.Equal(expected, result); + } + + [Fact] + public void HandlesHtmlEntities() + { + var input = ""Hello" & Goodbye <test> '"; + var expected = new[] { "\"", "HELLO", "\"", "&", "GOODBYE", "<", "TEST", ">", "'" }; + var result = SimpleWordTokenizer.WordTokenize(input); + Assert.Equal(expected, result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ChatTurnDetailsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ChatTurnDetailsTests.cs new file mode 100644 index 00000000000..b97334a1057 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ChatTurnDetailsTests.cs @@ -0,0 +1,240 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Reporting.Tests; + +public class ChatTurnDetailsTests +{ + [Fact] + public void DeserializeWithLatencyOnly() + { + string json = + """ + { + "latency": 5 + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(5), details!.Latency); + Assert.Null(details.Model); + Assert.Null(details.ModelProvider); + Assert.Null(details.Usage); + Assert.Null(details.CacheKey); + Assert.Null(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Null(deserializedDetails.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Null(deserializedDetails.Usage); + Assert.Null(deserializedDetails.CacheKey); + Assert.Null(deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithLatencyAndModel() + { + string json = + """ + { + "latency": 5, + "model": "gpt-4" + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(5), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Null(details.ModelProvider); + Assert.Null(details.Usage); + Assert.Null(details.CacheKey); + Assert.Null(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails!.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Null(deserializedDetails.Usage); + Assert.Null(deserializedDetails.CacheKey); + Assert.Null(deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithLatencyModelAndModelProvider() + { + string json = + """ + { + "latency": 5, + "model": "gpt-4", + "modelProvider": "azure.openai" + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(5), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Equal("azure.openai", details.ModelProvider); + Assert.Null(details.Usage); + Assert.Null(details.CacheKey); + Assert.Null(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails!.Model); + Assert.Equal(details.ModelProvider, deserializedDetails!.ModelProvider); + Assert.Null(deserializedDetails.Usage); + Assert.Null(deserializedDetails.CacheKey); + Assert.Null(deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithoutModelAndModelProvider() + { + string json = + """ + { + "latency": 1, + "usage": { "inputTokenCount": 10, "outputTokenCount": 20, "totalTokenCount": 30 }, + "cacheKey": "cache-key", + "cacheHit": true + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(1), details!.Latency); + Assert.Null(details.Model); + Assert.Null(details.ModelProvider); + Assert.NotNull(details.Usage); + Assert.Equal(10, details.Usage!.InputTokenCount); + Assert.Equal(20, details.Usage.OutputTokenCount); + Assert.Equal(30, details.Usage.TotalTokenCount); + Assert.Equal("cache-key", details.CacheKey); + Assert.True(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Null(deserializedDetails.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Equal(details.Usage!.InputTokenCount, deserializedDetails.Usage!.InputTokenCount); + Assert.Equal(details.Usage.OutputTokenCount, deserializedDetails.Usage.OutputTokenCount); + Assert.Equal(details.Usage.TotalTokenCount, deserializedDetails.Usage.TotalTokenCount); + Assert.Equal(details.CacheKey, deserializedDetails.CacheKey); + Assert.Equal(details.CacheHit, deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithoutModelProvider() + { + string json = + """ + { + "latency": 1, + "model": "gpt-4", + "usage": { "inputTokenCount": 10, "outputTokenCount": 20, "totalTokenCount": 30 }, + "cacheKey": "cache-key", + "cacheHit": true + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(1), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Null(details.ModelProvider); + Assert.NotNull(details.Usage); + Assert.Equal(10, details.Usage!.InputTokenCount); + Assert.Equal(20, details.Usage.OutputTokenCount); + Assert.Equal(30, details.Usage.TotalTokenCount); + Assert.Equal("cache-key", details.CacheKey); + Assert.True(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Equal(details.Usage!.InputTokenCount, deserializedDetails.Usage!.InputTokenCount); + Assert.Equal(details.Usage.OutputTokenCount, deserializedDetails.Usage.OutputTokenCount); + Assert.Equal(details.Usage.TotalTokenCount, deserializedDetails.Usage.TotalTokenCount); + Assert.Equal(details.CacheKey, deserializedDetails.CacheKey); + Assert.Equal(details.CacheHit, deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithModelProvider() + { + string json = + """ + { + "latency": 2, + "model": "gpt-4", + "modelProvider": "azure.openai", + "usage": { "inputTokenCount": 5, "outputTokenCount": 7, "totalTokenCount": 12 }, + "cacheKey": "cache-key-2", + "cacheHit": false + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(2), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Equal("azure.openai", details.ModelProvider); + Assert.NotNull(details.Usage); + Assert.Equal(5, details.Usage!.InputTokenCount); + Assert.Equal(7, details.Usage.OutputTokenCount); + Assert.Equal(12, details.Usage.TotalTokenCount); + Assert.Equal("cache-key-2", details.CacheKey); + Assert.False(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails.Model); + Assert.Equal(details.ModelProvider, deserializedDetails.ModelProvider); + Assert.Equal(details.Usage!.InputTokenCount, deserializedDetails.Usage!.InputTokenCount); + Assert.Equal(details.Usage.OutputTokenCount, deserializedDetails.Usage.OutputTokenCount); + Assert.Equal(details.Usage.TotalTokenCount, deserializedDetails.Usage.TotalTokenCount); + Assert.Equal(details.CacheKey, deserializedDetails.CacheKey); + Assert.Equal(details.CacheHit, deserializedDetails.CacheHit); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs index b69014e631b..50793dfdb66 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; @@ -45,10 +44,12 @@ public async Task AddUncachedEntry() Assert.Null(cache.Get(_keyB)); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); } [ConditionalFact] @@ -63,10 +64,12 @@ public async Task RemoveCachedEntry() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); await cache.RemoveAsync(_keyA); Assert.Null(await cache.GetAsync(_keyA)); @@ -90,10 +93,12 @@ public async Task CacheEntryExpiration() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); now = DateTime.UtcNow + Defaults.DefaultTimeToLiveForCacheEntries; @@ -138,10 +143,12 @@ public async Task DeleteExpiredEntries() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); now = DateTime.UtcNow + Defaults.DefaultTimeToLiveForCacheEntries; @@ -168,10 +175,12 @@ public async Task ResetCache() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); await provider.ResetAsync(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs index f44b7187c2c..637678f6be2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs @@ -64,6 +64,7 @@ public void SerializeScenarioRunResult() new ChatTurnDetails( latency: TimeSpan.FromSeconds(1), model: "gpt-4o", + modelProvider: "openai", usage: new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }, cacheKey: Guid.NewGuid().ToString(), cacheHit: true); @@ -155,6 +156,7 @@ public void SerializeDatasetCompact() new ChatTurnDetails( latency: TimeSpan.FromSeconds(1), model: "gpt-4o", + modelProvider: "openai", usage: new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }, cacheKey: Guid.NewGuid().ToString(), cacheHit: true); @@ -389,9 +391,11 @@ private class ChatTurnDetailsComparer : IEqualityComparer { public static ChatTurnDetailsComparer Instance { get; } = new ChatTurnDetailsComparer(); -#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S1067 // Expressions should not be too complex. public bool Equals(ChatTurnDetails? x, ChatTurnDetails? y) => x?.Latency == y?.Latency && + x?.Model == y?.Model && + x?.ModelProvider == y?.ModelProvider && x?.Usage?.InputTokenCount == y?.Usage?.InputTokenCount && x?.Usage?.OutputTokenCount == y?.Usage?.OutputTokenCount && x?.Usage?.TotalTokenCount == y?.Usage?.TotalTokenCount && diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs index 86a319c059d..bebc0d5affd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; @@ -24,17 +23,14 @@ public class SerializationChainingTests messages: [], modelResponse: new ChatResponse { - Messages = new List - { + Messages = + [ new ChatMessage { Role = ChatRole.User, - Contents = new List - { - new TextContent("A user message"), - }, + Contents = [new TextContent("A user message")] }, - }, + ], AdditionalProperties = new AdditionalPropertiesDictionary { { "model", "gpt-7" }, diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs index 366e4549748..1118d7f6624 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs @@ -27,16 +27,7 @@ public Settings(IConfiguration config) #pragma warning restore CA2208 } - private static Settings? _currentSettings; - - public static Settings Current - { - get - { - _currentSettings ??= GetCurrentSettings(); - return _currentSettings; - } - } + public static Settings Current => field ??= GetCurrentSettings(); private static Settings GetCurrentSettings() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInMetricUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInMetricUtilitiesTests.cs new file mode 100644 index 00000000000..555f72c1f15 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInMetricUtilitiesTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias Evaluation; +using Evaluation::Microsoft.Extensions.AI.Evaluation; +using Evaluation::Microsoft.Extensions.AI.Evaluation.Utilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class BuiltInMetricUtilitiesTests +{ + [Fact] + public void MarkAsBuiltInAddsMetadata() + { + var metric = new NumericMetric("name"); + metric.MarkAsBuiltIn(); + Assert.True(metric.IsBuiltIn()); + } + + [Fact] + public void IsBuiltInReturnsFalseIfMetadataIsMissing() + { + var metric = new NumericMetric("name"); + Assert.False(metric.IsBuiltIn()); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("True")] + public void MetadataValueOfTrueIsCaseInsensitive(string value) + { + var metric = new BooleanMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, value); + Assert.True(metric.IsBuiltIn()); + } + + [Theory] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("False")] + public void MetadataValueOfFalseIsCaseInsensitive(string value) + { + var metric = new StringMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, value); + Assert.False(metric.IsBuiltIn()); + } + + [Fact] + public void UnrecognizedMetadataValueIsTreatedAsFalse() + { + var metric = new NumericMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, "unrecognized"); + Assert.False(metric.IsBuiltIn()); + } + + [Fact] + public void EmptyMetadataValueIsTreatedAsFalse() + { + var metric = new NumericMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, string.Empty); + Assert.False(metric.IsBuiltIn()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/IntentResolutionRatingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/IntentResolutionRatingTests.cs new file mode 100644 index 00000000000..da839387e20 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/IntentResolutionRatingTests.cs @@ -0,0 +1,324 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Extensions.AI.Evaluation.Quality; +using Microsoft.Extensions.AI.Evaluation.Quality.JsonSerialization; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class IntentResolutionRatingTests +{ + [Fact] + public void JsonIsValid() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 5 + } + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.Equal(5, rating.ResolutionScore); + Assert.Equal("The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", rating.Explanation); + Assert.Equal("provide a comprehensive chocolate cake recipe", rating.AgentPerceivedIntent); + Assert.Equal("bake a chocolate cake", rating.ActualUserIntent); + Assert.True(rating.ConversationHasIntent); + Assert.True(rating.CorrectIntentDetected); + Assert.True(rating.IntentResolved); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonIsSurroundedWithMarkdownSyntax() + { + string json = + """ + + ``` + { + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 5, + } + ``` + + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.Equal(5, rating.ResolutionScore); + Assert.Equal("The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", rating.Explanation); + Assert.Equal("provide a comprehensive chocolate cake recipe", rating.AgentPerceivedIntent); + Assert.Equal("bake a chocolate cake", rating.ActualUserIntent); + Assert.True(rating.ConversationHasIntent); + Assert.True(rating.CorrectIntentDetected); + Assert.True(rating.IntentResolved); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonIsSurroundedWithMarkdownSyntaxWithJsonPrefix() + { + string json = + """ + + ```json + { + "resolution_score": 5, + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true + } + ``` + + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.Equal(5, rating.ResolutionScore); + Assert.Equal("The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", rating.Explanation); + Assert.Equal("provide a comprehensive chocolate cake recipe", rating.AgentPerceivedIntent); + Assert.Equal("bake a chocolate cake", rating.ActualUserIntent); + Assert.True(rating.ConversationHasIntent); + Assert.True(rating.CorrectIntentDetected); + Assert.True(rating.IntentResolved); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonCanBeRoundTripped() + { + IntentResolutionRating rating = + new IntentResolutionRating( + resolutionScore: 1, + explanation: "explanation", + agentPerceivedIntent: "perceived intent", + actualUserIntent: "actual intent", + conversationHasIntent: false, + correctIntentDetected: true, + intentResolved: true); + + string json = JsonSerializer.Serialize(rating, SerializerContext.Default.IntentResolutionRating); + IntentResolutionRating deserialized = IntentResolutionRating.FromJson(json); + + Assert.Equal(rating.ResolutionScore, deserialized.ResolutionScore); + Assert.Equal(rating.Explanation, deserialized.Explanation); + Assert.Equal(rating.AgentPerceivedIntent, deserialized.AgentPerceivedIntent); + Assert.Equal(rating.ActualUserIntent, deserialized.ActualUserIntent); + Assert.Equal(rating.ConversationHasIntent, deserialized.ConversationHasIntent); + Assert.Equal(rating.CorrectIntentDetected, deserialized.CorrectIntentDetected); + Assert.Equal(rating.IntentResolved, deserialized.IntentResolved); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void InconclusiveJsonCanBeRoundTripped() + { + IntentResolutionRating rating = IntentResolutionRating.Inconclusive; + + string json = JsonSerializer.Serialize(rating, SerializerContext.Default.IntentResolutionRating); + IntentResolutionRating deserialized = IntentResolutionRating.FromJson(json); + + Assert.Equal(rating.ResolutionScore, deserialized.ResolutionScore); + Assert.Equal(rating.Explanation, deserialized.Explanation); + Assert.Equal(rating.AgentPerceivedIntent, deserialized.AgentPerceivedIntent); + Assert.Equal(rating.ActualUserIntent, deserialized.ActualUserIntent); + Assert.Equal(rating.ConversationHasIntent, deserialized.ConversationHasIntent); + Assert.Equal(rating.CorrectIntentDetected, deserialized.CorrectIntentDetected); + Assert.Equal(rating.IntentResolved, deserialized.IntentResolved); + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithNegativeScoreIsInconclusive() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": -1 + } + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithZeroScoreIsInconclusive() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 0 + } + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithExcessivelyHighScoreIsInconclusive() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 200 + } + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithAdditionalHallucinatedPropertyIsProcessedCorrectly() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "hallucinated_property": "Some hallucinated text.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 5, + } + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.Equal(5, rating.ResolutionScore); + Assert.Equal("The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", rating.Explanation); + Assert.Equal("provide a comprehensive chocolate cake recipe", rating.AgentPerceivedIntent); + Assert.Equal("bake a chocolate cake", rating.ActualUserIntent); + Assert.True(rating.ConversationHasIntent); + Assert.True(rating.CorrectIntentDetected); + Assert.True(rating.IntentResolved); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonWithDuplicatePropertyUsesLastValue() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "explanation": "Duplicate explanation.", + "conversation_has_intent": true, + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 5, + } + """; + + IntentResolutionRating rating = IntentResolutionRating.FromJson(json); + + Assert.Equal(5, rating.ResolutionScore); + Assert.Equal("Duplicate explanation.", rating.Explanation); + Assert.Equal("provide a comprehensive chocolate cake recipe", rating.AgentPerceivedIntent); + Assert.Equal("bake a chocolate cake", rating.ActualUserIntent); + Assert.True(rating.ConversationHasIntent); + Assert.True(rating.CorrectIntentDetected); + Assert.True(rating.IntentResolved); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonWithSemicolonsInsteadOfCommasThrowsException() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake."; + "conversation_has_intent": true; + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe"; + "actual_user_intent": "bake a chocolate cake"; + "correct_intent_detected": true; + "intent_resolved": true; + "resolution_score": 5 + } + """; + + Assert.Throws(() => IntentResolutionRating.FromJson(json)); + } + + [Fact] + public void JsonWithMissingPropertiesThrowsException() + { + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "intent_resolved": true, + "resolution_score": 5 + } + """; + + Assert.Throws(() => IntentResolutionRating.FromJson(json)); + } + + [Fact] + public void JsonWithIncorrectPropertyValueTypeThrowsException() + { + // Incorrect property value (string instead of boolean for conversation_has_intent). + string json = + """ + { + "explanation": "The response delivers a complete and precise recipe, fully addressing the user's query about baking a chocolate cake.", + "conversation_has_intent": "A string value", + "agent_perceived_intent": "provide a comprehensive chocolate cake recipe", + "actual_user_intent": "bake a chocolate cake", + "correct_intent_detected": true, + "intent_resolved": true, + "resolution_score": 5, + } + """; + + Assert.Throws(() => IntentResolutionRating.FromJson(json)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj index d668fb94d14..b4c415ac358 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/ModelInfoTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/ModelInfoTests.cs new file mode 100644 index 00000000000..31d7ceb80c6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/ModelInfoTests.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias Evaluation; + +using System; +using Evaluation::Microsoft.Extensions.AI.Evaluation.Utilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class ModelInfoTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("openai")] + public void GetModelProvider_NoProviderUriAndModelSpecified_ReturnsProviderNameOnly(string? providerName) + { + var metadata = new ChatClientMetadata(providerName, providerUri: null); + + string? result = ModelInfo.GetModelProvider(model: null, metadata); + + Assert.Equal(providerName, result); + } + + [Theory] + [InlineData(null, "https://localhost:11434", " (local)")] + [InlineData(null, "https://test.services.ai.azure.com/", " (azure.ai.foundry)")] + [InlineData(null, "https://myapp.openai.azure.com/v1/chat", " (azure.openai)")] + [InlineData(null, "https://myapp.ml.azure.com/", " (azure.ml)")] + [InlineData(null, "https://models.inference.ai.azure.com/v1", " (github.models)")] + [InlineData(null, "https://models.github.ai", " (github.models)")] + [InlineData("", "https://custom.azure.com", " (azure)")] + [InlineData(" ", "https://models.github.com/openai", " (github)")] + [InlineData("\t", "https://services.microsoft.com/models", "\t (microsoft)")] + [InlineData(null, "https://localhost.com:11434/models", null)] + [InlineData(null, "https://github.com/models", null)] + [InlineData("", "https://azure.com/models", "")] + [InlineData("\t", "https://microsoft.com/models", "\t")] + [InlineData(null, "https://example.com/models", null)] + public void GetModelProvider_NoProviderNameAndModelSpecified_ReturnsHostMonikerOnly( + string? providerName, + string providerUri, + string? expected) + { + Uri? uri = providerUri != null ? new Uri(providerUri) : null; + var metadata = new ChatClientMetadata(providerName, providerUri: uri); + + string? result = ModelInfo.GetModelProvider(model: null, metadata); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("\t", null)] + [InlineData("unknown", null)] + [InlineData("azure.ai.foundry.evaluation", "azure.ai.foundry (azure.ai.foundry)")] + [InlineData(" azure.ai.foundry.evaluation", null)] + [InlineData("azure.ai.foundry.evaluation\t", null)] + [InlineData("azure.ai.foundry . evaluation", null)] + [InlineData("(azure.ai.foundry.evaluation)", null)] + [InlineData("azure.AI.FOUNDRY.evaluation", null)] + [InlineData("ai.foundry.evaluation", null)] + public void GetModelProvider_NoMetadataSpecified_ReturnsExpectedFormat( + string? model, + string? expected) + { + string? result = ModelInfo.GetModelProvider(model, metadata: null); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null, null, null, null)] + [InlineData("azure.ai.foundry.evaluation", null, null, "azure.ai.foundry (azure.ai.foundry)")] + [InlineData(" azure.ai.foundry.evaluation", null, null, null)] + [InlineData("azure.ai.foundry.evaluation\t", null, null, null)] + [InlineData("(azure.ai.foundry.evaluation)", null, null, null)] + [InlineData("azure.ai.foundry.evaluation", null, "https://myapp.openai.azure.com/", "azure.ai.foundry (azure.ai.foundry)")] + [InlineData("azure.ai.foundry.evaluation", "openai", null, "azure.ai.foundry (azure.ai.foundry)")] + [InlineData("azure.ai.foundry.evaluation", "azure", "https://services.ai.azure.com/", "azure.ai.foundry (azure.ai.foundry)")] + [InlineData("azure.AI.FOUNDRY.evaluation", "custom", null, "custom")] + [InlineData("ai.foundry.evaluation", "custom", "https://myapp.openai.azure.com/", "custom (azure.openai)")] + [InlineData(null, "custom", "https://services.ai.azure.com/", "custom (azure.ai.foundry)")] + [InlineData("", null, "https://myapp.openai.azure.com/", " (azure.openai)")] + [InlineData(" ", null, "https://myapp.openai.azure.com/v1", " (azure.openai)")] + [InlineData("\t", null, "https://myapp.OpenAI.Azure.com/v1/chat", " (azure.openai)")] + [InlineData("unknown", null, "https://myapp.OpenAI.Azure.com/v1/chat", " (azure.openai)")] + public void GetModelProvider_ModelSpecified_ReturnsExpectedFormat( + string? model, + string? providerName, + string? providerUri, + string? expected) + { + Uri? uri = providerUri != null ? new Uri(providerUri) : null; + var metadata = new ChatClientMetadata(providerName, providerUri: uri, defaultModelId: "ignored"); + + string? result = ModelInfo.GetModelProvider(model, metadata); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("llama", "https://localhost:11434", "llama (local)")] + [InlineData("llama", "https://LocalHost", "llama (local)")] + [InlineData("llama", "https://localhost:1234/models/llama", "llama (local)")] + [InlineData("openai", "https://services.ai.azure.com/", "openai (azure.ai.foundry)")] + [InlineData("azure", "https://test.services.ai.azure.com/endpoint", "azure (azure.ai.foundry)")] + [InlineData("openai", "https://myapp.openai.azure.com/", "openai (azure.openai)")] + [InlineData("azure", "https://test.openai.azure.com/v1/chat", "azure (azure.openai)")] + [InlineData("ml", "https://myapp.ml.azure.com/", "ml (azure.ml)")] + [InlineData("azure", "https://myapp.inference.ml.azure.com/v1", "azure (azure.ml)")] + [InlineData("github", "https://models.github.ai/", "github (github.models)")] + [InlineData("openai", "https://models.github.ai/v1", "openai (github.models)")] + [InlineData("github", "https://models.inference.ai.azure.com/", "github (github.models)")] + [InlineData("openai", "https://models.inference.ai.azure.com/v1", "openai (github.models)")] + [InlineData("custom", "https://test.azure.com/", "custom (azure)")] + [InlineData("provider", "https://api.github.com/", "provider (github)")] + [InlineData("service", "https://api.microsoft.com/", "service (microsoft)")] + [InlineData("openai", "https://api.openai.com/", "openai")] + [InlineData("anthropic.claude", "https://api.anthropic.com/", "anthropic.claude")] + [InlineData("custom", "https://example.com/", "custom")] + [InlineData("custom", "https://localhost.com:11434/", "custom")] + [InlineData("custom", "https://host:11434", "custom")] + [InlineData("custom", "https://127.0.0.0:11434", "custom")] + [InlineData("provider", "https://unknown-host.com/", "provider")] + [InlineData("OPENAI provider", "https://SERVICES.AI.AZURE.COM/", "OPENAI provider (azure.ai.foundry)")] + [InlineData("Azure-model-provider", "https://Test.OpenAI.Azure.Com/", "Azure-model-provider (azure.openai)")] + public void GetModelProvider_ReturnsProviderWithHostMoniker( + string providerName, + string providerUri, + string expected) + { + var metadata = new ChatClientMetadata(providerName, new Uri(providerUri)); + + string? result = ModelInfo.GetModelProvider(model: "some-model", metadata); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("https://myapp.openai.azure.services.ai.azure.com/", "azure.ai.foundry")] + [InlineData("https://myapp.services.ai.azure.openai.azure.com/", "azure.ai.foundry")] + [InlineData("https://myapp.microsoft.services.ai.azure.com/", "azure.ai.foundry")] + [InlineData("https://inference.openai.azure.ml.azure.com/", "azure.openai")] + [InlineData("https://inference.ml.azure.openai.azure.com/", "azure.openai")] + [InlineData("https://myapp.azure.models.github.ai/", "github.models")] + [InlineData("https://test.azure.microsoft.com/", "azure")] + [InlineData("https://test.microsoft.github.com/", "github")] + public void GetModelProvider_MultipleHostPatternMatches_ReturnsExpectedHostMoniker( + string providerUri, + string expectedHostMoniker) + { + var metadata = new ChatClientMetadata(providerName: "some-provider", new Uri(providerUri)); + + string? result = ModelInfo.GetModelProvider(model: "some-model", metadata); + + Assert.Equal($"some-provider ({expectedHostMoniker})", result); + } + + [Theory] + [InlineData(null, false, false)] + [InlineData("", false, false)] + [InlineData(" ", false, false)] + [InlineData("\t", false, false)] + [InlineData("llama (local)", false, true)] + [InlineData("openai (azure.ai.foundry)", true, false)] + [InlineData("azure (azure.openai)", true, false)] + [InlineData("azure (azure.ml)", true, false)] + [InlineData("github (github.models)", true, false)] + [InlineData("(azure.ai.foundry)", true, false)] + [InlineData("provider (azure)", true, false)] + [InlineData("service (github)", true, false)] + [InlineData("custom (microsoft)", true, false)] + [InlineData(" (azure.ai.foundry)", true, false)] + [InlineData(" (azure.openai)", true, false)] + [InlineData("\t(github.models)", true, false)] + [InlineData("\t (local)", false, true)] + [InlineData("(azure) ", false, false)] + [InlineData("(github) ", false, false)] + [InlineData("(microsoft)\t", false, false)] + [InlineData(" (local)\t", false, false)] + [InlineData("( azure.ml)", false, false)] + [InlineData("(local\t)", false, false)] + [InlineData("(azure .ml)", false, false)] + [InlineData("(azure. ml)", false, false)] + [InlineData("(LOCAL)", false, false)] + [InlineData("ml [azure.ml]", false, false)] + [InlineData("{azure.ml}", false, false)] + [InlineData("openai (AZURE.OPENAI)", false, false)] + [InlineData("prefix provider (azure.openai)", true, false)] + [InlineData("local", false, false)] + [InlineData("openai", false, false)] + [InlineData("azure.ai.foundry", false, false)] + [InlineData("azure.openai", false, false)] + [InlineData("azure.ml", false, false)] + [InlineData("github.models", false, false)] + [InlineData("azure", false, false)] + [InlineData("github", false, false)] + [InlineData("microsoft", false, false)] + [InlineData("(custom-host)", false, false)] + [InlineData("provider (unknown)", false, false)] + [InlineData("provider (", false, false)] + [InlineData("provider )", false, false)] + [InlineData("provider (azure.ai.foundry) extra", false, false)] + [InlineData("(microsoft)\tcustom (other)", false, false)] + [InlineData("provider (azure.ai.foundry", false, false)] + [InlineData("provider azure.ai.foundry)", false, false)] + public void ModelHostMonikerClassificationWorks( + string? modelProvider, + bool expectedIsModelHostWellKnown, + bool expectedIsModelHostedLocally) + { + bool isModelHostWellKnown = ModelInfo.IsModelHostWellKnown(modelProvider); + Assert.Equal(expectedIsModelHostWellKnown, isModelHostWellKnown); + + bool isModelHostedLocally = ModelInfo.IsModelHostedLocally(modelProvider); + Assert.Equal(expectedIsModelHostedLocally, isModelHostedLocally); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/RelevanceTruthAndCompletenessEvaluatorRatingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/RelevanceTruthAndCompletenessEvaluatorRatingTests.cs deleted file mode 100644 index db7cc6e3a26..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/RelevanceTruthAndCompletenessEvaluatorRatingTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json; -using Microsoft.Extensions.AI.Evaluation.Quality; -using Xunit; - -namespace Microsoft.Extensions.AI.Evaluation.Tests; - -[Experimental("AIEVAL001")] -public class RelevanceTruthAndCompletenessEvaluatorRatingTests -{ - [Fact] - public void JsonIsValid() - { - string json = """ - {"relevance": 1, "truth": 5, "completeness": 4} - """; - - var rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - - Assert.Equal(1, rating.Relevance); - Assert.Equal(5, rating.Truth); - Assert.Equal(4, rating.Completeness); - Assert.Null(rating.RelevanceReasoning); - Assert.Null(rating.TruthReasoning); - Assert.Null(rating.CompletenessReasoning); - Assert.Empty(rating.RelevanceReasons); - Assert.Empty(rating.TruthReasons); - Assert.Empty(rating.CompletenessReasons); - Assert.False(rating.IsInconclusive); - } - - [Fact] - public void JsonIsSurroundedWithMarkdownSyntax() - { - string json = """ - - ``` - {"relevance": 1, "truth": 5, "completeness": 4} - ``` - - """; - - var rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - - Assert.Equal(1, rating.Relevance); - Assert.Equal(5, rating.Truth); - Assert.Equal(4, rating.Completeness); - Assert.Null(rating.RelevanceReasoning); - Assert.Null(rating.TruthReasoning); - Assert.Null(rating.CompletenessReasoning); - Assert.Empty(rating.RelevanceReasons); - Assert.Empty(rating.TruthReasons); - Assert.Empty(rating.CompletenessReasons); - Assert.False(rating.IsInconclusive); - } - - [Fact] - public void JsonIsSurroundedWithMarkdownSyntaxWithJsonPrefix() - { - string json = """ - - ```json - {"relevance": 1, "truth": 5, "completeness": 4} - ``` - - """; - - var rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - - Assert.Equal(1, rating.Relevance); - Assert.Equal(5, rating.Truth); - Assert.Equal(4, rating.Completeness); - Assert.Null(rating.RelevanceReasoning); - Assert.Null(rating.TruthReasoning); - Assert.Null(rating.CompletenessReasoning); - Assert.Empty(rating.RelevanceReasons); - Assert.Empty(rating.TruthReasons); - Assert.Empty(rating.CompletenessReasons); - Assert.False(rating.IsInconclusive); - } - - [Fact] - public void JsonCanBeRoundTripped() - { - var rating = new RelevanceTruthAndCompletenessEvaluator.Rating( - relevance: 1, - relevanceReasoning: "The response is not relevant to the request.", - relevanceReasons: ["Reason 1", "Reason 2"], - truth: 5, - truthReasoning: "The response is mostly true.", - truthReasons: ["Reason 1", "Reason 2"], - completeness: 4, - completenessReasoning: "The response is mostly complete.", - completenessReasons: ["Reason 1", "Reason 2"]); - - string json = JsonSerializer.Serialize(rating, RelevanceTruthAndCompletenessEvaluator.SerializerContext.Default.Rating); - var deserialized = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.Equal(rating.Relevance, deserialized.Relevance); - Assert.Equal(rating.RelevanceReasoning, deserialized.RelevanceReasoning); - Assert.True(rating.RelevanceReasons.SequenceEqual(deserialized.RelevanceReasons)); - Assert.Equal(rating.Truth, deserialized.Truth); - Assert.Equal(rating.TruthReasoning, deserialized.TruthReasoning); - Assert.True(rating.TruthReasons.SequenceEqual(deserialized.TruthReasons)); - Assert.Equal(rating.Completeness, deserialized.Completeness); - Assert.Equal(rating.CompletenessReasoning, deserialized.CompletenessReasoning); - Assert.True(rating.CompletenessReasons.SequenceEqual(deserialized.CompletenessReasons)); - Assert.False(rating.IsInconclusive); - } - - [Fact] - public void JsonContainsInconclusiveMetrics() - { - string json = """{"relevance": -1, "truth": 4, "completeness": 7}"""; - var rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.True(rating.IsInconclusive); - - json = """{"relevance": 0, "truth": -1, "completeness": 3}"""; - rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.True(rating.IsInconclusive); - - json = """{"relevance": 0, "truth": 4, "completeness": -5}"""; - rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.True(rating.IsInconclusive); - - json = """{"relevance": 10, "truth": 4, "completeness": 3}"""; - rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.True(rating.IsInconclusive); - - json = """{"relevance": 0, "truth": 5, "completeness": 3}"""; - rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.True(rating.IsInconclusive); - - json = """{"relevance": 1, "truth": 4, "completeness": 6}"""; - rating = RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json); - Assert.True(rating.IsInconclusive); - } - - [Fact] - public void JsonContainsErrors() - { - string json = """{"relevance": 0, "truth": 2 ;"completeness": 3}"""; - Assert.Throws(() => RelevanceTruthAndCompletenessEvaluator.Rating.FromJson(json)); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/RelevanceTruthAndCompletenessRatingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/RelevanceTruthAndCompletenessRatingTests.cs new file mode 100644 index 00000000000..6be1a8ba142 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/RelevanceTruthAndCompletenessRatingTests.cs @@ -0,0 +1,382 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.AI.Evaluation.Quality; +using Microsoft.Extensions.AI.Evaluation.Quality.JsonSerialization; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class RelevanceTruthAndCompletenessRatingTests +{ + [Fact] + public void JsonIsValid() + { + string json = + """ + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 1, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(1, rating.Relevance); + Assert.Equal(1, rating.Truth); + Assert.Equal(1, rating.Completeness); + Assert.Equal("The reason for the relevance score", rating.RelevanceReasoning); + Assert.Equal("The reason for the truth score", rating.TruthReasoning); + Assert.Equal("The reason for the completeness score", rating.CompletenessReasoning); + Assert.Single(rating.RelevanceReasons); + Assert.Equal("relevance_reason_distant_topic", rating.RelevanceReasons[0]); + Assert.Equal(3, rating.TruthReasons.Length); + Assert.Contains("truth_reason_incorrect_information", rating.TruthReasons); + Assert.Contains("truth_reason_outdated_information", rating.TruthReasons); + Assert.Contains("truth_reason_misleading_incorrectforintent", rating.TruthReasons); + Assert.Equal(2, rating.CompletenessReasons.Length); + Assert.Contains("completeness_reason_no_solution", rating.CompletenessReasons); + Assert.Contains("completeness_reason_genericsolution_missingcode", rating.CompletenessReasons); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonIsSurroundedWithMarkdownSyntax() + { + string json = + """ + + ``` + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasons": [], + "truth": 4, + "truthReasoning": "The reason for the truth score", + "truthReasons": [], + "completeness": 5, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": [] + } + ``` + + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(1, rating.Relevance); + Assert.Equal(4, rating.Truth); + Assert.Equal(5, rating.Completeness); + Assert.Equal("The reason for the relevance score", rating.RelevanceReasoning); + Assert.Equal("The reason for the truth score", rating.TruthReasoning); + Assert.Equal("The reason for the completeness score", rating.CompletenessReasoning); + Assert.Empty(rating.RelevanceReasons); + Assert.Empty(rating.TruthReasons); + Assert.Empty(rating.CompletenessReasons); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonIsSurroundedWithMarkdownSyntaxWithJsonPrefix() + { + string json = + """ + + ```json + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 3, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_misleading_incorrectforintent"], + "completeness": 2, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution"], + } + ``` + + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(1, rating.Relevance); + Assert.Equal(3, rating.Truth); + Assert.Equal(2, rating.Completeness); + Assert.Equal("The reason for the relevance score", rating.RelevanceReasoning); + Assert.Equal("The reason for the truth score", rating.TruthReasoning); + Assert.Equal("The reason for the completeness score", rating.CompletenessReasoning); + Assert.Single(rating.RelevanceReasons); + Assert.Equal("relevance_reason_distant_topic", rating.RelevanceReasons[0]); + Assert.Single(rating.TruthReasons); + Assert.Contains("truth_reason_misleading_incorrectforintent", rating.TruthReasons); + Assert.Single(rating.CompletenessReasons); + Assert.Contains("completeness_reason_no_solution", rating.CompletenessReasons); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonCanBeRoundTripped() + { + RelevanceTruthAndCompletenessRating rating = + new RelevanceTruthAndCompletenessRating( + relevance: 1, + relevanceReasoning: "The response is not relevant to the request.", + relevanceReasons: ["Reason 1", "Reason 2"], + truth: 5, + truthReasoning: "The response is mostly true.", + truthReasons: ["Reason 1", "Reason 2"], + completeness: 4, + completenessReasoning: "The response is mostly complete.", + completenessReasons: ["Reason 1", "Reason 2"]); + + string json = JsonSerializer.Serialize(rating, SerializerContext.Default.RelevanceTruthAndCompletenessRating); + RelevanceTruthAndCompletenessRating deserialized = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(rating.Relevance, deserialized.Relevance); + Assert.Equal(rating.RelevanceReasoning, deserialized.RelevanceReasoning); + Assert.True(rating.RelevanceReasons.SequenceEqual(deserialized.RelevanceReasons)); + Assert.Equal(rating.Truth, deserialized.Truth); + Assert.Equal(rating.TruthReasoning, deserialized.TruthReasoning); + Assert.True(rating.TruthReasons.SequenceEqual(deserialized.TruthReasons)); + Assert.Equal(rating.Completeness, deserialized.Completeness); + Assert.Equal(rating.CompletenessReasoning, deserialized.CompletenessReasoning); + Assert.True(rating.CompletenessReasons.SequenceEqual(deserialized.CompletenessReasons)); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void InconclusiveJsonCanBeRoundTripped() + { + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.Inconclusive; + + string json = JsonSerializer.Serialize(rating, SerializerContext.Default.RelevanceTruthAndCompletenessRating); + RelevanceTruthAndCompletenessRating deserialized = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(rating.Relevance, deserialized.Relevance); + Assert.Equal(rating.RelevanceReasoning, deserialized.RelevanceReasoning); + Assert.True(rating.RelevanceReasons.SequenceEqual(deserialized.RelevanceReasons)); + Assert.Equal(rating.Truth, deserialized.Truth); + Assert.Equal(rating.TruthReasoning, deserialized.TruthReasoning); + Assert.True(rating.TruthReasons.SequenceEqual(deserialized.TruthReasons)); + Assert.Equal(rating.Completeness, deserialized.Completeness); + Assert.Equal(rating.CompletenessReasoning, deserialized.CompletenessReasoning); + Assert.True(rating.CompletenessReasons.SequenceEqual(deserialized.CompletenessReasons)); + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithNegativeScoreIsInconclusive() + { + string json = + """ + { + "relevance": -1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 1, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithZeroScoreIsInconclusive() + { + string json = + """ + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 0, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 1, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithExcessivelyHighScoreIsInconclusive() + { + string json = + """ + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 100, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.True(rating.IsInconclusive); + } + + [Fact] + public void JsonWithAdditionalHallucinatedPropertyIsProcessedCorrectly() + { + string json = + """ + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "hallucinatedProperty": "Some hallucinated text", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 1, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(1, rating.Relevance); + Assert.Equal(1, rating.Truth); + Assert.Equal(1, rating.Completeness); + Assert.Equal("The reason for the relevance score", rating.RelevanceReasoning); + Assert.Equal("The reason for the truth score", rating.TruthReasoning); + Assert.Equal("The reason for the completeness score", rating.CompletenessReasoning); + Assert.Single(rating.RelevanceReasons); + Assert.Equal("relevance_reason_distant_topic", rating.RelevanceReasons[0]); + Assert.Equal(3, rating.TruthReasons.Length); + Assert.Contains("truth_reason_incorrect_information", rating.TruthReasons); + Assert.Contains("truth_reason_outdated_information", rating.TruthReasons); + Assert.Contains("truth_reason_misleading_incorrectforintent", rating.TruthReasons); + Assert.Equal(2, rating.CompletenessReasons.Length); + Assert.Contains("completeness_reason_no_solution", rating.CompletenessReasons); + Assert.Contains("completeness_reason_genericsolution_missingcode", rating.CompletenessReasons); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonWithDuplicatePropertyUsesLastValue() + { + string json = + """ + { + "relevance": 1, + "relevanceReasoning": "The reason for the relevance score", + "relevanceReasoning": "Duplicate reason for the relevance score", + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 1, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + RelevanceTruthAndCompletenessRating rating = RelevanceTruthAndCompletenessRating.FromJson(json); + + Assert.Equal(1, rating.Relevance); + Assert.Equal(1, rating.Truth); + Assert.Equal(1, rating.Completeness); + Assert.Equal("Duplicate reason for the relevance score", rating.RelevanceReasoning); + Assert.Equal("The reason for the truth score", rating.TruthReasoning); + Assert.Equal("The reason for the completeness score", rating.CompletenessReasoning); + Assert.Single(rating.RelevanceReasons); + Assert.Equal("relevance_reason_distant_topic", rating.RelevanceReasons[0]); + Assert.Equal(3, rating.TruthReasons.Length); + Assert.Contains("truth_reason_incorrect_information", rating.TruthReasons); + Assert.Contains("truth_reason_outdated_information", rating.TruthReasons); + Assert.Contains("truth_reason_misleading_incorrectforintent", rating.TruthReasons); + Assert.Equal(2, rating.CompletenessReasons.Length); + Assert.Contains("completeness_reason_no_solution", rating.CompletenessReasons); + Assert.Contains("completeness_reason_genericsolution_missingcode", rating.CompletenessReasons); + Assert.False(rating.IsInconclusive); + } + + [Fact] + public void JsonWithSemicolonsInsteadOfCommasThrowsException() + { + string json = + """ + { + "relevance": 1; + "relevanceReasoning": "The reason for the relevance score"; + "relevanceReasons": ["relevance_reason_distant_topic"]; + "truth": 1; + "truthReasoning": "The reason for the truth score"; + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"]; + "completeness": 1; + "completenessReasoning": "The reason for the completeness score"; + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"]; + } + """; + + Assert.Throws(() => RelevanceTruthAndCompletenessRating.FromJson(json)); + } + + [Fact] + public void JsonWithMissingPropertiesThrowsException() + { + string json = + """ + { + "relevance": 1, + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + } + """; + + Assert.Throws(() => RelevanceTruthAndCompletenessRating.FromJson(json)); + } + + [Fact] + public void JsonWithIncorrectPropertyValueTypeThrowsException() + { + // Incorrect property value (integer instead of string for relevanceReasoning). + string json = + """ + { + "relevance": 1, + "relevanceReasoning": 6, + "relevanceReasons": ["relevance_reason_distant_topic"], + "truth": 1, + "truthReasoning": "The reason for the truth score", + "truthReasons": ["truth_reason_incorrect_information", "truth_reason_outdated_information", "truth_reason_misleading_incorrectforintent"], + "completeness": 1, + "completenessReasoning": "The reason for the completeness score", + "completenessReasons": ["completeness_reason_no_solution", "completeness_reason_genericsolution_missingcode"], + } + """; + + Assert.Throws(() => RelevanceTruthAndCompletenessRating.FromJson(json)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 994fef47517..992e86a1184 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -4,12 +4,15 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -22,32 +25,45 @@ #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable CA2214 // Do not call overridable methods in constructors #pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf' +#pragma warning disable S103 // Lines should not be too long +#pragma warning disable S1144 // Unused private types or members should be removed +#pragma warning disable S3604 // Member initializer values should not be redundant +#pragma warning disable SA1515 // Single-line comment should be preceded by blank line namespace Microsoft.Extensions.AI; public abstract class ChatClientIntegrationTests : IDisposable { - private readonly IChatClient? _chatClient; - protected ChatClientIntegrationTests() { - _chatClient = CreateChatClient(); + ChatClient = CreateChatClient(); } + protected IChatClient? ChatClient { get; } + + protected IEmbeddingGenerator>? EmbeddingGenerator { get; private set; } + public void Dispose() { - _chatClient?.Dispose(); + ChatClient?.Dispose(); GC.SuppressFinalize(this); } protected abstract IChatClient? CreateChatClient(); + /// + /// Optionally supplies an embedding generator for integration tests that exercise + /// embedding-based components (e.g., tool reduction). Default returns null and + /// tests depending on embeddings will skip if not overridden. + /// + protected virtual IEmbeddingGenerator>? CreateEmbeddingGenerator() => null; + [ConditionalFact] public virtual async Task GetResponseAsync_SingleRequestMessage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync("What's the biggest animal?"); + var response = await ChatClient.GetResponseAsync("What's the biggest animal?"); Assert.Contains("whale", response.Text, StringComparison.OrdinalIgnoreCase); } @@ -57,7 +73,7 @@ public virtual async Task GetResponseAsync_MultipleRequestMessages() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, "Pick a city, any city"), new(ChatRole.Assistant, "Seattle"), @@ -75,7 +91,7 @@ public virtual async Task GetResponseAsync_WithEmptyMessage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.System, []), new(ChatRole.User, []), @@ -97,7 +113,7 @@ public virtual async Task GetStreamingResponseAsync() ]; StringBuilder sb = new(); - await foreach (var chunk in _chatClient.GetStreamingResponseAsync(chatHistory)) + await foreach (var chunk in ChatClient.GetStreamingResponseAsync(chatHistory)) { sb.Append(chunk.Text); } @@ -112,7 +128,7 @@ public virtual async Task GetResponseAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync("Explain in 10 words how AI works"); + var response = await ChatClient.GetResponseAsync("Explain in 10 words how AI works"); Assert.True(response.Usage?.InputTokenCount > 1); Assert.True(response.Usage?.OutputTokenCount > 1); @@ -124,7 +140,7 @@ public virtual async Task GetStreamingResponseAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = _chatClient.GetStreamingResponseAsync("Explain in 10 words how AI works", new() + var response = ChatClient.GetStreamingResponseAsync("Explain in 10 words how AI works", new() { AdditionalProperties = new() { @@ -153,7 +169,7 @@ public virtual async Task GetStreamingResponseAsync_AppendToHistory() List history = [new(ChatRole.User, "Explain in 100 words how AI works")]; - var streamingResponse = _chatClient.GetStreamingResponseAsync(history); + var streamingResponse = ChatClient.GetStreamingResponseAsync(history); Assert.Single(history); await history.AddMessagesAsync(streamingResponse); @@ -172,7 +188,7 @@ public virtual async Task MultiModal_DescribeImage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, [ @@ -190,12 +206,12 @@ public virtual async Task MultiModal_DescribePdf() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, [ new TextContent("What text does this document contain?"), - new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf"), + new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf") { Name = "sample.pdf" }, ]) ], new() { ModelId = GetModel_MultiModal_DescribeImage() }); @@ -216,7 +232,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Paramet .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -239,7 +255,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithPar { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync("What is the result of SecretComputation on 42 and 84?", new() { @@ -254,7 +270,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithPar { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = chatClient.GetStreamingResponseAsync("What is the result of SecretComputation on 42 and 84?", new() { @@ -283,7 +299,7 @@ public virtual async Task FunctionInvocation_OptionalParameter() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -315,7 +331,7 @@ public virtual async Task FunctionInvocation_NestedParameters() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -334,6 +350,39 @@ public virtual async Task FunctionInvocation_NestedParameters() AssertUsageAgainstActivities(response, activities); } + [ConditionalFact] + public virtual async Task FunctionInvocation_ArrayParameter() + { + SkipIfNotEnabled(); + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var chatClient = new FunctionInvokingChatClient( + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); + + List messages = + [ + new(ChatRole.User, "Can you add bacon, lettuce, and tomatoes to Peter's shopping cart?") + ]; + + string? shopperName = null; + List shoppingCart = []; + AIFunction func = AIFunctionFactory.Create((string[] items, string shopperId) => { shoppingCart.AddRange(items); shopperName = shopperId; }, "AddItemsToShoppingCart"); + var response = await chatClient.GetResponseAsync(messages, new() + { + Tools = [func] + }); + + Assert.Equal("Peter", shopperName); + Assert.Equal(["bacon", "lettuce", "tomatoes"], shoppingCart); + AssertUsageAgainstActivities(response, activities); + } + private static void AssertUsageAgainstActivities(ChatResponse response, List activities) { // If the underlying IChatClient provides usage data, function invocation should aggregate the @@ -342,8 +391,8 @@ private static void AssertUsageAgainstActivities(ChatResponse response, List (int?)a.GetTagItem("gen_ai.response.input_tokens")!); - var totalOutputTokens = activities.Sum(a => (int?)a.GetTagItem("gen_ai.response.output_tokens")!); + var totalInputTokens = activities.Sum(a => (int?)a.GetTagItem("gen_ai.usage.input_tokens")!); + var totalOutputTokens = activities.Sum(a => (int?)a.GetTagItem("gen_ai.usage.output_tokens")!); Assert.Equal(totalInputTokens, finalUsage.InputTokenCount * 2); Assert.Equal(totalOutputTokens, finalUsage.OutputTokenCount * 2); } @@ -352,7 +401,14 @@ private static void AssertUsageAgainstActivities(ChatResponse response, List + AvailableTools_SchemasAreAccepted(strict: true); + + [ConditionalFact] + public virtual Task AvailableTools_SchemasAreAccepted_NonStrict() => + AvailableTools_SchemasAreAccepted(strict: false); + + private async Task AvailableTools_SchemasAreAccepted(bool strict) { SkipIfNotEnabled(); @@ -364,29 +420,92 @@ public virtual async Task AvailableTools_SchemasAreAccepted() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); + + int methodCount = 1; + Func createOptions = () => + { + AIFunctionFactoryOptions aiFuncOptions = new() + { + Name = $"Method{methodCount++}", + }; + + if (strict) + { + aiFuncOptions.AdditionalProperties = new Dictionary { ["strictJsonSchema"] = true }; + } + + return aiFuncOptions; + }; + + Func createWithSchema = schema => + { + Dictionary additionalProperties = new(); + + if (strict) + { + additionalProperties["strictJsonSchema"] = true; + } + + return new CustomAIFunction($"CustomMethod{methodCount++}", schema, additionalProperties); + }; ChatOptions options = new() { MaxOutputTokens = 100, Tools = [ - AIFunctionFactory.Create((int? i) => i, "Method1"), - AIFunctionFactory.Create((string? s) => s, "Method2"), - AIFunctionFactory.Create((int? i = null) => i, "Method3"), - AIFunctionFactory.Create((bool b) => b, "Method4"), - AIFunctionFactory.Create((double d) => d, "Method5"), - AIFunctionFactory.Create((decimal d) => d, "Method6"), - AIFunctionFactory.Create((float f) => f, "Method7"), - AIFunctionFactory.Create((long l) => l, "Method8"), - AIFunctionFactory.Create((char c) => c, "Method9"), - AIFunctionFactory.Create((DateTime dt) => dt, "Method10"), - AIFunctionFactory.Create((DateTime? dt) => dt, "Method11"), - AIFunctionFactory.Create((Guid guid) => guid, "Method12"), - AIFunctionFactory.Create((List list) => list, "Method13"), - AIFunctionFactory.Create((int[] arr) => arr, "Method14"), - AIFunctionFactory.Create((string p1 = "str", int p2 = 42, BindingFlags p3 = BindingFlags.IgnoreCase, char p4 = 'x') => p1, "Method15"), - AIFunctionFactory.Create((string? p1 = "str", int? p2 = 42, BindingFlags? p3 = BindingFlags.IgnoreCase, char? p4 = 'x') => p1, "Method16"), + // Using AIFunctionFactory + AIFunctionFactory.Create((int? i) => i, createOptions()), + AIFunctionFactory.Create((string? s) => s, createOptions()), + AIFunctionFactory.Create((int? i = null) => i, createOptions()), + AIFunctionFactory.Create((bool b) => b, createOptions()), + AIFunctionFactory.Create((double d) => d, createOptions()), + AIFunctionFactory.Create((decimal d) => d, createOptions()), + AIFunctionFactory.Create((float f) => f, createOptions()), + AIFunctionFactory.Create((long l) => l, createOptions()), + AIFunctionFactory.Create((char c) => c, createOptions()), + AIFunctionFactory.Create((DateTime dt) => dt, createOptions()), + AIFunctionFactory.Create((DateTimeOffset? dt) => dt, createOptions()), + AIFunctionFactory.Create((TimeSpan ts) => ts, createOptions()), +#if NET + AIFunctionFactory.Create((DateOnly d) => d, createOptions()), + AIFunctionFactory.Create((TimeOnly t) => t, createOptions()), +#endif + AIFunctionFactory.Create((Uri uri) => uri, createOptions()), + AIFunctionFactory.Create((Guid guid) => guid, createOptions()), + AIFunctionFactory.Create((List list) => list, createOptions()), + AIFunctionFactory.Create((int[] arr, ComplexObject? co) => arr, createOptions()), + AIFunctionFactory.Create((string p1 = "str", int p2 = 42, BindingFlags p3 = BindingFlags.IgnoreCase, char p4 = 'x') => p1, createOptions()), + AIFunctionFactory.Create((string? p1 = "str", int? p2 = 42, BindingFlags? p3 = BindingFlags.IgnoreCase, char? p4 = 'x') => p1, createOptions()), + + // Selection from @modelcontextprotocol/server-everything + createWithSchema(""" + {"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} + """), + createWithSchema(""" + {"type":"object","properties":{"duration":{"type":"number","default":10,"description":"Duration of the operation in seconds"},"steps":{"type":"number","default":5,"description":"Number of steps in the operation"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} + """), + createWithSchema(""" + {"type":"object","properties":{"prompt":{"type":"string","description":"The prompt to send to the LLM"},"maxTokens":{"type":"number","default":100,"description":"Maximum number of tokens to generate"}},"required":["prompt"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} + """), + createWithSchema(""" + {"type":"object","properties":{},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} + """), + createWithSchema(""" + {"type":"object","properties":{"messageType":{"type":"string","enum":["error","success","debug"],"description":"Type of message to demonstrate different annotation patterns"},"includeImage":{"type":"boolean","default":false,"description":"Whether to include an example image"}},"required":["messageType"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} + """), + createWithSchema(""" + {"type":"object","properties":{"resourceId":{"type":"number","minimum":1,"maximum":100,"description":"ID of the resource to reference (1-100)"}},"required":["resourceId"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} + """), + + // Selection from GH MCP server + createWithSchema(""" + {"properties":{"body":{"description":"The text of the review comment","type":"string"},"line":{"description":"The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range","type":"number"},"owner":{"description":"Repository owner","type":"string"},"path":{"description":"The relative path to the file that necessitates a comment","type":"string"},"pullNumber":{"description":"Pull request number","type":"number"},"repo":{"description":"Repository name","type":"string"},"side":{"description":"The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state","enum":["LEFT","RIGHT"],"type":"string"},"startLine":{"description":"For multi-line comments, the first line of the range that the comment applies to","type":"number"},"startSide":{"description":"For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state","enum":["LEFT","RIGHT"],"type":"string"},"subjectType":{"description":"The level at which the comment is targeted","enum":["FILE","LINE"],"type":"string"}},"required":["owner","repo","pullNumber","path","body","subjectType"],"type":"object"} + """), + createWithSchema(""" + {"properties":{"commit_message":{"description":"Extra detail for merge commit","type":"string"},"commit_title":{"description":"Title for merge commit","type":"string"},"merge_method":{"description":"Merge method","enum":["merge","squash","rebase"],"type":"string"},"owner":{"description":"Repository owner","type":"string"},"pullNumber":{"description":"Pull request number","type":"number"},"repo":{"description":"Repository name","type":"string"}},"required":["owner","repo","pullNumber"],"type":"object"} + """), ], }; @@ -395,6 +514,57 @@ public virtual async Task AvailableTools_SchemasAreAccepted() Assert.NotNull(response); } + private sealed class CustomAIFunction(string name, string jsonSchema, IReadOnlyDictionary additionalProperties) : AIFunction + { + public override string Name => name; + public override IReadOnlyDictionary AdditionalProperties => additionalProperties; + public override JsonElement JsonSchema { get; } = JsonSerializer.Deserialize(jsonSchema, AIJsonUtilities.DefaultOptions); + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private class ComplexObject + { + [DisplayName("Something cool")] +#if NET + [DeniedValues("abc", "def", "default")] +#endif + public string? SomeString { get; set; } + +#if NET + [AllowedValues("abc", "def", "default")] +#endif + public string AnotherString { get; set; } = "default"; + +#if NET + [Range(25, 75)] +#endif + public int Value { get; set; } + + [EmailAddress] + public string? Email { get; set; } + + [RegularExpression("[abc]")] + public string? RegexString { get; set; } + + [StringLength(42)] + public string MeasuredString { get; set; } = "default"; + +#if NET + [Length(1, 2)] +#endif + public int[]? MeasuredArray1 { get; set; } + +#if NET + [MinLength(1)] +#endif + public int[]? MeasuredArray2 { get; set; } + +#if NET + [MaxLength(10)] +#endif + public int[]? MeasuredArray3 { get; set; } + } + protected virtual bool SupportsParallelFunctionCalling => true; [ConditionalFact] @@ -406,7 +576,7 @@ public virtual async Task FunctionInvocation_SupportsMultipleParallelRequests() throw new SkipTestException("Parallel function calling is not supported by this chat client"); } - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); // The service/model isn't guaranteed to request two calls to GetPersonAge in the same turn, but it's common that it will. var response = await chatClient.GetResponseAsync("How much older is Elsa than Anna? Return the age difference as a single number.", new() @@ -439,7 +609,7 @@ public virtual async Task FunctionInvocation_RequireAny() return 123; }, "GetSecretNumber"); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync("Are birds real?", new() { @@ -459,7 +629,7 @@ public virtual async Task FunctionInvocation_RequireSpecific() var getSecretNumberTool = AIFunctionFactory.Create(() => 123, "GetSecretNumber"); var shieldsUpTool = AIFunctionFactory.Create(() => shieldsUp = true, "ShieldsUp"); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); // Even though the user doesn't ask for the shields to be activated, verify that the tool is invoked var response = await chatClient.GetResponseAsync("What's the current secret number?", new() @@ -477,9 +647,9 @@ public virtual async Task Caching_OutputVariesWithoutCaching() SkipIfNotEnabled(); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); - var firstResponse = await _chatClient.GetResponseAsync([message]); + var firstResponse = await ChatClient.GetResponseAsync([message]); - var secondResponse = await _chatClient.GetResponseAsync([message]); + var secondResponse = await ChatClient.GetResponseAsync([message]); Assert.NotEqual(firstResponse.Text, secondResponse.Text); } @@ -489,7 +659,7 @@ public virtual async Task Caching_SamePromptResultsInCacheHit_NonStreaming() SkipIfNotEnabled(); using var chatClient = new DistributedCachingChatClient( - _chatClient, + ChatClient, new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); @@ -514,7 +684,7 @@ public virtual async Task Caching_SamePromptResultsInCacheHit_Streaming() SkipIfNotEnabled(); using var chatClient = new DistributedCachingChatClient( - _chatClient, + ChatClient, new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); @@ -618,9 +788,9 @@ public virtual async Task Caching_AfterFunctionInvocation_FunctionOutputUnchange // Second time, the calls to the LLM don't happen, but the function is called again var secondResponse = await chatClient.GetResponseAsync([message]); - Assert.Equal(response.Text, secondResponse.Text); Assert.Equal(2, functionCallCount); Assert.Equal(FunctionInvokingChatClientSetsConversationId ? 3 : 2, llmCallCount!.CallCount); + Assert.Equal(response.Text, secondResponse.Text); } public virtual bool FunctionInvokingChatClientSetsConversationId => false; @@ -781,12 +951,12 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() var activity = Assert.Single(activities); Assert.StartsWith("chat", activity.DisplayName); - Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); + Assert.Contains(".", (string)activity.GetTagItem("server.address")!); Assert.Equal(chatClient.GetService()?.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); - Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.input_tokens")!); - Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.output_tokens")!); + Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.usage.input_tokens")!); + Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.usage.output_tokens")!); Assert.True(activity.Duration.TotalMilliseconds > 0); } @@ -796,7 +966,7 @@ public virtual async Task GetResponseAsync_StructuredOutput() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Who is described in the following sentence? Jimbo Smith is a 35-year-old programmer from Cardiff, Wales. """); @@ -812,7 +982,7 @@ public virtual async Task GetResponseAsync_StructuredOutputArray() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Who are described in the following sentence? Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Josh Simpson is a 25-year-old software developer from Newport, Wales. @@ -828,7 +998,7 @@ public virtual async Task GetResponseAsync_StructuredOutputInteger() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" There were 14 abstractions for AI programming, which was too many. To fix this we added another one. How many are there now? """); @@ -841,7 +1011,7 @@ public virtual async Task GetResponseAsync_StructuredOutputString() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" The software developer, Jimbo Smith, is a 35-year-old from Cardiff, Wales. What's his full name? """); @@ -854,7 +1024,7 @@ public virtual async Task GetResponseAsync_StructuredOutputBool_True() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Is there at least one software developer from Cardiff? """); @@ -867,7 +1037,7 @@ public virtual async Task GetResponseAsync_StructuredOutputBool_False() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Reply true if the previous statement indicates that he is a medical doctor, otherwise false. """); @@ -880,7 +1050,7 @@ public virtual async Task GetResponseAsync_StructuredOutputEnum() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Taylor Swift is a famous singer and songwriter. What is her job? """); @@ -900,7 +1070,7 @@ public virtual async Task GetResponseAsync_StructuredOutput_WithFunctions() Job = JobType.Programmer, }; - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync( "Who is person with ID 123?", new ChatOptions { @@ -924,7 +1094,7 @@ public virtual async Task GetResponseAsync_StructuredOutput_NonNative() SkipIfNotEnabled(); var capturedOptions = new List(); - var captureOutputChatClient = _chatClient.AsBuilder() + var captureOutputChatClient = ChatClient.AsBuilder() .Use((messages, options, nextAsync, cancellationToken) => { capturedOptions.Add(options); @@ -964,14 +1134,632 @@ private enum JobType Unknown, } - [MemberNotNull(nameof(_chatClient))] + [ConditionalFact] + public virtual async Task SummarizingChatReducer_PreservesConversationContext() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 1); + + List messages = + [ + new(ChatRole.User, "My name is Alice and I love hiking in the mountains."), + new(ChatRole.Assistant, "Nice to meet you, Alice! Hiking in the mountains sounds wonderful. Do you have a favorite trail?"), + new(ChatRole.User, "Yes, I love the Pacific Crest Trail. I hiked a section last summer."), + new(ChatRole.Assistant, "The Pacific Crest Trail is amazing! Which section did you hike?"), + new(ChatRole.User, "I hiked the section through the Sierra Nevada. It was challenging but beautiful."), + new(ChatRole.Assistant, "The Sierra Nevada section is known for its stunning views. How long did it take you?"), + new(ChatRole.User, "What's my name and what activity do I enjoy?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); // Indicates this is the assistant's summary + Assert.Contains("Alice", m.Text); + }, + m => Assert.StartsWith("The Sierra Nevada section", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("What's my name", m.Text, StringComparison.Ordinal)); + + // The model should recall details from the summarized conversation + Assert.Contains("Alice", response.Text); + Assert.True( + response.Text.IndexOf("hiking", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("hike", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected 'hiking' or 'hike' in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_PreservesSystemMessage() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new(ChatRole.System, "You are a pirate. Always respond in pirate speak."), + new(ChatRole.User, "Tell me about the weather"), + new(ChatRole.Assistant, "Ahoy matey! The weather be fine today, with clear skies on the horizon!"), + new(ChatRole.User, "What about tomorrow?"), + new(ChatRole.Assistant, "Arr, tomorrow be lookin' a bit cloudy, might be some rain blowin' in from the east!"), + new(ChatRole.User, "Should I bring an umbrella?"), + new(ChatRole.Assistant, "Aye, ye best be bringin' yer umbrella, unless ye want to be soaked like a barnacle!"), + new(ChatRole.User, "What's 2 + 2?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(4, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("You are a pirate. Always respond in pirate speak.", m.Text); + }, + m => Assert.Equal(ChatRole.Assistant, m.Role), // Summary message + m => Assert.StartsWith("Aye, ye best be bringin'", m.Text, StringComparison.Ordinal), + m => Assert.Equal("What's 2 + 2?", m.Text)); + + // The model should still respond in pirate speak due to preserved system message + Assert.True( + response.Text.IndexOf("arr", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("aye", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("matey", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("ye", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected pirate speak in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_WithFunctionCalls() + { + SkipIfNotEnabled(); + + int weatherCallCount = 0; + var getWeather = AIFunctionFactory.Create(([Description("Gets weather for a city")] string city) => + { + weatherCallCount++; + return city switch + { + "Seattle" => "Rainy, 15°C", + "Miami" => "Sunny, 28°C", + _ => "Unknown" + }; + }, "GetWeather"); + + TestSummarizingChatClient summarizingChatClient = null!; + var chatClient = ChatClient + .AsBuilder() + .Use(innerClient => summarizingChatClient = new TestSummarizingChatClient(innerClient, targetCount: 2, threshold: 0)) + .UseFunctionInvocation() + .Build(); + + List messages = + [ + new(ChatRole.User, "What's the weather in Seattle?"), + new(ChatRole.Assistant, "Let me check the weather in Seattle for you."), + new(ChatRole.User, "And what about Miami?"), + new(ChatRole.Assistant, "I'll check Miami's weather as well."), + new(ChatRole.User, "Which city had better weather?") + ]; + + var response = await chatClient.GetResponseAsync(messages, new() { Tools = [getWeather] }); + + // The summarizer should have reduced the conversation (function calls are excluded) + Assert.Equal(1, summarizingChatClient.SummarizerCallCount); + Assert.NotNull(summarizingChatClient.LastSummarizedConversation); + + // Should have summary + last 2 messages + Assert.Equal(3, summarizingChatClient.LastSummarizedConversation.Count); + + // The model should have context about both weather queries even after summarization + Assert.True(response.Text.IndexOf("Miami", StringComparison.OrdinalIgnoreCase) >= 0, $"Expected 'Miami' in response: {response.Text}"); + Assert.True( + response.Text.IndexOf("sunny", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("better", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("warm", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected weather comparison in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_Streaming() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new(ChatRole.User, "I'm Bob and I work as a software engineer at a startup."), + new(ChatRole.Assistant, "Nice to meet you, Bob! Working at a startup must be exciting. What kind of software do you develop?"), + new(ChatRole.User, "We build AI-powered tools for education."), + new(ChatRole.Assistant, "That sounds impactful! AI in education has so much potential."), + new(ChatRole.User, "Yes, we focus on personalized learning experiences."), + new(ChatRole.Assistant, "Personalized learning is the future of education!"), + new(ChatRole.User, "Was anyone named in the conversation? Provide their name and job.") + ]; + + StringBuilder sb = new(); + await foreach (var chunk in chatClient.GetStreamingResponseAsync(messages)) + { + sb.Append(chunk.Text); + } + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); // Summary + Assert.Contains("Bob", m.Text); + }, + m => Assert.StartsWith("Personalized learning", m.Text, StringComparison.Ordinal), + m => Assert.Equal("Was anyone named in the conversation? Provide their name and job.", m.Text)); + + string responseText = sb.ToString(); + Assert.Contains("Bob", responseText); + Assert.True( + responseText.IndexOf("software", StringComparison.OrdinalIgnoreCase) >= 0 || + responseText.IndexOf("engineer", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected 'software' or 'engineer' in response: {responseText}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_CustomPrompt() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + chatClient.Reducer.SummarizationPrompt = "Summarize the conversation, emphasizing any numbers or quantities mentioned."; + + List messages = + [ + new(ChatRole.User, "I have 3 cats and 2 dogs."), + new(ChatRole.Assistant, "That's 5 pets total! You must have a lively household."), + new(ChatRole.User, "Yes, and I spend about $200 per month on pet food."), + new(ChatRole.Assistant, "That's a significant expense, but I'm sure they're worth it!"), + new(ChatRole.User, "They eat 10 cans of food per week."), + new(ChatRole.Assistant, "That's quite a bit of food for your furry friends!"), + new(ChatRole.User, "How many pets do I have in total?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + + // Verify the summary emphasizes numbers as requested by the custom prompt + var summaryMessage = chatClient.LastSummarizedConversation[0]; + Assert.Equal(ChatRole.Assistant, summaryMessage.Role); + Assert.True( + summaryMessage.Text.IndexOf("3", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("5", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("200", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("10", StringComparison.Ordinal) >= 0, + $"Expected numbers in summary: {summaryMessage.Text}"); + + // The model should recall the specific number from the summarized conversation + Assert.Contains("5", response.Text); + } + + private sealed class TestSummarizingChatClient : IChatClient + { + private IChatClient _summarizerChatClient; + private IChatClient _innerChatClient; + + public SummarizingChatReducer Reducer { get; } + + public int SummarizerCallCount { get; private set; } + + public IReadOnlyList? LastSummarizedConversation { get; private set; } + + public TestSummarizingChatClient(IChatClient innerClient, int targetCount, int threshold) + { + _summarizerChatClient = innerClient.AsBuilder() + .Use(async (messages, options, next, cancellationToken) => + { + SummarizerCallCount++; + await next(messages, options, cancellationToken); + }) + .Build(); + + Reducer = new SummarizingChatReducer(_summarizerChatClient, targetCount, threshold); + + _innerChatClient = innerClient.AsBuilder() + .UseChatReducer(Reducer) + .Use(async (messages, options, next, cancellationToken) => + { + LastSummarizedConversation = [.. messages]; + await next(messages, options, cancellationToken); + }) + .Build(); + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => _innerChatClient.GetResponseAsync(messages, options, cancellationToken); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => _innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) + => _innerChatClient.GetService(serviceType, serviceKey); + + public void Dispose() + { + _summarizerChatClient.Dispose(); + _innerChatClient.Dispose(); + } + } + + [ConditionalFact] + public virtual async Task ToolReduction_DynamicSelection_RespectsConversationHistory() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + // Limit to 2 so that, once the conversation references both weather and translation, + // both tools can be included even if the latest user turn only mentions one of them. + var strategy = new EmbeddingToolReductionStrategy(EmbeddingGenerator, toolLimit: 2); + + var weatherTool = AIFunctionFactory.Create( + () => "Weather data", + new AIFunctionFactoryOptions + { + Name = "GetWeatherForecast", + Description = "Returns weather forecast and temperature for a given city." + }); + + var translateTool = AIFunctionFactory.Create( + () => "Translated text", + new AIFunctionFactoryOptions + { + Name = "TranslateText", + Description = "Translates text between human languages." + }); + + var mathTool = AIFunctionFactory.Create( + () => 42, + new AIFunctionFactoryOptions + { + Name = "SolveMath", + Description = "Solves basic math problems." + }); + + var allTools = new List { weatherTool, translateTool, mathTool }; + + IList? firstTurnTools = null; + IList? secondTurnTools = null; + + using var client = ChatClient! + .AsBuilder() + .UseToolReduction(strategy) + .Use(async (messages, options, next, ct) => + { + // Capture the (possibly reduced) tool list for each turn. + if (firstTurnTools is null) + { + firstTurnTools = options?.Tools; + } + else + { + secondTurnTools ??= options?.Tools; + } + + await next(messages, options, ct); + }) + .UseFunctionInvocation() + .Build(); + + // Maintain chat history across turns. + List history = []; + + // Turn 1: Ask a weather question. + history.Add(new ChatMessage(ChatRole.User, "What will the weather be in Seattle tomorrow?")); + var firstResponse = await client.GetResponseAsync(history, new ChatOptions { Tools = allTools }); + history.AddMessages(firstResponse); // Append assistant reply. + + Assert.NotNull(firstTurnTools); + Assert.Contains(firstTurnTools, t => t.Name == "GetWeatherForecast"); + + // Turn 2: Ask a translation question. Even though only translation is mentioned now, + // conversation history still contains a weather request. Expect BOTH weather + translation tools. + history.Add(new ChatMessage(ChatRole.User, "Please translate 'good evening' into French.")); + var secondResponse = await client.GetResponseAsync(history, new ChatOptions { Tools = allTools }); + history.AddMessages(secondResponse); + + Assert.NotNull(secondTurnTools); + Assert.Equal(2, secondTurnTools.Count); // Should have filled both slots with the two relevant domains. + Assert.Contains(secondTurnTools, t => t.Name == "GetWeatherForecast"); + Assert.Contains(secondTurnTools, t => t.Name == "TranslateText"); + + // Ensure unrelated tool was excluded. + Assert.DoesNotContain(secondTurnTools, t => t.Name == "SolveMath"); + } + + [ConditionalFact] + public virtual async Task ToolReduction_RequireSpecificToolPreservedAndOrdered() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + // Limit would normally reduce to 1, but required tool plus another should remain. + var strategy = new EmbeddingToolReductionStrategy(EmbeddingGenerator, toolLimit: 1); + + var translateTool = AIFunctionFactory.Create( + () => "Translated text", + new AIFunctionFactoryOptions + { + Name = "TranslateText", + Description = "Translates phrases between languages." + }); + + var weatherTool = AIFunctionFactory.Create( + () => "Weather data", + new AIFunctionFactoryOptions + { + Name = "GetWeatherForecast", + Description = "Returns forecast data for a city." + }); + + var tools = new List { translateTool, weatherTool }; + + IList? captured = null; + + using var client = ChatClient! + .AsBuilder() + .UseToolReduction(strategy) + .UseFunctionInvocation() + .Use((messages, options, next, ct) => + { + captured = options?.Tools; + return next(messages, options, ct); + }) + .Build(); + + var history = new List + { + new(ChatRole.User, "What will the weather be like in Redmond next week?") + }; + + var response = await client.GetResponseAsync(history, new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.RequireSpecific(translateTool.Name) + }); + history.AddMessages(response); + + Assert.NotNull(captured); + Assert.Equal(2, captured!.Count); + Assert.Equal("TranslateText", captured[0].Name); // Required should appear first. + Assert.Equal("GetWeatherForecast", captured[1].Name); + } + + [ConditionalFact] + public virtual async Task ToolReduction_ToolRemovedAfterFirstUse_NotInvokedAgain() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + int weatherInvocationCount = 0; + + var weatherTool = AIFunctionFactory.Create( + () => + { + weatherInvocationCount++; + return "Sunny and dry."; + }, + new AIFunctionFactoryOptions + { + Name = "GetWeather", + Description = "Gets the weather forecast for a given location." + }); + + // Strategy exposes tools only on the first request, then removes them. + var removalStrategy = new RemoveToolAfterFirstUseStrategy(); + + IList? firstTurnTools = null; + IList? secondTurnTools = null; + + using var client = ChatClient! + .AsBuilder() + // Place capture immediately after reduction so it's invoked exactly once per user request. + .UseToolReduction(removalStrategy) + .Use((messages, options, next, ct) => + { + if (firstTurnTools is null) + { + firstTurnTools = options?.Tools; + } + else + { + secondTurnTools ??= options?.Tools; + } + + return next(messages, options, ct); + }) + .UseFunctionInvocation() + .Build(); + + List history = []; + + // Turn 1 + history.Add(new ChatMessage(ChatRole.User, "What's the weather like tomorrow in Seattle?")); + var firstResponse = await client.GetResponseAsync(history, new ChatOptions + { + Tools = [weatherTool], + ToolMode = ChatToolMode.RequireAny + }); + history.AddMessages(firstResponse); + + Assert.Equal(1, weatherInvocationCount); + Assert.NotNull(firstTurnTools); + Assert.Contains(firstTurnTools!, t => t.Name == "GetWeather"); + + // Turn 2 (tool removed by strategy even though caller supplies it again) + history.Add(new ChatMessage(ChatRole.User, "And what about next week?")); + var secondResponse = await client.GetResponseAsync(history, new ChatOptions + { + Tools = [weatherTool] + }); + history.AddMessages(secondResponse); + + Assert.Equal(1, weatherInvocationCount); // Not invoked again. + Assert.NotNull(secondTurnTools); + Assert.Empty(secondTurnTools!); // Strategy removed the tool set. + + // Response text shouldn't just echo the tool's stub output. + Assert.DoesNotContain("Sunny and dry.", secondResponse.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public virtual async Task ToolReduction_MessagesEmbeddingTextSelector_UsesChatClientToAnalyzeConversation() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + // Create tools for different domains. + var weatherTool = AIFunctionFactory.Create( + () => "Weather data", + new AIFunctionFactoryOptions + { + Name = "GetWeatherForecast", + Description = "Returns weather forecast and temperature for a given city." + }); + + var translateTool = AIFunctionFactory.Create( + () => "Translated text", + new AIFunctionFactoryOptions + { + Name = "TranslateText", + Description = "Translates text between human languages." + }); + + var mathTool = AIFunctionFactory.Create( + () => 42, + new AIFunctionFactoryOptions + { + Name = "SolveMath", + Description = "Solves basic math problems." + }); + + var allTools = new List { weatherTool, translateTool, mathTool }; + + // Track the analysis result from the chat client used in the selector. + string? capturedAnalysis = null; + + var strategy = new EmbeddingToolReductionStrategy(EmbeddingGenerator, toolLimit: 2) + { + // Use a chat client to analyze the conversation and extract relevant tool categories. + MessagesEmbeddingTextSelector = async messages => + { + var conversationText = string.Join("\n", messages.Select(m => $"{m.Role}: {m.Text}")); + + var analysisPrompt = $""" + Analyze the following conversation and identify what kinds of tools would be most helpful. + Focus on the key topics and tasks being discussed. + Respond with a brief summary of the relevant tool categories (e.g., "weather", "translation", "math"). + + Conversation: + {conversationText} + + Relevant tool categories: + """; + + var response = await ChatClient!.GetResponseAsync(analysisPrompt); + capturedAnalysis = response.Text; + + // Return the analysis as the query text for embedding-based tool selection. + return capturedAnalysis; + } + }; + + IList? selectedTools = null; + + using var client = ChatClient! + .AsBuilder() + .UseToolReduction(strategy) + .Use(async (messages, options, next, ct) => + { + selectedTools = options?.Tools; + await next(messages, options, ct); + }) + .UseFunctionInvocation() + .Build(); + + // Conversation that clearly indicates weather-related needs. + List history = []; + history.Add(new ChatMessage(ChatRole.User, "What will the weather be like in London tomorrow?")); + + var response = await client.GetResponseAsync(history, new ChatOptions { Tools = allTools }); + history.AddMessages(response); + + // Verify that the chat client was used to analyze the conversation. + Assert.NotNull(capturedAnalysis); + Assert.True( + capturedAnalysis.IndexOf("weather", StringComparison.OrdinalIgnoreCase) >= 0 || + capturedAnalysis.IndexOf("forecast", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected analysis to mention weather or forecast: {capturedAnalysis}"); + + // Verify that the tool selection was influenced by the analysis. + Assert.NotNull(selectedTools); + Assert.True(selectedTools.Count <= 2, $"Expected at most 2 tools, got {selectedTools.Count}"); + Assert.Contains(selectedTools, t => t.Name == "GetWeatherForecast"); + } + + // Test-only custom strategy: include tools on first request, then remove them afterward. + private sealed class RemoveToolAfterFirstUseStrategy : IToolReductionStrategy + { + private bool _used; + + public Task> SelectToolsForRequestAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken = default) + { + if (!_used && options?.Tools is { Count: > 0 }) + { + _used = true; + // Returning the same instance signals no change. + return Task.FromResult>(options.Tools); + } + + // After first use, remove all tools. + return Task.FromResult>(Array.Empty()); + } + } + + [MemberNotNull(nameof(ChatClient))] protected void SkipIfNotEnabled() { string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; - if (skipIntegration is not null || _chatClient is null) + if (skipIntegration is not null || ChatClient is null) { throw new SkipTestException("Client is not enabled."); } } + + [MemberNotNull(nameof(EmbeddingGenerator))] + protected void EnsureEmbeddingGenerator() + { + EmbeddingGenerator ??= CreateEmbeddingGenerator(); + + if (EmbeddingGenerator is null) + { + throw new SkipTestException("Embedding generator is not enabled."); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs index 1504d0d2488..20423ae9e8b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -124,11 +124,11 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() Assert.Single(activities); var activity = activities.Single(); Assert.StartsWith("embed", activity.DisplayName); - Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); + Assert.Contains(".", (string)activity.GetTagItem("server.address")!); Assert.Equal(embeddingGenerator.GetService()?.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); - Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.response.input_tokens")!); + Assert.NotEqual(0, (int)activity.GetTagItem("gen_ai.usage.input_tokens")!); Assert.True(activity.Duration.TotalMilliseconds > 0); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/HttpHandlerExpectedInput.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/HttpHandlerExpectedInput.cs new file mode 100644 index 00000000000..981e43912c3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/HttpHandlerExpectedInput.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Http; + +namespace Microsoft.Extensions.AI; + +/// Model for expected input to an HTTP handler. +public sealed class HttpHandlerExpectedInput +{ + /// Gets or sets the expected request URI. + public Uri? Uri { get; set; } + + /// Gets or sets the expected request body. + public string? Body { get; set; } + + /// + /// Gets or sets the expected HTTP method. + /// + public HttpMethod? Method { get; set; } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..2cbdcd96abf --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,448 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2000 // Dispose objects before losing scope +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +/// +/// Abstract base class for integration tests that verify ImageGeneratingChatClient with real IChatClient implementations. +/// Concrete test classes should inherit from this and provide a real IChatClient that supports function calling. +/// +public abstract class ImageGeneratingChatClientIntegrationTests : IDisposable +{ + private const string ImageKey = "meai_image"; + private readonly IChatClient? _baseChatClient; + + protected ImageGeneratingChatClientIntegrationTests() + { + _baseChatClient = CreateChatClient(); + ImageGenerator = new(); + + if (_baseChatClient != null) + { + ChatClient = _baseChatClient + .AsBuilder() + .UseImageGeneration(ImageGenerator) + .UseFunctionInvocation() + .Build(); + } + } + + /// Gets the ImageGeneratingChatClient configured with function invocation support. + protected IChatClient? ChatClient { get; } + + /// Gets the IImageGenerator used for testing. + protected CapturingImageGenerator ImageGenerator { get; } + + public void Dispose() + { + ChatClient?.Dispose(); + _baseChatClient?.Dispose(); + ImageGenerator.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Creates the base IChatClient implementation to test with. + /// Should return a real chat client that supports function calling. + /// + /// An IChatClient instance, or null to skip tests. + protected abstract IChatClient? CreateChatClient(); + + /// + /// Helper method to get a chat response using either streaming or non-streaming based on the parameter. + /// + /// Whether to use streaming or non-streaming response. + /// The chat messages to send. + /// The chat options to use. + /// A ChatResponse from either streaming or non-streaming call. + protected async Task GetResponseAsync(bool useStreaming, IEnumerable messages, ChatOptions? options = null, IChatClient? chatClient = null) + { + chatClient ??= ChatClient ?? throw new InvalidOperationException("ChatClient is not initialized."); + + if (useStreaming) + { + return ValidateChatResponse(await chatClient.GetStreamingResponseAsync(messages, options).ToChatResponseAsync()); + } + else + { + return ValidateChatResponse(await chatClient.GetResponseAsync(messages, options)); + } + + static ChatResponse ValidateChatResponse(ChatResponse response) + { + var contents = response.Messages.SelectMany(m => m.Contents).ToArray(); + + List imageIds = []; + foreach (var toolResult in contents.OfType()) + { + Assert.NotNull(toolResult.Outputs); + + foreach (var dataContent in toolResult.Outputs.OfType()) + { + var imageId = dataContent.AdditionalProperties?[ImageKey] as string; + Assert.NotNull(imageId); + imageIds.Add(imageId); + } + } + + foreach (var textContent in contents.OfType()) + { + Assert.DoesNotContain(ImageKey, textContent.Text, StringComparison.OrdinalIgnoreCase); + foreach (var imageId in imageIds) + { + // Ensure no image IDs appear in text content + Assert.DoesNotContain(imageId, textContent.Text, StringComparison.OrdinalIgnoreCase); + } + } + + return response; + } + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task GenerateImage_CallsGenerateFunction_ReturnsDataContent(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, "Please generate an image of a cat")], + chatOptions); + + // Assert + Assert.Single(imageGenerator.GenerateCalls); + var (request, _) = imageGenerator.GenerateCalls[0]; + Assert.Contains("cat", request.Prompt, StringComparison.OrdinalIgnoreCase); + Assert.Null(request.OriginalImages); // Generation, not editing + + // Verify that we get ImageGenerationToolResultContent back in the response + var imageResults = response.Messages + .SelectMany(m => m.Contents) + .OfType(); + + var imageResult = Assert.Single(imageResults); + Assert.NotNull(imageResult.Outputs); + var imageContent = Assert.Single(imageResult.Outputs.OfType()); + Assert.Equal("image/png", imageContent.MediaType); + Assert.False(imageContent.Data.IsEmpty); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task EditImage_WithImageInSameRequest_PassesExactDataContent(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var testImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var originalImageData = new DataContent(testImageData, "image/png") { Name = "original.png" }; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, [new TextContent("Please edit this image to add a red border"), originalImageData])], + chatOptions); + + // Assert + var (request, _) = Assert.Single(imageGenerator.GenerateCalls); + Assert.NotNull(request.OriginalImages); + + var originalImage = Assert.Single(request.OriginalImages); + var originalImageContent = Assert.IsType(originalImage); + Assert.Equal(testImageData, originalImageContent.Data.ToArray()); + Assert.Equal("image/png", originalImageContent.MediaType); + Assert.Equal("original.png", originalImageContent.Name); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task GenerateThenEdit_FromChatHistory_EditsGeneratedImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a dog") + }; + + // First request: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second request: Edit the generated image + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to make it more colorful")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(2, imageGenerator.GenerateCalls.Count); + + // First call should be generation (no original images) + var (firstRequest, _) = imageGenerator.GenerateCalls[0]; + Assert.Null(firstRequest.OriginalImages); + + // Extract the DataContent from the ImageGenerationToolResultContent + var firstToolResultContent = Assert.Single(firstResponse.Messages.SelectMany(m => m.Contents).OfType()); + Assert.NotNull(firstToolResultContent.Outputs); + var firstContent = Assert.Single(firstToolResultContent.Outputs.OfType()); + + // Second call should be editing (with original images) + var (secondRequest, _) = imageGenerator.GenerateCalls[1]; + Assert.Single(secondResponse.Messages.SelectMany(m => m.Contents).OfType().SelectMany(t => t.Outputs!.OfType())); + Assert.NotNull(secondRequest.OriginalImages); + var editContent = Assert.Single(secondRequest.OriginalImages); + Assert.Equal(firstContent, editContent); // Should be the same image as generated in first call + + var editedImage = Assert.IsType(secondRequest.OriginalImages.First()); + Assert.Equal("image/png", editedImage.MediaType); + Assert.Contains("generated_image_1", editedImage.Name); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task MultipleEdits_EditsLatestImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a tree") + }; + + // First: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second: First edit + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to add flowers")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(secondResponse.Messages); + + // Third: Second edit (should edit the latest version by default) + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit that last image to add birds")); + var thirdResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(3, imageGenerator.GenerateCalls.Count); + + // Third call should edit the second generated image (from first edit), not the original + var (thirdRequest, _) = imageGenerator.GenerateCalls[2]; + Assert.NotNull(thirdRequest.OriginalImages); + + // Extract the DataContent from the second response's ImageGenerationToolResultContent + var secondToolResultContent = Assert.Single(secondResponse.Messages.SelectMany(m => m.Contents).OfType()); + var secondImage = Assert.Single(secondToolResultContent.Outputs!.OfType()); + var lastImageToEdit = Assert.Single(thirdRequest.OriginalImages.OfType()); + Assert.Equal(secondImage, lastImageToEdit); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task MultipleEdits_EditsFirstImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a tree") + }; + + // First: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second: First edit + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to add fruit")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(secondResponse.Messages); + + // Third: Second edit (should edit the latest version by default) + chatHistory.Add(new ChatMessage(ChatRole.User, "That didn't work out. Please edit the original image to add birds")); + var thirdResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(3, imageGenerator.GenerateCalls.Count); + + // Third call should edit the original generated image (not from edit) + var (thirdRequest, _) = imageGenerator.GenerateCalls[2]; + Assert.NotNull(thirdRequest.OriginalImages); + + // Extract the DataContent from the first response's ImageGenerationToolResultContent + var firstToolResultContent = Assert.Single(firstResponse.Messages.SelectMany(m => m.Contents).OfType()); + var firstGeneratedImage = Assert.Single(firstToolResultContent.Outputs!.OfType()); + var lastImageToEdit = Assert.IsType(thirdRequest.OriginalImages.First()); + Assert.Equal(firstGeneratedImage, lastImageToEdit); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task ImageGeneration_WithOptions_PassesOptionsToGenerator(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var imageGenerationOptions = new ImageGenerationOptions + { + Count = 2, + ImageSize = new System.Drawing.Size(512, 512) + }; + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool { Options = imageGenerationOptions }] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, "Generate an image of a castle")], + chatOptions); + + // Assert + Assert.Single(imageGenerator.GenerateCalls); + var (_, options) = imageGenerator.GenerateCalls[0]; + Assert.NotNull(options); + Assert.Equal(2, options.Count); + Assert.Equal(new System.Drawing.Size(512, 512), options.ImageSize); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task ImageContentHandling_AllImages_ReplacesImagesWithPlaceholders(bool useStreaming) + { + SkipIfNotEnabled(); + + var testImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var capturedMessages = new List>(); + + // Create a new ImageGeneratingChatClient with AllImages data content handling + using var imageGeneratingClient = _baseChatClient! + .AsBuilder() + .UseImageGeneration(ImageGenerator) + .Use((messages, options, next, cancellationToken) => + { + capturedMessages.Add(messages); + return next(messages, options, cancellationToken); + }) + .UseFunctionInvocation() + .Build(); + + var originalImage = new DataContent(testImageData, "image/png") { Name = "test.png" }; + + // Act + await GetResponseAsync(useStreaming, + [ + new ChatMessage(ChatRole.User, + [ + new TextContent("Here's an image to process"), + originalImage + ]) + ], + new ChatOptions { Tools = [new HostedImageGenerationTool()] }, + imageGeneratingClient); + + // Assert + Assert.NotEmpty(capturedMessages); + var processedMessages = capturedMessages.First().ToList(); + var userMessage = processedMessages.First(m => m.Role == ChatRole.User); + + // Should have text content with placeholder instead of original image + var textContents = userMessage.Contents.OfType().ToList(); + Assert.Contains(textContents, tc => tc.Text.Contains(ImageKey) && tc.Text.Contains("] available for edit")); + + // Should not contain the original DataContent + Assert.DoesNotContain(userMessage.Contents, c => c == originalImage); + } + + /// + /// Test image generator that captures calls and returns fake image data. + /// + protected sealed class CapturingImageGenerator : IImageGenerator + { + private const string TestImageMediaType = "image/png"; + private static readonly byte[] _testImageData = [0x89, 0x50, 0x4E, 0x47]; // PNG header + + public List<(ImageGenerationRequest request, ImageGenerationOptions? options)> GenerateCalls { get; } = []; + public int ImageCounter { get; private set; } + + public Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + GenerateCalls.Add((request, options)); + + // Create fake image data with unique content + var imageData = new byte[_testImageData.Length + 4]; + _testImageData.CopyTo(imageData, 0); + BitConverter.GetBytes(++ImageCounter).CopyTo(imageData, _testImageData.Length); + + var imageContent = new DataContent(imageData, TestImageMediaType) + { + Name = $"generated_image_{ImageCounter}.png" + }; + + return Task.FromResult(new ImageGenerationResponse([imageContent])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // No resources to dispose + } + } + + [MemberNotNull(nameof(ChatClient))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || ChatClient is null) + { + throw new SkipTestException("Client is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..76b08941bc5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +public abstract class ImageGeneratorIntegrationTests : IDisposable +{ + private readonly IImageGenerator? _generator; + + protected ImageGeneratorIntegrationTests() + { + _generator = CreateGenerator(); + } + + public void Dispose() + { + _generator?.Dispose(); + GC.SuppressFinalize(this); + } + + protected abstract IImageGenerator? CreateGenerator(); + + [ConditionalFact] + public virtual async Task GenerateImagesAsync_SingleImageGeneration() + { + SkipIfNotEnabled(); + + var options = new ImageGenerationOptions + { + Count = 1 + }; + + var response = await _generator.GenerateImagesAsync("A simple drawing of a house", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + + var content = Assert.Single(response.Contents); + switch (content) + { + case UriContent uc: + Assert.StartsWith("http", uc.Uri.Scheme, StringComparison.Ordinal); + break; + + case DataContent dc: + Assert.False(dc.Data.IsEmpty); + Assert.StartsWith("image/", dc.MediaType, StringComparison.Ordinal); + break; + + default: + Assert.Fail($"Unexpected content type: {content.GetType()}"); + break; + } + } + + [ConditionalFact] + public virtual async Task GenerateImagesAsync_MultipleImages() + { + SkipIfNotEnabled(); + + var options = new ImageGenerationOptions + { + Count = 2 + }; + + var response = await _generator.GenerateImagesAsync("A cat sitting on a table", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Equal(2, response.Contents.Count); + + foreach (var content in response.Contents) + { + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + } + + [ConditionalFact] + public virtual async Task EditImagesAsync_SingleImage() + { + SkipIfNotEnabled(); + + var imageData = GetImageData("dotnet.png"); + AIContent[] originalImages = [new DataContent(imageData, "image/png") { Name = "dotnet.png" }]; + + var options = new ImageGenerationOptions + { + Count = 1 + }; + + var response = await _generator.EditImagesAsync(originalImages, "Add a red border and make the background tie-dye", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + + var content = response.Contents[0]; + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + + private static byte[] GetImageData(string fileName) + { + using Stream? s = typeof(ImageGeneratorIntegrationTests).Assembly.GetManifestResourceStream($"Microsoft.Extensions.AI.Resources.{fileName}"); + Assert.NotNull(s); + using MemoryStream ms = new(); + s.CopyTo(ms); + return ms.ToArray(); + } + + [MemberNotNull(nameof(_generator))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || _generator is null) + { + throw new SkipTestException("Generator is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj index ddc72caa90d..bd6c6b9ba2f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj @@ -8,6 +8,7 @@ $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 $(NoWarn);MEAI001 + $(NoWarn);S104 true @@ -25,13 +26,14 @@ Never - + + @@ -44,7 +46,6 @@ - diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs index ea87408da38..5a7bf0b246e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/QuantizationEmbeddingGenerator.cs @@ -52,7 +52,7 @@ private static BinaryEmbedding QuantizeToBinary(Embedding embedding) { if (vector[i] > 0) { - result[i / 8] = true; + result[i] = true; } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md index 3b99e9bccc1..988ab2d08f5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md @@ -17,6 +17,7 @@ Optionally also run the following. The values shown here are the defaults if you ``` dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +dotnet user-secrets set OpenAI:ImageModel dall-e-3 ``` ### Configuring OpenAI tests (Azure OpenAI) @@ -35,6 +36,7 @@ Optionally also run the following. The values shown here are the defaults if you ``` dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +dotnet user-secrets set OpenAI:ImageModel dall-e-3 ``` Your account must have models matching these names. diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index f2ec8ebdba0..dee92f67145 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.ML.Tokenizers; @@ -13,7 +12,6 @@ #pragma warning disable S103 // Lines should not be too long #pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously namespace Microsoft.Extensions.AI; @@ -57,64 +55,6 @@ public async Task Reduction_LimitsMessagesBasedOnTokenLimit() } } -/// Provides an example of a chat client for reducing the size of a message list. -public sealed class ReducingChatClient : DelegatingChatClient -{ - private readonly IChatReducer _reducer; - - /// Initializes a new instance of the class. - /// The inner client. - /// The reducer to be used by this instance. - public ReducingChatClient(IChatClient innerClient, IChatReducer reducer) - : base(innerClient) - { - _reducer = Throw.IfNull(reducer); - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); - - return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - } -} - -/// Represents a reducer capable of shrinking the size of a list of chat messages. -public interface IChatReducer -{ - /// Reduces the size of a list of chat messages. - /// The messages. - /// The to monitor for cancellation requests. The default is . - /// The new list of messages, or if no reduction need be performed or was true. - Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); -} - -/// Provides extensions for configuring instances. -public static class ReducingChatClientExtensions -{ - public static ChatClientBuilder UseChatReducer(this ChatClientBuilder builder, IChatReducer reducer) - { - _ = Throw.IfNull(builder); - _ = Throw.IfNull(reducer); - - return builder.Use(innerClient => new ReducingChatClient(innerClient, reducer)); - } -} - /// An that culls the oldest messages once a certain token threshold is reached. public sealed class TokenCountingChatReducer : IChatReducer { @@ -127,7 +67,7 @@ public TokenCountingChatReducer(Tokenizer tokenizer, int tokenLimit) _tokenLimit = Throw.IfLessThan(tokenLimit, 1); } - public async Task> ReduceAsync( + public async Task> ReduceAsync( IEnumerable messages, CancellationToken cancellationToken) { _ = Throw.IfNull(messages); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ToolReductionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ToolReductionTests.cs new file mode 100644 index 00000000000..436d657acfa --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ToolReductionTests.cs @@ -0,0 +1,661 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ToolReductionTests +{ + [Fact] + public void EmbeddingToolReductionStrategy_Constructor_ThrowsWhenToolLimitIsLessThanOrEqualToZero() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + Assert.Throws("toolLimit", () => new EmbeddingToolReductionStrategy(gen, toolLimit: 0)); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_NoReduction_WhenToolsBelowLimit() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 5); + + var tools = CreateTools("Weather", "Math"); + var options = new ChatOptions { Tools = tools }; + + var result = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "Tell me about weather") }, + options); + + Assert.Same(tools, result); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_NoReduction_WhenOptionalToolsBelowLimit() + { + // 1 required + 2 optional, limit = 2 (optional count == limit) => original list returned + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2) + { + IsRequiredTool = t => t.Name == "Req" + }; + + var tools = CreateTools("Req", "Opt1", "Opt2"); + var result = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "anything") }, + new ChatOptions { Tools = tools }); + + Assert.Same(tools, result); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_Reduces_ToLimit_BySimilarity() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + + var tools = CreateTools("Weather", "Translate", "Math", "Jokes"); + var options = new ChatOptions { Tools = tools }; + + var messages = new[] + { + new ChatMessage(ChatRole.User, "Can you do some weather math for forecasting?") + }; + + var reduced = (await strategy.SelectToolsForRequestAsync(messages, options)).ToList(); + + Assert.Equal(2, reduced.Count); + Assert.Contains(reduced, t => t.Name == "Weather"); + Assert.Contains(reduced, t => t.Name == "Math"); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_PreserveOriginalOrdering_ReordersAfterSelection() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2) + { + PreserveOriginalOrdering = true + }; + + var tools = CreateTools("Math", "Translate", "Weather"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "Explain weather math please") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(2, reduced.Count); + Assert.Equal("Math", reduced[0].Name); + Assert.Equal("Weather", reduced[1].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_Caching_AvoidsReEmbeddingTools() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("Weather", "Math", "Jokes"); + var messages = new[] { new ChatMessage(ChatRole.User, "weather") }; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + int afterFirst = gen.TotalValueInputs; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + int afterSecond = gen.TotalValueInputs; + + // +1 for second query embedding only + Assert.Equal(afterFirst + 1, afterSecond); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_OptionsNullOrNoTools_ReturnsEmptyOrOriginal() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + + var empty = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "anything") }, null); + Assert.Empty(empty); + + var options = new ChatOptions { Tools = [] }; + var result = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather") }, options); + Assert.Same(options.Tools, result); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_CustomSimilarity_InvertsOrdering() + { + using var gen = new VectorBasedTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + Similarity = (q, t) => -t.Span[0] + }; + + var highTool = new SimpleTool("HighScore", "alpha"); + var lowTool = new SimpleTool("LowScore", "beta"); + gen.VectorSelector = text => text.Contains("alpha") ? 10f : 1f; + + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "Pick something") }, + new ChatOptions { Tools = [highTool, lowTool] })).ToList(); + + Assert.Single(reduced); + Assert.Equal("LowScore", reduced[0].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_TieDeterminism_PrefersLowerOriginalIndex() + { + // Generator returns identical vectors so similarity ties; we expect original order preserved + using var gen = new ConstantEmbeddingGenerator(3); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + + var tools = CreateTools("T1", "T2", "T3", "T4"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "any") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(2, reduced.Count); + Assert.Equal("T1", reduced[0].Name); + Assert.Equal("T2", reduced[1].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultEmbeddingTextSelector_EmptyDescription_UsesNameOnly() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + + var target = new SimpleTool("ComputeSum", description: ""); + var filler = new SimpleTool("Other", "Unrelated"); + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "math") }, + new ChatOptions { Tools = [target, filler] }); + + Assert.Contains("ComputeSum", recorder.Inputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultEmbeddingTextSelector_EmptyName_UsesDescriptionOnly() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + + var target = new SimpleTool("", description: "Translates between languages."); + var filler = new SimpleTool("Other", "Unrelated"); + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "translate") }, + new ChatOptions { Tools = [target, filler] }); + + Assert.Contains("Translates between languages.", recorder.Inputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_CustomEmbeddingTextSelector_Applied() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1) + { + ToolEmbeddingTextSelector = t => $"NAME:{t.Name}|DESC:{t.Description}" + }; + + var target = new SimpleTool("WeatherTool", "Gets forecast."); + var filler = new SimpleTool("Other", "Irrelevant"); + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather") }, + new ChatOptions { Tools = [target, filler] }); + + Assert.Contains("NAME:WeatherTool|DESC:Gets forecast.", recorder.Inputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_MessagesEmbeddingTextSelector_CustomFiltersMessages() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("Weather", "Math", "Translate"); + + var messages = new[] + { + new ChatMessage(ChatRole.User, "Please tell me the weather tomorrow."), + new ChatMessage(ChatRole.Assistant, "Sure, I can help."), + new ChatMessage(ChatRole.User, "Now instead solve a math problem.") + }; + + strategy.MessagesEmbeddingTextSelector = msgs => new ValueTask(msgs.LastOrDefault()?.Text ?? string.Empty); + + var reduced = (await strategy.SelectToolsForRequestAsync( + messages, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Single(reduced); + Assert.Equal("Math", reduced[0].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_MessagesEmbeddingTextSelector_InvokedOnce() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("Weather", "Math"); + int invocationCount = 0; + + strategy.MessagesEmbeddingTextSelector = msgs => + { + invocationCount++; + return new ValueTask(string.Join("\n", msgs.Select(m => m.Text))); + }; + + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather and math") }, + new ChatOptions { Tools = tools }); + + Assert.Equal(1, invocationCount); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultMessagesEmbeddingTextSelector_IncludesReasoningContent() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + var tools = CreateTools("Weather", "Math"); + + var reasoningLine = "Thinking about the best way to get tomorrow's forecast..."; + var answerLine = "Tomorrow will be sunny."; + var userLine = "What's the weather tomorrow?"; + + var messages = new[] + { + new ChatMessage(ChatRole.User, userLine), + new ChatMessage(ChatRole.Assistant, + [ + new TextReasoningContent(reasoningLine), + new TextContent(answerLine) + ]) + }; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + + string queryInput = recorder.Inputs[0]; + + Assert.Contains(userLine, queryInput); + Assert.Contains(reasoningLine, queryInput); + Assert.Contains(answerLine, queryInput); + + var userIndex = queryInput.IndexOf(userLine, StringComparison.Ordinal); + var reasoningIndex = queryInput.IndexOf(reasoningLine, StringComparison.Ordinal); + var answerIndex = queryInput.IndexOf(answerLine, StringComparison.Ordinal); + Assert.True(userIndex >= 0 && reasoningIndex > userIndex && answerIndex > reasoningIndex); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultMessagesEmbeddingTextSelector_SkipsNonTextContent() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + var tools = CreateTools("Alpha", "Beta"); + + var textOnly = "Provide translation."; + var messages = new[] + { + new ChatMessage(ChatRole.User, + [ + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new TextContent(textOnly) + ]) + }; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + + var queryInput = recorder.Inputs[0]; + Assert.Contains(textOnly, queryInput); + Assert.DoesNotContain("application/octet-stream", queryInput, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_RequiredToolAlwaysIncluded() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + IsRequiredTool = t => t.Name == "Core" + }; + + var tools = CreateTools("Core", "Weather", "Math"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "math") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(2, reduced.Count); // required + one optional (limit=1) + Assert.Contains(reduced, t => t.Name == "Core"); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_MultipleRequiredTools_ExceedLimit_AllRequiredIncluded() + { + // 3 required, limit=1 => expect 3 required + 1 ranked optional = 4 total + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + IsRequiredTool = t => t.Name.StartsWith("R", StringComparison.Ordinal) + }; + + var tools = CreateTools("R1", "R2", "R3", "Weather", "Math"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather math") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(4, reduced.Count); + Assert.Equal(3, reduced.Count(t => t.Name.StartsWith("R"))); + } + + [Fact] + public async Task ToolReducingChatClient_ReducesTools_ForGetResponseAsync() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + var tools = CreateTools("Weather", "Math", "Translate", "Jokes"); + + IList? observedTools = null; + + using var inner = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, ct) => + { + observedTools = options?.Tools; + return Task.FromResult(new ChatResponse()); + } + }; + + using var client = inner.AsBuilder().UseToolReduction(strategy).Build(); + + await client.GetResponseAsync( + new[] { new ChatMessage(ChatRole.User, "weather math please") }, + new ChatOptions { Tools = tools }); + + Assert.NotNull(observedTools); + Assert.Equal(2, observedTools!.Count); + Assert.Contains(observedTools, t => t.Name == "Weather"); + Assert.Contains(observedTools, t => t.Name == "Math"); + } + + [Fact] + public async Task ToolReducingChatClient_ReducesTools_ForStreaming() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + var tools = CreateTools("Weather", "Math"); + + IList? observedTools = null; + + using var inner = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, ct) => + { + observedTools = options?.Tools; + return EmptyAsyncEnumerable(); + } + }; + + using var client = inner.AsBuilder().UseToolReduction(strategy).Build(); + + await foreach (var _ in client.GetStreamingResponseAsync( + new[] { new ChatMessage(ChatRole.User, "math") }, + new ChatOptions { Tools = tools })) + { + // Consume + } + + Assert.NotNull(observedTools); + Assert.Single(observedTools!); + Assert.Equal("Math", observedTools![0].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_EmptyQuery_NoReduction() + { + // Arrange: more tools than limit so we'd normally reduce, but query is empty -> return full list unchanged. + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("ToolA", "ToolB", "ToolC"); + var options = new ChatOptions { Tools = tools }; + + // Empty / whitespace message text produces empty query. + var messages = new[] { new ChatMessage(ChatRole.User, " ") }; + + // Act + var result = await strategy.SelectToolsForRequestAsync(messages, options); + + // Assert: same reference (no reduction), and generator not invoked at all. + Assert.Same(tools, result); + Assert.Equal(0, gen.TotalValueInputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_EmptyQuery_NoReduction_WithRequiredTool() + { + // Arrange: required tool + optional tools; still should return original set when query is empty. + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + IsRequiredTool = t => t.Name == "Req" + }; + + var tools = CreateTools("Req", "Optional1", "Optional2"); + var options = new ChatOptions { Tools = tools }; + + var messages = new[] { new ChatMessage(ChatRole.User, " ") }; + + // Act + var result = await strategy.SelectToolsForRequestAsync(messages, options); + + // Assert + Assert.Same(tools, result); + Assert.Equal(0, gen.TotalValueInputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_EmptyQuery_ViaCustomMessagesSelector_NoReduction() + { + // Arrange: force empty query through custom selector returning whitespace. + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + MessagesEmbeddingTextSelector = _ => new ValueTask(" ") + }; + + var tools = CreateTools("One", "Two"); + var messages = new[] + { + new ChatMessage(ChatRole.User, "This content will be ignored by custom selector.") + }; + + // Act + var result = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + + // Assert: no reduction and no embeddings generated. + Assert.Same(tools, result); + Assert.Equal(0, gen.TotalValueInputs); + } + + private static List CreateTools(params string[] names) => + names.Select(n => (AITool)new SimpleTool(n, $"Description about {n}")).ToList(); + + private static async IAsyncEnumerable EmptyAsyncEnumerable() + { + yield break; + } + + private sealed class SimpleTool : AITool + { + private readonly string _name; + private readonly string _description; + + public SimpleTool(string name, string description) + { + _name = name; + _description = description; + } + + public override string Name => _name; + public override string Description => _description; + } + + /// + /// Deterministic embedding generator producing sparse keyword indicator vectors. + /// Each dimension corresponds to a known keyword. Cosine similarity then reflects + /// pure keyword overlap (non-overlapping keywords contribute nothing), avoiding + /// false ties for tools unrelated to the query. + /// + private sealed class DeterministicTestEmbeddingGenerator : IEmbeddingGenerator> + { + private static readonly string[] _keywords = + [ + "weather","forecast","temperature","math","calculate","sum","translate","language","joke" + ]; + + // +1 bias dimension (last) to avoid zero magnitude vectors when no keywords present. + private static int VectorLength => _keywords.Length + 1; + + public int TotalValueInputs { get; private set; } + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var list = new List>(); + + foreach (var v in values) + { + TotalValueInputs++; + var vec = new float[VectorLength]; + if (!string.IsNullOrWhiteSpace(v)) + { + var lower = v.ToLowerInvariant(); + for (int i = 0; i < _keywords.Length; i++) + { + if (lower.Contains(_keywords[i])) + { + vec[i] = 1f; + } + } + } + + vec[^1] = 1f; // bias + list.Add(new Embedding(vec)); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // No-op + } + } + + private sealed class RecordingEmbeddingGenerator : IEmbeddingGenerator> + { + public List Inputs { get; } = new(); + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var v in values) + { + Inputs.Add(v); + + // Basic 2-dim vector (length encodes a bit of variability) + list.Add(new Embedding(new float[] { v.Length, 1f })); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } + + private sealed class VectorBasedTestEmbeddingGenerator : IEmbeddingGenerator> + { + public Func VectorSelector { get; set; } = _ => 1f; + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var v in values) + { + list.Add(new Embedding(new float[] { VectorSelector(v), 1f })); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } + + private sealed class ConstantEmbeddingGenerator : IEmbeddingGenerator> + { + private readonly float[] _vector; + public ConstantEmbeddingGenerator(int dims) + { + _vector = Enumerable.Repeat(1f, dims).ToArray(); + } + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var _ in values) + { + list.Add(new Embedding(_vector)); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } + + private sealed class TestChatClient : IChatClient + { + public Func, ChatOptions?, CancellationToken, Task>? GetResponseAsyncCallback { get; set; } + public Func, ChatOptions?, CancellationToken, IAsyncEnumerable>? GetStreamingResponseAsyncCallback { get; set; } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + (GetResponseAsyncCallback ?? throw new InvalidOperationException())(messages, options, cancellationToken); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + (GetStreamingResponseAsyncCallback ?? throw new InvalidOperationException())(messages, options, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs index 8b5f1973348..5c7e6ee2ecb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Text; using System.Text.Json.Nodes; @@ -21,44 +22,88 @@ namespace Microsoft.Extensions.AI; /// An that checks the request body against an expected one /// and sends back an expected response. /// -public sealed class VerbatimHttpHandler(string expectedInput, string expectedOutput, bool validateExpectedResponse = false) : - DelegatingHandler(new HttpClientHandler()) +public sealed class VerbatimHttpHandler : DelegatingHandler { + private readonly string _expectedOutput; + private readonly bool _validateExpectedResponse; + private readonly HttpHandlerExpectedInput _expectedInput; + + public VerbatimHttpHandler(string expectedInput, string expectedOutput, bool validateExpectedResponse = false) + : this(new HttpHandlerExpectedInput { Body = expectedInput }, expectedOutput, validateExpectedResponse) + { + } + + public VerbatimHttpHandler(HttpHandlerExpectedInput expectedInput, string expectedOutput, bool validateExpectedResponse = false) + : base(new HttpClientHandler()) + { + _expectedOutput = expectedOutput; + _validateExpectedResponse = validateExpectedResponse; + _expectedInput = expectedInput; + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - Assert.NotNull(request.Content); + if (_expectedInput.Body is not null) + { + Assert.NotNull(request.Content); - string? actualInput = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + string? actualInput = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.NotNull(actualInput); - AssertEqualNormalized(expectedInput, actualInput); + Assert.NotNull(actualInput); + AssertEqualNormalized(_expectedInput.Body, actualInput); - if (validateExpectedResponse) - { - ByteArrayContent newContent = new(Encoding.UTF8.GetBytes(actualInput)); - foreach (var header in request.Content.Headers) + if (_validateExpectedResponse) { - newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + ByteArrayContent newContent = new(Encoding.UTF8.GetBytes(actualInput)); + foreach (var header in request.Content.Headers) + { + newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + request.Content = newContent; } + } + + if (_expectedInput.Uri is not null) + { + Assert.Equal(_expectedInput.Uri, request.RequestUri); + } - request.Content = newContent; + if (_expectedInput.Method is not null) + { + Assert.Equal(_expectedInput.Method, request.Method); + } + if (_validateExpectedResponse) + { using var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); string? actualOutput = await response.Content.ReadAsStringAsync().ConfigureAwait(false); Assert.NotNull(actualOutput); - AssertEqualNormalized(expectedOutput, actualOutput); + AssertEqualNormalized(_expectedOutput, actualOutput); } - return new() { Content = new StringContent(expectedOutput) }; + return new() { Content = new StringContent(_expectedOutput) }; } - public static string? RemoveWhiteSpace(string? text) => - text is null ? null : - Regex.Replace(text, @"\s*", string.Empty); + [return: NotNullIfNotNull(nameof(text))] + public static string? RemoveWhiteSpace(string? text) + { + if (text is null) + { + return null; + } + + text = text.Replace("\\r", "").Replace("\\n", "").Replace("\\t", ""); + + return Regex.Replace(text, @"\s*", string.Empty); + } private static void AssertEqualNormalized(string expected, string actual) { + expected = RemoveWhiteSpace(expected); + actual = RemoveWhiteSpace(actual); + // First try to compare as JSON. JsonNode? expectedNode = null; JsonNode? actualNode = null; @@ -82,10 +127,7 @@ private static void AssertEqualNormalized(string expected, string actual) } // Legitimately may not have been JSON. Fall back to whitespace normalization. - if (RemoveWhiteSpace(expected) != RemoveWhiteSpace(actual)) - { - FailNotEqual(expected, actual); - } + FailNotEqual(expected, actual); } private static void FailNotEqual(string expected, string actual) => diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj deleted file mode 100644 index 5db789e3b6b..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Microsoft.Extensions.AI - Unit tests for Microsoft.Extensions.AI.Ollama - - - - true - - - - - - - - - - - - - - diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs deleted file mode 100644 index 83e84e49f5b..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.TestUtilities; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OllamaChatClientIntegrationTests : ChatClientIntegrationTests -{ - protected override IChatClient? CreateChatClient() => - IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? - new OllamaChatClient(endpoint, "llama3.1") : - null; - - public override Task FunctionInvocation_RequireAny() => - throw new SkipTestException("Ollama does not currently support requiring function invocation."); - - public override Task FunctionInvocation_RequireSpecific() => - throw new SkipTestException("Ollama does not currently support requiring function invocation."); - - protected override string? GetModel_MultiModal_DescribeImage() => "llava"; - - [ConditionalFact] - public async Task PromptBasedFunctionCalling_NoArgs() - { - SkipIfNotEnabled(); - - using var chatClient = CreateChatClient()! - .AsBuilder() - .UseFunctionInvocation() - .UsePromptBasedFunctionCalling() - .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Build(); - - var secretNumber = 42; - var response = await chatClient.GetResponseAsync("What is the current secret number? Answer with digits only.", new ChatOptions - { - ModelId = "llama3:8b", - Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], - Temperature = 0, - Seed = 0, - }); - - Assert.Contains(secretNumber.ToString(), response.Text); - } - - [ConditionalFact] - public async Task PromptBasedFunctionCalling_WithArgs() - { - SkipIfNotEnabled(); - - using var chatClient = CreateChatClient()! - .AsBuilder() - .UseFunctionInvocation() - .UsePromptBasedFunctionCalling() - .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Build(); - - var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( - [Description("The ticker symbol")] string symbol, - [Description("The currency code such as USD or JPY")] string currency) => - { - Assert.Equal("MSFT", symbol); - Assert.Equal("GBP", currency); - return 999; - }, "GetStockPrice"); - - var didCallIrrelevantTool = false; - var irrelevantTool = AIFunctionFactory.Create(() => { didCallIrrelevantTool = true; return 123; }, "GetSecretNumber"); - - var response = await chatClient.GetResponseAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions - { - Tools = [stockPriceTool, irrelevantTool], - Temperature = 0, - Seed = 0, - }); - - Assert.Contains("999", response.Text); - Assert.False(didCallIrrelevantTool); - } - - [ConditionalFact] - public async Task InvalidModelParameter_ThrowsInvalidOperationException() - { - SkipIfNotEnabled(); - - var endpoint = IntegrationTestHelpers.GetOllamaUri(); - Assert.NotNull(endpoint); - - using var chatClient = new OllamaChatClient(endpoint, modelId: "inexistent-model"); - - InvalidOperationException ex; - ex = await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("Hello, world!")); - Assert.Contains("inexistent-model", ex.Message); - - ex = await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("Hello, world!").ToChatResponseAsync()); - Assert.Contains("inexistent-model", ex.Message); - } - - private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) - { - public override Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - Assert.Null(options?.Tools); - return base.GetResponseAsync(messages, options, cancellationToken); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs deleted file mode 100644 index 2f716d2fe7d..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ /dev/null @@ -1,487 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Net.Http; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public class OllamaChatClientTests -{ - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws("endpoint", () => new OllamaChatClient((Uri)null!)); - Assert.Throws("modelId", () => new OllamaChatClient("http://localhost", " ")); - } - - [Fact] - public void ToolCallJsonSerializerOptions_HasExpectedValue() - { - using OllamaChatClient client = new("http://localhost", "model"); - - Assert.Same(client.ToolCallJsonSerializerOptions, AIJsonUtilities.DefaultOptions); - Assert.Throws("value", () => client.ToolCallJsonSerializerOptions = null!); - - JsonSerializerOptions options = new(); - client.ToolCallJsonSerializerOptions = options; - Assert.Same(options, client.ToolCallJsonSerializerOptions); - } - - [Fact] - public void GetService_SuccessfullyReturnsUnderlyingClient() - { - using OllamaChatClient client = new("http://localhost"); - - Assert.Same(client, client.GetService()); - Assert.Same(client, client.GetService()); - - using IChatClient pipeline = client - .AsBuilder() - .UseFunctionInvocation() - .UseOpenTelemetry() - .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - - Assert.Same(client, pipeline.GetService()); - Assert.IsType(pipeline.GetService()); - } - - [Fact] - public void Ctor_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - using IChatClient chatClient = new OllamaChatClient(endpoint, model); - var metadata = chatClient.GetService(); - Assert.NotNull(metadata); - Assert.Equal("ollama", metadata.ProviderName); - Assert.Equal(endpoint, metadata.ProviderUri); - Assert.Equal(model, metadata.DefaultModelId); - } - - [Fact] - public async Task BasicRequestResponse_NonStreaming() - { - const string Input = """ - { - "model":"llama3.1", - "messages":[{"role":"user","content":"hello"}], - "stream":false, - "options":{"num_predict":10,"temperature":0.5} - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T15:46:10.5248793Z", - "message": { - "role": "assistant", - "content": "Hello! How are you today? Is there something" - }, - "done_reason": "length", - "done": true, - "total_duration": 22186844400, - "load_duration": 17947219100, - "prompt_eval_count": 11, - "prompt_eval_duration": 1953805000, - "eval_count": 10, - "eval_duration": 2277274000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using OllamaChatClient client = new("http://localhost:11434", "llama3.1", httpClient); - var response = await client.GetResponseAsync("hello", new() - { - MaxOutputTokens = 10, - Temperature = 0.5f, - }); - Assert.NotNull(response); - - Assert.Equal("Hello! How are you today? Is there something", response.Text); - Assert.Single(response.Messages.Single().Contents); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T15:46:10.5248793Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Length, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(11, response.Usage.InputTokenCount); - Assert.Equal(10, response.Usage.OutputTokenCount); - Assert.Equal(21, response.Usage.TotalTokenCount); - } - - [Fact] - public async Task BasicRequestResponse_Streaming() - { - const string Input = """ - { - "model":"llama3.1", - "messages":[{"role":"user","content":"hello"}], - "stream":true, - "options":{"num_predict":20,"temperature":0.5} - } - """; - - const string Output = """ - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.4965315Z","message":{"role":"assistant","content":"Hello"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.763058Z","message":{"role":"assistant","content":"!"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.9751134Z","message":{"role":"assistant","content":" How"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.1788125Z","message":{"role":"assistant","content":" are"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.3883171Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.5912498Z","message":{"role":"assistant","content":" today"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.7968039Z","message":{"role":"assistant","content":"?"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.0034152Z","message":{"role":"assistant","content":" Is"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.1931196Z","message":{"role":"assistant","content":" there"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.3827484Z","message":{"role":"assistant","content":" something"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.5659027Z","message":{"role":"assistant","content":" I"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.7488871Z","message":{"role":"assistant","content":" can"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.9339881Z","message":{"role":"assistant","content":" help"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.1201564Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.303447Z","message":{"role":"assistant","content":" with"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.4964909Z","message":{"role":"assistant","content":" or"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.6837816Z","message":{"role":"assistant","content":" would"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.8723142Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.064613Z","message":{"role":"assistant","content":" like"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.2504498Z","message":{"role":"assistant","content":" to"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.2514508Z","message":{"role":"assistant","content":""},"done_reason":"length", "done":true,"total_duration":11912402900,"load_duration":6824559200,"prompt_eval_count":11,"prompt_eval_duration":1329601000,"eval_count":20,"eval_duration":3754262000} - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient); - - List updates = []; - var streamingResponse = client.GetStreamingResponseAsync("hello", new() - { - MaxOutputTokens = 20, - Temperature = 0.5f, - }); - await foreach (var update in streamingResponse) - { - updates.Add(update); - } - - Assert.Equal(21, updates.Count); - - DateTimeOffset[] createdAts = Regex.Matches(Output, @"2024.*?Z").Cast().Select(m => DateTimeOffset.Parse(m.Value)).ToArray(); - - for (int i = 0; i < updates.Count; i++) - { - Assert.NotNull(updates[i].ResponseId); - Assert.NotNull(updates[i].MessageId); - Assert.Equal(i < updates.Count - 1 ? 1 : 2, updates[i].Contents.Count); - Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.Equal("llama3.1", updates[i].ModelId); - Assert.Equal(createdAts[i], updates[i].CreatedAt); - Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Length, updates[i].FinishReason); - } - - Assert.Equal("Hello! How are you today? Is there something I can help you with or would you like to", string.Concat(updates.Select(u => u.Text))); - Assert.Equal(2, updates[updates.Count - 1].Contents.Count); - Assert.IsType(updates[updates.Count - 1].Contents[0]); - UsageContent usage = Assert.IsType(updates[updates.Count - 1].Contents[1]); - Assert.Equal(11, usage.Details.InputTokenCount); - Assert.Equal(20, usage.Details.OutputTokenCount); - Assert.Equal(31, usage.Details.TotalTokenCount); - - var chatResponse = await streamingResponse.ToChatResponseAsync(); - Assert.Single(Assert.Single(chatResponse.Messages).Contents); - Assert.Equal("Hello! How are you today? Is there something I can help you with or would you like to", chatResponse.Text); - } - - [Fact] - public async Task MultipleMessages_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "hello!" - }, - { - "role": "assistant", - "content": "hi, how are you?" - }, - { - "role": "user", - "content": "i\u0027m good. how are you?" - } - ], - "stream": false, - "options": { - "frequency_penalty": 0.75, - "presence_penalty": 0.5, - "seed": 42, - "stop": ["great"], - "temperature": 0.25 - } - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T17:18:46.308987Z", - "message": { - "role": "assistant", - "content": "I'm just a computer program, so I don't have feelings or emotions like humans do, but I'm functioning properly and ready to help with any questions or tasks you may have! How about we chat about something in particular or just shoot the breeze? Your choice!" - }, - "done_reason": "stop", - "done": true, - "total_duration": 23229369000, - "load_duration": 7724086300, - "prompt_eval_count": 36, - "prompt_eval_duration": 4245660000, - "eval_count": 55, - "eval_duration": 11256470000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient("http://localhost:11434", httpClient: httpClient); - - List messages = - [ - new(ChatRole.User, "hello!"), - new(ChatRole.Assistant, "hi, how are you?"), - new(ChatRole.User, "i'm good. how are you?"), - ]; - - var response = await client.GetResponseAsync(messages, new() - { - ModelId = "llama3.1", - Temperature = 0.25f, - FrequencyPenalty = 0.75f, - PresencePenalty = 0.5f, - StopSequences = ["great"], - Seed = 42, - }); - Assert.NotNull(response); - - Assert.Equal( - VerbatimHttpHandler.RemoveWhiteSpace(""" - I'm just a computer program, so I don't have feelings or emotions like humans do, - but I'm functioning properly and ready to help with any questions or tasks you may have! - How about we chat about something in particular or just shoot the breeze ? Your choice! - """), - VerbatimHttpHandler.RemoveWhiteSpace(response.Text)); - Assert.Single(response.Messages.Single().Contents); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T17:18:46.308987Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(36, response.Usage.InputTokenCount); - Assert.Equal(55, response.Usage.OutputTokenCount); - Assert.Equal(91, response.Usage.TotalTokenCount); - } - - [Fact] - public async Task FunctionCallContent_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - } - ], - "stream": false, - "tools": [ - { - "type": "function", - "function": { - "name": "GetPersonAge", - "description": "Gets the age of the specified person.", - "parameters": { - "type": "object", - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - }, - "required": ["personName"] - } - } - } - ] - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T18:48:30.2669578Z", - "message": { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "function": { - "name": "GetPersonAge", - "arguments": { - "personName": "Alice" - } - } - } - ] - }, - "done_reason": "stop", - "done": true, - "total_duration": 27351311300, - "load_duration": 8041538400, - "prompt_eval_count": 170, - "prompt_eval_duration": 16078776000, - "eval_count": 19, - "eval_duration": 3227962000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) - { - ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, - }; - - var response = await client.GetResponseAsync("How old is Alice?", new() - { - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - }); - Assert.NotNull(response); - - Assert.Empty(response.Text); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T18:48:30.2669578Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(170, response.Usage.InputTokenCount); - Assert.Equal(19, response.Usage.OutputTokenCount); - Assert.Equal(189, response.Usage.TotalTokenCount); - - Assert.Single(response.Messages.Single().Contents); - FunctionCallContent fcc = Assert.IsType(response.Messages.Single().Contents[0]); - Assert.Equal("GetPersonAge", fcc.Name); - AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); - } - - [Fact] - public async Task FunctionResultContent_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - }, - { - "role": "assistant", - "content": "{\u0022call_id\u0022:\u0022abcd1234\u0022,\u0022name\u0022:\u0022GetPersonAge\u0022,\u0022arguments\u0022:{\u0022personName\u0022:\u0022Alice\u0022}}" - }, - { - "role": "tool", - "content": "{\u0022call_id\u0022:\u0022abcd1234\u0022,\u0022result\u0022:42}" - } - ], - "stream": false, - "tools": [ - { - "type": "function", - "function": { - "name": "GetPersonAge", - "description": "Gets the age of the specified person.", - "parameters": { - "type": "object", - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - }, - "required": ["personName"] - } - } - } - ] - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T20:57:20.157266Z", - "message": { - "role": "assistant", - "content": "Alice is 42 years old." - }, - "done_reason": "stop", - "done": true, - "total_duration": 20320666000, - "load_duration": 8159642600, - "prompt_eval_count": 106, - "prompt_eval_duration": 10846727000, - "eval_count": 8, - "eval_duration": 1307842000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) - { - ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, - }; - - var response = await client.GetResponseAsync( - [ - new(ChatRole.User, "How old is Alice?"), - new(ChatRole.Assistant, [new FunctionCallContent("abcd1234", "GetPersonAge", new Dictionary { ["personName"] = "Alice" })]), - new(ChatRole.Tool, [new FunctionResultContent("abcd1234", 42)]), - ], - new() - { - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - }); - Assert.NotNull(response); - - Assert.Equal("Alice is 42 years old.", response.Text); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T20:57:20.157266Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(106, response.Usage.InputTokenCount); - Assert.Equal(8, response.Usage.OutputTokenCount); - Assert.Equal(114, response.Usage.TotalTokenCount); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs deleted file mode 100644 index 493c0bf0333..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Threading.Tasks; -using Microsoft.TestUtilities; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OllamaEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests -{ - protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => - IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? - new OllamaEmbeddingGenerator(endpoint, "all-minilm") : - null; - - [ConditionalFact] - public async Task InvalidModelParameter_ThrowsInvalidOperationException() - { - SkipIfNotEnabled(); - - var endpoint = IntegrationTestHelpers.GetOllamaUri(); - Assert.NotNull(endpoint); - - using var generator = new OllamaEmbeddingGenerator(endpoint, modelId: "inexistent-model"); - - InvalidOperationException ex; - ex = await Assert.ThrowsAsync(() => generator.GenerateAsync(["Hello, world!"])); - Assert.Contains("inexistent-model", ex.Message); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs deleted file mode 100644 index be18138de84..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public class OllamaEmbeddingGeneratorTests -{ - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws("endpoint", () => new OllamaEmbeddingGenerator((string)null!)); - Assert.Throws("modelId", () => new OllamaEmbeddingGenerator(new Uri("http://localhost"), " ")); - } - - [Fact] - public void GetService_SuccessfullyReturnsUnderlyingClient() - { - using OllamaEmbeddingGenerator generator = new("http://localhost"); - - Assert.Same(generator, generator.GetService()); - Assert.Same(generator, generator.GetService>>()); - - using IEmbeddingGenerator> pipeline = generator - .AsBuilder() - .UseOpenTelemetry() - .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - - Assert.NotNull(pipeline.GetService>>()); - Assert.NotNull(pipeline.GetService>>()); - Assert.NotNull(pipeline.GetService>>()); - - Assert.Same(generator, pipeline.GetService()); - Assert.IsType>>(pipeline.GetService>>()); - } - - [Fact] - public void AsIEmbeddingGenerator_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator(endpoint, model); - var metadata = generator.GetService(); - Assert.Equal("ollama", metadata?.ProviderName); - Assert.Equal(endpoint, metadata?.ProviderUri); - Assert.Equal(model, metadata?.DefaultModelId); - } - - [Fact] - public async Task GetEmbeddingsAsync_ExpectedRequestResponse() - { - const string Input = """ - {"model":"all-minilm","input":["hello, world!","red, white, blue"]} - """; - - const string Output = """ - { - "model":"all-minilm", - "embeddings":[ - [-0.038159743,0.032830726,-0.005602915,0.014363416,-0.04031945,-0.11662117,0.031710647,0.0019634133,-0.042558126,0.02925818,0.04254404,0.032178584,0.029820565,0.010947956,-0.05383333,-0.05031401,-0.023460664,0.010746779,-0.13776828,0.003972192,0.029283607,0.06673441,-0.015434976,0.048401773,-0.088160664,-0.012700827,0.04134059,0.0408592,-0.050058633,-0.058048956,0.048720006,0.068883754,0.0588242,0.008813041,-0.016036017,0.08514798,-0.07813561,-0.07740018,0.020856613,0.016228318,0.032506905,-0.053466275,-0.06220645,-0.024293836,0.0073994277,0.02410873,0.006477103,0.051144805,0.072868116,0.03460658,-0.0547553,-0.05937917,-0.007205277,0.020145971,0.035794333,0.005588114,0.010732389,-0.052755248,0.01006711,-0.008716047,-0.062840104,0.038445882,-0.013913384,0.07341423,0.09004691,-0.07995187,-0.016410379,0.044806693,-0.06886798,-0.03302609,-0.015488586,0.0112944925,0.03645402,0.06637969,-0.054364193,0.008732196,0.012049053,-0.038111813,0.006928739,0.05113517,0.07739711,-0.12295967,0.016389083,0.049567502,0.03162499,-0.039604694,0.0016613991,0.009564599,-0.03268798,-0.033994347,-0.13328508,0.0072719813,-0.010261588,0.038570367,-0.093384996,-0.041716397,0.069951184,-0.02632818,-0.149702,0.13445856,0.037486482,0.052814852,0.045044158,0.018727085,0.05445453,0.01727433,-0.032474063,0.046129994,-0.046679277,-0.03058037,-0.0181755,-0.048695795,0.033057086,-0.0038555008,0.050006237,-0.05828653,-0.010029618,0.01062073,-0.040105496,-0.0015263702,0.060846698,-0.04557025,0.049251337,0.026121102,0.019804202,-0.0016694543,0.059516467,-6.525171e-33,0.06351319,0.0030810465,0.028928237,0.17336167,0.0029677018,0.027755935,-0.09513812,-0.031182382,0.026697554,-0.0107956175,0.023849761,0.02378595,-0.03121345,0.049473017,-0.02506533,0.101713106,-0.079133175,-0.0032418896,0.04290832,0.094838716,-0.06652884,0.0062877694,0.02221229,0.0700068,-0.007469806,-0.0017550732,0.027011596,-0.075321496,0.114022695,0.0085597,-0.023766534,-0.04693697,0.014437173,0.01987886,-0.0046902793,0.0013660098,-0.034307938,-0.054156985,-0.09417741,-0.028919358,-0.018871028,0.04574328,0.047602862,-0.0031305805,-0.033291575,-0.0135114025,0.051019657,0.031115327,0.015239397,0.05413997,-0.085031144,0.013366392,-0.04757861,0.07102588,-0.013105953,-0.0023799809,0.050322797,-0.041649505,-0.014187793,0.0324716,0.005401626,0.091307014,0.0044665188,-0.018263677,-0.015284639,-0.04634121,0.038754962,0.014709013,0.052040145,0.0017918312,-0.014979437,0.027103048,0.03117813,0.023749126,-0.004567645,0.03617759,0.06680814,-0.001835277,0.021281,-0.057563916,0.019137124,0.031450257,-0.018432263,-0.040860977,0.10391725,0.011970765,-0.014854915,-0.10521159,-0.012288272,-0.00041675335,-0.09510029,0.058300544,0.042590536,-0.025064372,-0.09454636,4.0064686e-33,0.13224861,0.0053342036,-0.033114634,-0.09096768,-0.031561732,-0.03395822,-0.07202013,0.12591493,-0.08332582,0.052816514,0.001065021,0.022002738,0.1040207,0.013038866,0.04092958,0.018689224,0.1142518,0.024801003,0.014596161,0.006195551,-0.011214642,-0.035760444,-0.037979998,0.011274433,-0.051305123,0.007884909,0.06734877,0.0033462204,-0.09284879,0.037033774,-0.022331867,0.039951596,-0.030730229,-0.011403805,-0.014458028,0.024968812,-0.097553216,-0.03536226,-0.037567392,-0.010149212,-0.06387594,0.025570663,0.02060328,0.037549157,-0.104355134,-0.02837097,-0.052078977,0.0128349,-0.05123587,-0.029060647,-0.09632806,-0.042301137,0.067175224,-0.030890828,-0.010358077,0.027408795,-0.028092034,0.010337195,0.04303845,0.022324203,0.00797792,0.056084383,0.040727936,0.092925824,0.01653155,-0.053750493,0.00046004262,0.050728552,0.04253214,-0.029197674,0.00926312,-0.010662153,-0.037244495,0.002277273,-0.030296732,0.07459592,0.002572513,-0.017561244,0.0028881067,0.03841156,0.007247727,0.045637112,0.039992437,0.014227117,-0.014297474,0.05854321,0.03632371,0.05527864,-0.02007574,-0.08043163,-0.030238612,-0.014929122,0.022335418,0.011954643,-0.06906099,-1.8807288e-8,-0.07850291,0.046684187,-0.023935271,0.063510746,0.024001691,0.0014455577,-0.09078209,-0.066868275,-0.0801402,0.005480386,0.053663295,0.10483363,-0.066864185,0.015531167,0.06711155,0.07081655,-0.031996343,0.020819444,-0.021926524,-0.0073062326,-0.010652819,0.0041180425,0.033138428,-0.0789938,0.03876969,-0.075220205,-0.015715994,0.0059789424,0.005140016,-0.06150612,0.041992374,0.09544083,-0.043187104,0.014401576,-0.10615426,-0.027936764,0.011047429,0.069572434,0.06690283,-0.074798405,-0.07852024,0.04276141,-0.034642085,-0.106051244,-0.03581038,0.051521253,0.06865896,-0.04999753,0.0154549,-0.06452052,-0.07598782,0.02603005,0.074413665,-0.012398757,0.13330704,0.07475513,0.051348723,0.02098748,-0.02679416,0.08896129,0.039944872,-0.041040305,0.031930625,0.018114654], - [0.007228383,-0.021804843,-0.07494023,-0.021707121,-0.021184582,0.09326986,0.10764054,-0.01918113,0.007439991,0.01367952,-0.034187328,-0.044076536,0.016042138,0.007507193,-0.016432272,0.025345335,0.010598066,-0.03832474,-0.14418823,-0.033625234,0.013156937,-0.0048872638,-0.08534306,-0.00003228713,-0.08900276,-0.00008128615,0.010332802,0.053303026,-0.050233904,-0.0879366,-0.064243905,-0.017168961,0.1284308,-0.015268303,-0.049664143,-0.07491954,0.021887481,0.015997978,-0.07967111,0.08744341,-0.039261423,-0.09904984,0.02936398,0.042995434,0.057036504,0.09063012,0.0000012311281,0.06120768,-0.050825767,-0.014443322,0.02879051,-0.002343813,-0.10176559,0.104563184,0.031316753,0.08251861,-0.041213628,-0.0217945,0.0649965,-0.011131547,0.018417398,-0.014460508,-0.05108664,0.11330918,0.01863208,0.006442521,-0.039408617,-0.03609412,-0.009156692,-0.0031261789,-0.010928502,-0.021108521,0.037411734,0.012443921,0.018142054,-0.0362644,0.058286663,-0.02733258,-0.052172586,-0.08320095,-0.07089281,-0.0970049,-0.048587535,0.055343032,0.048351917,0.06892102,-0.039993215,0.06344781,-0.084417015,0.003692423,-0.059397053,0.08186814,0.0029228176,-0.010551637,-0.058019258,0.092128515,0.06862907,-0.06558893,0.021121018,0.079212844,0.09616225,0.0045106052,0.039712362,-0.053576704,0.035097837,-0.04251009,-0.013761404,0.011582285,0.02387105,0.009042205,0.054141942,-0.051263757,-0.07984356,-0.020198742,-0.051623948,-0.0013434993,-0.05825417,-0.0026240738,0.0050159167,-0.06320204,0.07872169,-0.04051374,0.04671058,-0.05804034,-0.07103668,-0.07507343,0.015222599,-3.0948323e-33,0.0076309564,-0.06283016,0.024291662,0.12532257,0.013917241,0.04869009,-0.037988827,-0.035241846,-0.041410565,-0.033772282,0.018835608,0.081035286,-0.049912665,0.044602085,0.030495265,-0.009206943,0.027668765,0.011651487,-0.10254086,0.054472663,-0.06514106,0.12192646,0.048823033,-0.015688669,0.010323047,-0.02821445,-0.030832449,-0.035029083,-0.010604268,0.0014445938,0.08670387,0.01997448,0.0101131955,0.036524937,-0.033489946,-0.026745271,-0.04709222,0.015197909,0.018787097,-0.009976326,-0.0016434817,-0.024719588,-0.09179337,0.09343157,0.029579962,-0.015174558,0.071250066,0.010549244,0.010716396,0.05435638,-0.06391847,-0.031383075,0.007916095,0.012391228,-0.012053197,-0.017409964,0.013742709,0.0594159,-0.033767693,0.04505938,-0.0017214329,0.12797962,0.03223919,-0.054756388,0.025249248,-0.02273578,-0.04701282,-0.018718086,0.009820931,-0.06267794,-0.012644738,0.0068301614,0.093209736,-0.027372226,-0.09436381,0.003861504,0.054960024,-0.058553983,-0.042971537,-0.008994571,-0.08225824,-0.013560626,-0.01880568,0.0995795,-0.040887516,-0.0036491079,-0.010253542,-0.031025425,-0.006957114,-0.038943008,-0.090270124,-0.031345647,0.029613726,-0.099465184,-0.07469079,7.844707e-34,0.024241973,0.03597121,-0.049776066,0.05084303,0.006059542,-0.020719761,0.019962702,0.092246406,0.069408394,0.062306542,0.013837189,0.054749023,0.05090263,0.04100415,-0.02573441,0.09535842,0.036858294,0.059478357,0.0070162765,0.038462427,-0.053635903,0.05912332,-0.037887845,-0.0012995935,-0.068758026,0.0671618,0.029407106,-0.061569903,-0.07481879,-0.01849014,0.014240046,-0.08064838,0.028351007,0.08456427,0.016858438,0.02053254,0.06171099,-0.028964644,-0.047633287,0.08802184,0.0017116248,0.019451816,0.03419083,0.07152118,-0.027244413,-0.04888475,-0.10314279,0.07628554,-0.045991484,-0.023299307,-0.021448445,0.04111079,-0.036342163,-0.010670482,0.01950527,-0.0648448,-0.033299454,0.05782628,0.030278979,0.079154804,-0.03679649,0.031728156,-0.034912236,0.08817754,0.059208114,-0.02319613,-0.027045371,-0.018559752,-0.051946763,-0.010635224,0.048839167,-0.043925915,-0.028300019,-0.0039419765,0.044211324,-0.067469835,-0.027534118,0.005051618,-0.034172326,0.080007285,-0.01931061,-0.005759926,0.08765162,0.08372951,-0.093784876,0.011837292,0.019019455,0.047941882,0.05504541,-0.12475821,0.012822803,0.12833545,0.08005919,0.019278418,-0.025834465,-1.9763878e-8,0.05211108,0.024891146,-0.0015623684,0.0040500895,0.015101377,-0.0031462535,0.014759316,-0.041329216,-0.029255627,0.048599463,0.062482737,0.018376771,-0.066601776,0.014752581,0.07968402,-0.015090815,-0.12100162,-0.0014005995,0.0134423375,-0.0065814927,-0.01188529,-0.01107086,-0.059613306,0.030120188,0.0418596,-0.009260598,0.028435009,0.024893047,0.031339604,0.09501834,0.027570697,0.0636991,-0.056108754,-0.0329521,-0.114633024,-0.00981398,-0.060992315,0.027551433,0.0069592255,-0.059862003,0.0008075791,0.001507554,-0.028574942,-0.011227367,0.0056030746,-0.041190825,-0.09364463,-0.04459479,-0.055058934,-0.029972456,-0.028642913,-0.015199684,0.007875299,-0.034083385,0.02143902,-0.017395096,0.027429376,0.013198211,0.005065835,0.037760753,0.08974973,0.07598824,0.0050444477,0.014734193] - ], - "total_duration":375551700, - "load_duration":354411900, - "prompt_eval_count":9 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator("http://localhost:11434", "all-minilm", httpClient); - - var response = await generator.GenerateAsync([ - "hello, world!", - "red, white, blue", - ]); - Assert.NotNull(response); - Assert.Equal(2, response.Count); - - Assert.NotNull(response.Usage); - Assert.Equal(9, response.Usage.InputTokenCount); - Assert.Equal(9, response.Usage.TotalTokenCount); - - foreach (Embedding e in response) - { - Assert.Equal("all-minilm", e.ModelId); - Assert.NotNull(e.CreatedAt); - Assert.Equal(384, e.Vector.Length); - Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs deleted file mode 100644 index 49560a9c451..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(IDictionary))] -internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/IntegrationTestHelpers.cs similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs rename to test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/IntegrationTestHelpers.cs diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj index 14ca7e244d1..d977c035279 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs index 921e2d3b5f9..28d3e21fd65 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs @@ -2,14 +2,115 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TestUtilities; using OllamaSharp; +using Xunit; namespace Microsoft.Extensions.AI; -public class OllamaSharpChatClientIntegrationTests : OllamaChatClientIntegrationTests +public class OllamaSharpChatClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? new OllamaApiClient(endpoint, "llama3.2") : null; + + public override Task FunctionInvocation_RequireAny() => + throw new SkipTestException("Ollama does not currently support requiring function invocation."); + + public override Task FunctionInvocation_RequireSpecific() => + throw new SkipTestException("Ollama does not currently support requiring function invocation."); + + protected override string? GetModel_MultiModal_DescribeImage() => "llava"; + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_NoArgs() + { + SkipIfNotEnabled(); + + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Build(); + + var secretNumber = 42; + var response = await chatClient.GetResponseAsync("What is the current secret number? Answer with digits only.", new ChatOptions + { + ModelId = "llama3:8b", + Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], + Temperature = 0, + Seed = 0, + }); + + Assert.Contains(secretNumber.ToString(), response.Text); + } + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_WithArgs() + { + SkipIfNotEnabled(); + + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Build(); + + var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( + [Description("The ticker symbol")] string symbol, + [Description("The currency code such as USD or JPY")] string currency) => + { + Assert.Equal("MSFT", symbol); + Assert.Equal("GBP", currency); + return 999; + }, "GetStockPrice"); + + var didCallIrrelevantTool = false; + var irrelevantTool = AIFunctionFactory.Create(() => { didCallIrrelevantTool = true; return 123; }, "GetSecretNumber"); + + var response = await chatClient.GetResponseAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions + { + Tools = [stockPriceTool, irrelevantTool], + Temperature = 0, + Seed = 0, + }); + + Assert.Contains("999", response.Text); + Assert.False(didCallIrrelevantTool); + } + + [ConditionalFact] + public async Task InvalidModelParameter_ThrowsInvalidOperationException() + { + SkipIfNotEnabled(); + + var endpoint = IntegrationTestHelpers.GetOllamaUri(); + Assert.NotNull(endpoint); + + using var chatClient = new OllamaApiClient(endpoint, defaultModel: "inexistent-model"); + + InvalidOperationException ex; + ex = await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("Hello, world!")); + Assert.Contains("inexistent-model", ex.Message); + + ex = await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("Hello, world!").ToChatResponseAsync()); + Assert.Contains("inexistent-model", ex.Message); + } + + private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) + { + public override Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Assert.Null(options?.Tools); + return base.GetResponseAsync(messages, options, cancellationToken); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs index 1826855f459..f7775143c36 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs @@ -2,14 +2,35 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Threading.Tasks; +using Microsoft.TestUtilities; using OllamaSharp; +using Xunit; namespace Microsoft.Extensions.AI; -public class OllamaSharpEmbeddingGeneratorIntegrationTests : OllamaEmbeddingGeneratorIntegrationTests +public class OllamaSharpEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? new OllamaApiClient(endpoint, "all-minilm") : null; + + [ConditionalFact] + public async Task InvalidModelParameter_ThrowsInvalidOperationException() + { + SkipIfNotEnabled(); + + var endpoint = IntegrationTestHelpers.GetOllamaUri(); + Assert.NotNull(endpoint); + + using var client = new OllamaApiClient(endpoint, defaultModel: "inexistent-model"); + + InvalidOperationException ex; + ex = await Assert.ThrowsAsync(() => client.EmbedAsync(new OllamaSharp.Models.EmbedRequest + { + Input = ["Hello, world!"], + })); + Assert.Contains("inexistent-model", ex.Message); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..7a6f60af778 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using OllamaSharp; + +namespace Microsoft.Extensions.AI; + +/// +/// OllamaSharp-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with OllamaSharp chat client implementation. +/// +public class OllamaSharpImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? + new OllamaApiClient(endpoint, "llama3.2") : + null; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs index 9d8f806ca8a..d06f20c3f74 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -3,9 +3,8 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; +using System.ClientModel.Primitives; using Azure.Identity; -using Microsoft.Extensions.Configuration; using OpenAI; namespace Microsoft.Extensions.AI; @@ -26,14 +25,13 @@ internal static class IntegrationTestHelpers var endpoint = configuration["OpenAI:Endpoint"] ?? throw new InvalidOperationException("To use AzureOpenAI, set a value for OpenAI:Endpoint"); - if (apiKey is not null) - { - return new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); - } - else - { - return new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - } + // Use Azure endpoint with /openai/v1 suffix + var options = new OpenAIClientOptions { Endpoint = new Uri(new Uri(endpoint), "/openai/v1") }; + return apiKey is not null ? + new OpenAIClient(new ApiKeyCredential(apiKey), options) : + new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + options); } else if (apiKey is not null) { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj index 60b6f8c84ab..093260d779c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj @@ -2,7 +2,8 @@ Microsoft.Extensions.AI Unit tests for Microsoft.Extensions.AI.OpenAI - $(NoWarn);OPENAI002;MEAI001;S104 + $(NoWarn);S104 + $(NoWarn);OPENAI001;MEAI001 @@ -31,7 +32,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs new file mode 100644 index 00000000000..ef9d6063ddd --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable CA1822 // Mark members as static +#pragma warning disable CA2000 // Dispose objects before losing scope +#pragma warning disable S1135 // Track uses of "TODO" tags +#pragma warning disable xUnit1013 // Public method should be marked as test + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using OpenAI.Assistants; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIAssistantChatClientIntegrationTests : ChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() + { + var openAIClient = IntegrationTestHelpers.GetOpenAIClient(); + if (openAIClient is null) + { + return null; + } + + AssistantClient ac = openAIClient.GetAssistantClient(); + var assistant = + ac.GetAssistants().FirstOrDefault() ?? + ac.CreateAssistant("gpt-4o-mini"); + + return ac.AsIChatClient(assistant.Id); + } + + public override bool FunctionInvokingChatClientSetsConversationId => true; + + // These tests aren't written in a way that works well with threads. + public override Task Caching_AfterFunctionInvocation_FunctionOutputChangedAsync() => Task.CompletedTask; + public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask; + + // Assistants doesn't support data URIs. + public override Task MultiModal_DescribeImage() => Task.CompletedTask; + public override Task MultiModal_DescribePdf() => Task.CompletedTask; + + [ConditionalFact] + public async Task UseCodeInterpreter_ProducesCodeExecutionResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync("Use the code interpreter to calculate the square root of 42.", new() + { + Tools = [new HostedCodeInterpreterTool()], + }); + Assert.NotNull(response); + + ChatMessage message = Assert.Single(response.Messages); + + Assert.NotEmpty(message.Text); + + // Validate CodeInterpreterToolCallContent + var toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().SingleOrDefault(); + Assert.NotNull(toolCallContent); + if (toolCallContent.CallId is not null) + { + Assert.NotEmpty(toolCallContent.CallId); + } + + if (toolCallContent.Inputs is not null) + { + Assert.NotEmpty(toolCallContent.Inputs); + if (toolCallContent.Inputs.OfType().FirstOrDefault() is { } codeInput) + { + Assert.Equal("text/x-python", codeInput.MediaType); + Assert.NotEmpty(codeInput.Data.ToArray()); + } + } + + // Validate CodeInterpreterToolResultContent (when present) + var toolResultContents = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + foreach (var toolResultContent in toolResultContents) + { + if (toolResultContent.CallId is not null) + { + Assert.NotEmpty(toolResultContent.CallId); + } + + if (toolResultContent.Outputs is not null) + { + Assert.NotEmpty(toolResultContent.Outputs); + if (toolResultContent.Outputs.OfType().FirstOrDefault() is { } resultOutput) + { + Assert.NotEmpty(resultOutput.Text); + } + } + } + } + + // [Fact] // uncomment and run to clear out _all_ threads in your OpenAI account + public async Task DeleteAllThreads() + { + using HttpClient client = new(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }); + + // These values need to be filled in. The bearer token needs to be sniffed from a browser + // session interacting with the dashboard (e.g. use F12 networking tools to look at request headers + // made to "https://api.openai.com/v1/threads?limit=10" after clicking on Assistants | Threads in the + // OpenAI portal dashboard). + client.DefaultRequestHeaders.Add("authorization", $"Bearer sess-ENTERYOURSESSIONTOKEN"); + client.DefaultRequestHeaders.Add("openai-organization", "org-ENTERYOURORGID"); + client.DefaultRequestHeaders.Add("openai-project", "proj_ENTERYOURPROJECTID"); + + AssistantClient ac = new(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); + while (true) + { + string listing = await client.GetStringAsync("https://api.openai.com/v1/threads?limit=100"); + + var matches = Regex.Matches(listing, @"thread_\w+"); + if (matches.Count == 0) + { + break; + } + + foreach (Match m in matches) + { + var dr = await ac.DeleteThreadAsync(m.Value); + Assert.True(dr.Value.Deleted); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs new file mode 100644 index 00000000000..7779e4cf18d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using OpenAI; +using OpenAI.Assistants; +using Xunit; + +#pragma warning disable S103 // Lines should not be too long + +namespace Microsoft.Extensions.AI; + +public class OpenAIAssistantChatClientTests +{ + [Fact] + public void AsIChatClient_InvalidArgs_Throws() + { + Assert.Throws("assistantClient", () => ((AssistantClient)null!).AsIChatClient("assistantId")); + Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient((string)null!)); + Assert.Throws("assistant", () => new AssistantClient("ignored").AsIChatClient((Assistant)null!)); + } + + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IChatClient[] clients = + [ + client.GetAssistantClient().AsIChatClient("assistantId"), + client.GetAssistantClient().AsIChatClient("assistantId", "threadId"), + ]; + + foreach (var chatClient in clients) + { + var metadata = chatClient.GetService(); + Assert.Equal("openai", metadata?.ProviderName); + Assert.Equal(endpoint, metadata?.ProviderUri); + } + } + + [Fact] + public void GetService_AssistantClient_SuccessfullyReturnsUnderlyingClient() + { + AssistantClient assistantClient = new OpenAIClient("key").GetAssistantClient(); + IChatClient chatClient = assistantClient.AsIChatClient("assistantId"); + + Assert.Same(assistantClient, chatClient.GetService()); + + Assert.Null(chatClient.GetService()); + + using IChatClient pipeline = chatClient + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Build(); + + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + Assert.NotNull(pipeline.GetService()); + + Assert.Same(assistantClient, pipeline.GetService()); + Assert.IsType(pipeline.GetService()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs index 6322e3d6b64..a9e08a58e52 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs @@ -8,4 +8,8 @@ public class OpenAIChatClientIntegrationTests : ChatClientIntegrationTests protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() ?.GetChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini").AsIChatClient(); + + protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => + IntegrationTestHelpers.GetOpenAIClient() + ?.GetEmbeddingClient(TestRunnerConfiguration.Instance["OpenAI:EmbeddingModel"] ?? "text-embedding-3-small").AsIEmbeddingGenerator(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 9ba9c743166..5e4932c6736 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -8,10 +8,7 @@ using System.ComponentModel; using System.Linq; using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -30,17 +27,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("chatClient", () => ((ChatClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetChatClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -159,6 +152,7 @@ public async Task BasicRequestResponse_NonStreaming() var response = await client.GetResponseAsync("hello", new() { + AllowMultipleToolCalls = false, MaxOutputTokens = 10, Temperature = 0.5f, }); @@ -276,6 +270,74 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "tools": [ + { + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "strict": true, + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + } + }, + "type": "function" + } + ], + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "tool_choice": "auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { @@ -330,14 +392,12 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, @@ -409,14 +469,12 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. @@ -493,9 +551,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -569,9 +625,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -600,50 +654,51 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Equal("Hello! How can I assist you today?", responseText); } - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } - - /// Used to create the JSON payload for an OpenAI chat tool description. - private sealed class ChatToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } - } - [Fact] public async Task StronglyTypedOptions_AllSent() { const string Input = """ { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "logprobs":true, - "top_logprobs":42, - "logit_bias":{"12":34}, - "parallel_tool_calls":false, - "user":"12345", - "metadata":{"something":"else"}, - "store":true + "metadata": { + "something": "else" + }, + "user": "12345", + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "top_logprobs": 42, + "store": true, + "logit_bias": { + "12": 34 + }, + "logprobs": true, + "tools": [ + { + "type": "function", + "function": { + "description": "", + "name": "GetPersonAge", + "parameters": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + ], + "tool_choice": "auto", + "parallel_tool_calls": false } """; @@ -671,6 +726,7 @@ public async Task StronglyTypedOptions_AllSent() Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, + Tools = [AIFunctionFactory.Create((string name) => 42, "GetPersonAge")], RawRepresentationFactory = (c) => { var openAIOptions = new ChatCompletionOptions @@ -1327,26 +1383,26 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() "tool_calls": [ { "id": "12345", + "type": "function", "function": { "name": "SayHello", "arguments": "null" - }, - "type": "function" + } }, { "id": "12346", + "type": "function", "function": { "name": "SayHi", "arguments": "null" - }, - "type": "function" + } } ] }, { "role": "tool", "tool_call_id": "12345", - "content": "Said hello" + "content": "{ \"$type\": \"text\", \"text\": \"Said hello\" }" }, { "role":"tool", @@ -1415,7 +1471,7 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() ]), new (ChatRole.Tool, [ - new FunctionResultContent("12345", "Said hello"), + new FunctionResultContent("12345", new TextContent("Said hello")), new FunctionResultContent("12346", "Said hi"), ]), new(ChatRole.Assistant, "You are great."), @@ -1563,8 +1619,201 @@ private static async Task DataContentMessage_Image_AdditionalPropertyDetail_NonS }, response.Usage.AdditionalCounts); } + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => client.GetResponseAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o", + "max_completion_tokens":10 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-2024-08-06", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 8, + "completion_tokens": 9, + "total_tokens": 17 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + ModelId = "gpt-4o", + }); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", response.ResponseId); + Assert.Equal("Hello! How can I assist you today?", response.Text); + Assert.Equal("gpt-4o-2024-08-06", response.ModelId); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o", + "stream":true, + "stream_options":{"include_usage":true}, + "max_completion_tokens":20 + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_f85bea6784","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-2024-08-06","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17}} + + data: [DONE] + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ModelId = "gpt-4o", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + Assert.All(updates, u => Assert.Equal("gpt-4o-2024-08-06", u.ModelId)); + } + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) .GetChatClient(modelId) .AsIChatClient(); + + [Fact] + public void AsChatMessages_PreservesRole_SystemMessage() + { + List openAIMessages = [new SystemChatMessage("You are a helpful assistant")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.System, extMessages[0].Role); + Assert.Equal("You are a helpful assistant", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_UserMessage() + { + List openAIMessages = [new UserChatMessage("Hello")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.User, extMessages[0].Role); + Assert.Equal("Hello", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_AssistantMessage() + { + List openAIMessages = [new AssistantChatMessage("Hi there!")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.Assistant, extMessages[0].Role); + Assert.Equal("Hi there!", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_DeveloperMessage() + { + List openAIMessages = [new DeveloperChatMessage("Developer instructions")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.System, extMessages[0].Role); + Assert.Equal("Developer instructions", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_ToolMessage() + { + List openAIMessages = [new ToolChatMessage("tool-123", "Result")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.Tool, extMessages[0].Role); + var frc = Assert.IsType(Assert.Single(extMessages[0].Contents)); + Assert.Equal("tool-123", frc.CallId); + Assert.Equal("Result", frc.Result); + } + + [Fact] + public void AsChatMessages_PreservesRole_MultipleMessages() + { + List openAIMessages = + [ + new SystemChatMessage("System prompt"), + new UserChatMessage("User message"), + new AssistantChatMessage("Assistant response"), + new DeveloperChatMessage("Developer note") + ]; + + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Equal(4, extMessages.Count); + Assert.Equal(ChatRole.System, extMessages[0].Role); + Assert.Equal(ChatRole.User, extMessages[1].Role); + Assert.Equal(ChatRole.Assistant, extMessages[2].Role); + Assert.Equal(ChatRole.System, extMessages[3].Role); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs new file mode 100644 index 00000000000..7fe1ceb8b57 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -0,0 +1,1555 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using OpenAI.Assistants; +using OpenAI.Chat; +using OpenAI.Realtime; +using OpenAI.Responses; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIConversionTests +{ + private static readonly AIFunction _testFunction = AIFunctionFactory.Create( + ([Description("The name parameter")] string name) => name, + "test_function", + "A test function for conversion"); + + [Fact] + public void AsOpenAIChatResponseFormat_HandlesVariousFormats() + { + Assert.Null(MicrosoftExtensionsAIChatExtensions.AsOpenAIChatResponseFormat(null)); + + var text = MicrosoftExtensionsAIChatExtensions.AsOpenAIChatResponseFormat(ChatResponseFormat.Text); + Assert.NotNull(text); + Assert.Equal("""{"type":"text"}""", ((IJsonModel)text).Write(ModelReaderWriterOptions.Json).ToString()); + + var json = MicrosoftExtensionsAIChatExtensions.AsOpenAIChatResponseFormat(ChatResponseFormat.Json); + Assert.NotNull(json); + Assert.Equal("""{"type":"json_object"}""", ((IJsonModel)json).Write(ModelReaderWriterOptions.Json).ToString()); + + var jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIChatResponseFormat(); + Assert.NotNull(jsonSchema); + Assert.Equal(RemoveWhitespace(""" + {"type":"json_schema","json_schema":{"description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + }}} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + + jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIChatResponseFormat( + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + Assert.NotNull(jsonSchema); + Assert.Equal(RemoveWhitespace(""" + { + "type":"json_schema","json_schema":{"description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + },"strict":true}} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + } + + [Fact] + public void AsOpenAIResponseTextFormat_HandlesVariousFormats() + { + Assert.Null(MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTextFormat(null)); + + var text = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTextFormat(ChatResponseFormat.Text); + Assert.NotNull(text); + Assert.Equal(ResponseTextFormatKind.Text, text.Kind); + + var json = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTextFormat(ChatResponseFormat.Json); + Assert.NotNull(json); + Assert.Equal(ResponseTextFormatKind.JsonObject, json.Kind); + + var jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIResponseTextFormat(); + Assert.NotNull(jsonSchema); + Assert.Equal(ResponseTextFormatKind.JsonSchema, jsonSchema.Kind); + Assert.Equal(RemoveWhitespace(""" + {"type":"json_schema","description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + }} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + + jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIResponseTextFormat( + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + Assert.NotNull(jsonSchema); + Assert.Equal(ResponseTextFormatKind.JsonSchema, jsonSchema.Kind); + Assert.Equal(RemoveWhitespace(""" + {"type":"json_schema","description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + },"strict":true} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + } + + [Fact] + public void AsOpenAIChatTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIChatTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.FunctionDescription); + ValidateSchemaParameters(tool.FunctionParameters); + } + + [Fact] + public void AsOpenAIResponseTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIResponseTool(); + + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIResponseTool_WithAIFunctionTool_ProducesValidFunctionTool() + { + var tool = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(tool: _testFunction); + + Assert.NotNull(tool); + var functionTool = Assert.IsType(tool); + Assert.Equal("test_function", functionTool.FunctionName); + Assert.Equal("A test function for conversion", functionTool.FunctionDescription); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedWebSearchTool_ProducesValidWebSearchTool() + { + var webSearchTool = new HostedWebSearchTool(); + + var result = webSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedWebSearchToolWithAdditionalProperties_ProducesValidWebSearchTool() + { + var location = WebSearchToolLocation.CreateApproximateLocation("US", "Region", "City", "UTC"); + var webSearchTool = new HostedWebSearchToolWithProperties(new Dictionary + { + [nameof(WebSearchToolLocation)] = location, + [nameof(WebSearchToolContextSize)] = WebSearchToolContextSize.High + }); + + var result = webSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + + var tool = Assert.IsType(result); + Assert.Equal(WebSearchToolContextSize.High, tool.SearchContextSize); + Assert.NotNull(tool); + + var approximateLocation = Assert.IsType(tool.UserLocation); + Assert.Equal(location.Country, approximateLocation.Country); + Assert.Equal(location.Region, approximateLocation.Region); + Assert.Equal(location.City, approximateLocation.City); + Assert.Equal(location.Timezone, approximateLocation.Timezone); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchTool_ProducesValidFileSearchTool() + { + var fileSearchTool = new HostedFileSearchTool { MaximumResultCount = 10 }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Empty(tool.VectorStoreIds); + Assert.Equal(fileSearchTool.MaximumResultCount, tool.MaxResultCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithVectorStores_ProducesValidFileSearchTool() + { + var vectorStoreContent = new HostedVectorStoreContent("vector-store-123"); + var fileSearchTool = new HostedFileSearchTool + { + Inputs = [vectorStoreContent] + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Single(tool.VectorStoreIds); + Assert.Equal(vectorStoreContent.VectorStoreId, tool.VectorStoreIds[0]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithMaxResults_ProducesValidFileSearchTool() + { + var fileSearchTool = new HostedFileSearchTool + { + MaximumResultCount = 10 + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(10, tool.MaxResultCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedCodeInterpreterTool_ProducesValidCodeInterpreterTool() + { + var codeTool = new HostedCodeInterpreterTool(); + + var result = codeTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.Container); + Assert.NotNull(tool.Container.ContainerConfiguration); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedCodeInterpreterToolWithFiles_ProducesValidCodeInterpreterTool() + { + var fileContent = new HostedFileContent("file-123"); + var codeTool = new HostedCodeInterpreterTool + { + Inputs = [fileContent] + }; + + var result = codeTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + var autoContainerConfig = Assert.IsType(tool.Container.ContainerConfiguration); + Assert.Single(autoContainerConfig.FileIds); + Assert.Equal(fileContent.FileId, autoContainerConfig.FileIds[0]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerTool_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000"); + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(new Uri("http://localhost:8000"), tool.ServerUri); + Assert.Equal("test-server", tool.ServerLabel); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithDescription_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ServerDescription = "A test MCP server" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("A test MCP server", tool.ServerDescription); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthToken_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AuthorizationToken = "test-token" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("test-token", tool.AuthorizationToken); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithUri_ProducesValidMcpTool() + { + var expectedUri = new Uri("http://localhost:8000"); + var mcpTool = new HostedMcpServerTool("test-server", expectedUri); + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(expectedUri, tool.ServerUri); + Assert.Equal("test-server", tool.ServerLabel); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAllowedTools_ProducesValidMcpTool() + { + var allowedTools = new List { "tool1", "tool2", "tool3" }; + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AllowedTools = allowedTools + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.AllowedTools); + Assert.Equal(3, tool.AllowedTools.ToolNames.Count); + Assert.Contains("tool1", tool.AllowedTools.ToolNames); + Assert.Contains("tool2", tool.AllowedTools.ToolNames); + Assert.Contains("tool3", tool.AllowedTools.ToolNames); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAlwaysRequireApprovalMode_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.GlobalPolicy); + Assert.Equal(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval, tool.ToolCallApprovalPolicy.GlobalPolicy); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithNeverRequireApprovalMode_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.GlobalPolicy); + Assert.Equal(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval, tool.ToolCallApprovalPolicy.GlobalPolicy); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithRequireSpecificApprovalMode_ProducesValidMcpTool() + { + var alwaysRequireTools = new List { "tool1", "tool2" }; + var neverRequireTools = new List { "tool3" }; + var approvalMode = HostedMcpServerToolApprovalMode.RequireSpecific(alwaysRequireTools, neverRequireTools); + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = approvalMode + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); + Assert.Equal(2, tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames.Count); + Assert.Single(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); + Assert.Contains("tool1", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames); + Assert.Contains("tool2", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames); + Assert.Contains("tool3", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); + } + + [Fact] + public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() + { + var unknownTool = new UnknownAITool(); + + var result = unknownTool.AsOpenAIResponseTool(); + + Assert.Null(result); + } + + [Fact] + public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException() + { + Assert.Throws("tool", () => ((AITool)null!).AsOpenAIResponseTool()); + } + + [Fact] + public void AsOpenAIConversationFunctionTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIConversationFunctionTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.Name); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + [Fact] + public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + /// Helper method to validate function parameters match our schema. + private static void ValidateSchemaParameters(BinaryData parameters) + { + Assert.NotNull(parameters); + + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; + + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIChatMessages()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello") { AuthorName = "Jane" }, + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]) { AuthorName = "!@#$%John Smith^*)" }, + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42.") { AuthorName = "@#$#$@$" }, + ]; + + ChatOptions? options = withOptions ? new ChatOptions { Instructions = "You talk like a parrot." } : null; + + var convertedMessages = messages.AsOpenAIChatMessages(options).ToArray(); + + int index = 0; + if (withOptions) + { + Assert.Equal(6, convertedMessages.Length); + + index = 1; + SystemChatMessage instructionsMessage = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("You talk like a parrot.", Assert.Single(instructionsMessage.Content).Text); + } + else + { + Assert.Equal(5, convertedMessages.Length); + } + + SystemChatMessage m0 = Assert.IsType(convertedMessages[index], exactMatch: false); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + UserChatMessage m1 = Assert.IsType(convertedMessages[index + 1], exactMatch: false); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + Assert.Equal("Jane", m1.ParticipantName); + + AssistantChatMessage m2 = Assert.IsType(convertedMessages[index + 2], exactMatch: false); + Assert.Single(m2.Content); + Assert.Equal("Hi there!", m2.Content[0].Text); + var tc = Assert.Single(m2.ToolCalls); + Assert.Equal("callid123", tc.Id); + Assert.Equal("SomeFunction", tc.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); + Assert.Equal("JohnSmith", m2.ParticipantName); + + ToolChatMessage m3 = Assert.IsType(convertedMessages[index + 3], exactMatch: false); + Assert.Equal("callid123", m3.ToolCallId); + Assert.Equal("theresult", Assert.Single(m3.Content).Text); + + AssistantChatMessage m4 = Assert.IsType(convertedMessages[index + 4], exactMatch: false); + Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); + Assert.Null(m4.ParticipantName); + } + + [Fact] + public void AsOpenAIResponseItems_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIResponseItems()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedItems = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(6, convertedItems.Length); + + MessageResponseItem m0 = Assert.IsAssignableFrom(convertedItems[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + MessageResponseItem m1 = Assert.IsAssignableFrom(convertedItems[1]); + Assert.Equal(OpenAI.Responses.MessageRole.User, m1.Role); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + MessageResponseItem m2 = Assert.IsAssignableFrom(convertedItems[2]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m2.Role); + Assert.Equal("Hi there!", Assert.Single(m2.Content).Text); + + FunctionCallResponseItem m3 = Assert.IsAssignableFrom(convertedItems[3]); + Assert.Equal("callid123", m3.CallId); + Assert.Equal("SomeFunction", m3.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(m3.FunctionArguments.ToMemory().Span))); + + FunctionCallOutputResponseItem m4 = Assert.IsAssignableFrom(convertedItems[4]); + Assert.Equal("callid123", m4.CallId); + Assert.Equal("theresult", m4.FunctionOutput); + + MessageResponseItem m5 = Assert.IsAssignableFrom(convertedItems[5]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); + Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); + } + + [Fact] + public void AsOpenAIResponseItems_RoundtripsRawRepresentation() + { + List messages = + [ + new(ChatRole.User, + [ + new TextContent("Hello, "), + new AIContent { RawRepresentation = ResponseItem.CreateWebSearchCallItem() }, + new AIContent { RawRepresentation = ResponseItem.CreateReferenceItem("123") }, + new TextContent("World"), + new TextContent("!"), + ]), + new(ChatRole.Assistant, + [ + new TextContent("Hi!"), + new AIContent { RawRepresentation = ResponseItem.CreateReasoningItem("text") }, + ]), + new(ChatRole.User, + [ + new AIContent { RawRepresentation = ResponseItem.CreateSystemMessageItem("test") }, + ]), + ]; + + var items = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(7, items.Length); + Assert.Equal("Hello, ", ((MessageResponseItem)items[0]).Content[0].Text); + Assert.Same(messages[0].Contents[1].RawRepresentation, items[1]); + Assert.Same(messages[0].Contents[2].RawRepresentation, items[2]); + Assert.Equal("World", ((MessageResponseItem)items[3]).Content[0].Text); + Assert.Equal("!", ((MessageResponseItem)items[3]).Content[1].Text); + Assert.Equal("Hi!", ((MessageResponseItem)items[4]).Content[0].Text); + Assert.Same(messages[1].Contents[1].RawRepresentation, items[5]); + Assert.Same(messages[2].Contents[0].RawRepresentation, items[6]); + } + + [Fact] + public void AsChatResponse_ConvertsOpenAIChatCompletion() + { + Assert.Throws("chatCompletion", () => ((ChatCompletion)null!).AsChatResponse()); + + ChatCompletion cc = OpenAIChatModelFactory.ChatCompletion( + "id", OpenAI.Chat.ChatFinishReason.Length, null, null, + [ChatToolCall.CreateFunctionToolCall("id", "functionName", BinaryData.FromString("test"))], + ChatMessageRole.User, null, null, null, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + cc.Content.Add(ChatMessageContentPart.CreateTextPart("Hello, world!")); + cc.Content.Add(ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))); + + ChatResponse response = cc.AsChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.User, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0], exactMatch: false).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1], exactMatch: false).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2], exactMatch: false).Name); + } + + [Fact] + public async Task AsChatResponse_ConvertsOpenAIStreamingChatCompletionUpdates() + { + Assert.Throws("chatCompletionUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); + + List updates = []; + await foreach (var update in CreateUpdates().AsChatResponseUpdatesAsync()) + { + updates.Add(update); + } + + var response = updates.ToChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0], exactMatch: false).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1], exactMatch: false).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2], exactMatch: false).Name); + + static async IAsyncEnumerable CreateUpdates() + { + await Task.Yield(); + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + "id", + new ChatMessageContent( + ChatMessageContentPart.CreateTextPart("Hello, world!"), + ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))), + null, + [OpenAIChatModelFactory.StreamingChatToolCallUpdate(0, "id", ChatToolCallKind.Function, "functionName", BinaryData.FromString("test"))], + ChatMessageRole.Assistant, + null, null, null, OpenAI.Chat.ChatFinishReason.ToolCalls, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + } + } + + [Fact] + public void AsChatResponse_ConvertsOpenAIResponse() + { + Assert.Throws("response", () => ((OpenAIResponse)null!).AsChatResponse()); + + // The OpenAI library currently doesn't provide any way to create an OpenAIResponse instance, + // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + } + + [Fact] + public void AsChatResponseUpdatesAsync_ConvertsOpenAIStreamingResponseUpdates() + { + Assert.Throws("responseUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); + + // The OpenAI library currently doesn't provide any way to create a StreamingResponseUpdate instance, + // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + } + + [Fact] + public void AsChatMessages_FromOpenAIChatMessages_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsChatMessages().ToArray()); + + List openAIMessages = + [ + new SystemChatMessage("You are a helpful assistant."), + new UserChatMessage("Hello"), + new AssistantChatMessage(ChatMessageContentPart.CreateTextPart("Hi there!")), + new ToolChatMessage("call456", "Function output") + ]; + + var convertedMessages = openAIMessages.AsChatMessages().ToArray(); + + Assert.Equal(4, convertedMessages.Length); + + Assert.Equal("You are a helpful assistant.", convertedMessages[0].Text); + Assert.Equal("Hello", convertedMessages[1].Text); + Assert.Equal("Hi there!", convertedMessages[2].Text); + Assert.Equal("Function output", convertedMessages[3].Contents.OfType().First().Result); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("items", () => ((IEnumerable)null!).AsChatMessages()); + } + + [Fact] + public void AsChatMessages_FromResponseItems_ProducesExpectedOutput() + { + List inputMessages = + [ + new(ChatRole.Assistant, "Hi there!") + ]; + + var responseItems = inputMessages.AsOpenAIResponseItems().ToArray(); + + var convertedMessages = responseItems.AsChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + var message = convertedMessages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal("Hi there!", message.Text); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithEmptyCollection_ReturnsEmptyCollection() + { + var convertedMessages = Array.Empty().AsChatMessages().ToArray(); + Assert.Empty(convertedMessages); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithFunctionCall_HandlesCorrectly() + { + List inputMessages = + [ + new(ChatRole.Assistant, + [ + new TextContent("I'll call a function."), + new FunctionCallContent("call123", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + ]; + + var responseItems = inputMessages.AsOpenAIResponseItems().ToArray(); + var convertedMessages = responseItems.AsChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + var message = convertedMessages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + + var textContent = message.Contents.OfType().FirstOrDefault(); + var functionCall = message.Contents.OfType().FirstOrDefault(); + + Assert.NotNull(textContent); + Assert.Equal("I'll call a function.", textContent.Text); + + Assert.NotNull(functionCall); + Assert.Equal("call123", functionCall.CallId); + Assert.Equal("TestFunction", functionCall.Name); + Assert.Equal("value", functionCall.Arguments!["param"]?.ToString()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIChatCompletion()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithMultipleContents_ProducesValidInstance() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Here's an image and some text."), + new UriContent("https://example.com/image.jpg", "image/jpeg"), + new DataContent(new byte[] { 1, 2, 3, 4 }, "application/octet-stream") + ])) + { + ResponseId = "multi-content-response", + ModelId = "gpt-4-vision", + FinishReason = ChatFinishReason.Stop, + CreatedAt = new DateTimeOffset(2025, 1, 3, 14, 30, 0, TimeSpan.Zero), + Usage = new UsageDetails + { + InputTokenCount = 25, + OutputTokenCount = 12, + TotalTokenCount = 37 + } + }; + + ChatCompletion completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.Equal("multi-content-response", completion.Id); + Assert.Equal("gpt-4-vision", completion.Model); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ChatMessageRole.Assistant, completion.Role); + Assert.Equal(new DateTimeOffset(2025, 1, 3, 14, 30, 0, TimeSpan.Zero), completion.CreatedAt); + + Assert.NotNull(completion.Usage); + Assert.Equal(25, completion.Usage.InputTokenCount); + Assert.Equal(12, completion.Usage.OutputTokenCount); + Assert.Equal(37, completion.Usage.TotalTokenCount); + + Assert.NotEmpty(completion.Content); + Assert.Contains(completion.Content, c => c.Text == "Here's an image and some text."); + } + + [Fact] + public void AsOpenAIChatCompletion_WithEmptyData_HandlesGracefully() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")); + var completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.NotNull(completion); + Assert.Equal(ChatMessageRole.Assistant, completion.Role); + Assert.Equal("Hello", Assert.Single(completion.Content).Text); + Assert.Empty(completion.ToolCalls); + + var emptyResponse = new ChatResponse([]); + var emptyCompletion = emptyResponse.AsOpenAIChatCompletion(); + Assert.NotNull(emptyCompletion); + Assert.Equal(ChatMessageRole.Assistant, emptyCompletion.Role); + } + + [Fact] + public void AsOpenAIChatCompletion_WithComplexFunctionCallArguments_SerializesCorrectly() + { + var complexArgs = new Dictionary + { + ["simpleString"] = "hello", + ["number"] = 42, + ["boolean"] = true, + ["nullValue"] = null, + ["nestedObject"] = new Dictionary + { + ["innerString"] = "world", + ["innerArray"] = new[] { 1, 2, 3 } + } + }; + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("I'll process this complex data."), + new FunctionCallContent("process_data", "ProcessComplexData", complexArgs) + ])) + { + ResponseId = "complex-function-call", + ModelId = "gpt-4", + FinishReason = ChatFinishReason.ToolCalls + }; + + ChatCompletion completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.Equal("complex-function-call", completion.Id); + Assert.Equal(OpenAI.Chat.ChatFinishReason.ToolCalls, completion.FinishReason); + + var toolCall = Assert.Single(completion.ToolCalls); + Assert.Equal("process_data", toolCall.Id); + Assert.Equal("ProcessComplexData", toolCall.FunctionName); + + var deserializedArgs = JsonSerializer.Deserialize>(toolCall.FunctionArguments.ToMemory().Span); + Assert.NotNull(deserializedArgs); + Assert.Equal("hello", deserializedArgs["simpleString"]?.ToString()); + Assert.Equal(42, ((JsonElement)deserializedArgs["number"]!).GetInt32()); + Assert.True(((JsonElement)deserializedArgs["boolean"]!).GetBoolean()); + Assert.Null(deserializedArgs["nullValue"]); + + var nestedObj = (JsonElement)deserializedArgs["nestedObject"]!; + Assert.Equal("world", nestedObj.GetProperty("innerString").GetString()); + Assert.Equal(3, nestedObj.GetProperty("innerArray").GetArrayLength()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithDifferentFinishReasons_MapsCorrectly() + { + var testCases = new[] + { + (ChatFinishReason.Stop, OpenAI.Chat.ChatFinishReason.Stop), + (ChatFinishReason.Length, OpenAI.Chat.ChatFinishReason.Length), + (ChatFinishReason.ContentFilter, OpenAI.Chat.ChatFinishReason.ContentFilter), + (ChatFinishReason.ToolCalls, OpenAI.Chat.ChatFinishReason.ToolCalls) + }; + + foreach (var (inputFinishReason, expectedOpenAIFinishReason) in testCases) + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) + { + FinishReason = inputFinishReason + }; + + var completion = chatResponse.AsOpenAIChatCompletion(); + Assert.Equal(expectedOpenAIFinishReason, completion.FinishReason); + } + } + + [Fact] + public void AsOpenAIChatCompletion_WithDifferentRoles_MapsCorrectly() + { + var testCases = new[] + { + (ChatRole.Assistant, ChatMessageRole.Assistant), + (ChatRole.User, ChatMessageRole.User), + (ChatRole.System, ChatMessageRole.System), + (ChatRole.Tool, ChatMessageRole.Tool) + }; + + foreach (var (inputRole, expectedOpenAIRole) in testCases) + { + var chatResponse = new ChatResponse(new ChatMessage(inputRole, "Test")); + var completion = chatResponse.AsOpenAIChatCompletion(); + Assert.Equal(expectedOpenAIRole, completion.Role); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithNullArgument_ThrowsArgumentNullException() + { + var asyncEnumerable = ((IAsyncEnumerable)null!).AsOpenAIStreamingChatCompletionUpdatesAsync(); + await Assert.ThrowsAsync(async () => await asyncEnumerable.GetAsyncEnumerator().MoveNextAsync()); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithEmptyCollection_ReturnsEmptySequence() + { + var updates = new List(); + var result = new List(); + + await foreach (var update in CreateAsyncEnumerable(updates).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Empty(result); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithRawRepresentation_ReturnsOriginal() + { + var originalUpdate = OpenAIChatModelFactory.StreamingChatCompletionUpdate( + "test-id", + new ChatMessageContent(ChatMessageContentPart.CreateTextPart("Hello")), + role: ChatMessageRole.Assistant, + finishReason: OpenAI.Chat.ChatFinishReason.Stop, + createdAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + model: "gpt-3.5-turbo"); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Hello") + { + RawRepresentation = originalUpdate + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Same(originalUpdate, result[0]); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithTextContent_CreatesValidUpdate() + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Hello, world!") + { + ResponseId = "response-123", + MessageId = "message-456", + ModelId = "gpt-4", + FinishReason = ChatFinishReason.Stop, + CreatedAt = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero) + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Equal("gpt-4", streamingUpdate.Model); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, streamingUpdate.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), streamingUpdate.CreatedAt); + Assert.Equal(ChatMessageRole.Assistant, streamingUpdate.Role); + Assert.Equal("Hello, world!", Assert.Single(streamingUpdate.ContentUpdate).Text); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithUsageContent_CreatesUpdateWithUsage() + { + var responseUpdate = new ChatResponseUpdate + { + ResponseId = "response-123", + Contents = + [ + new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30 + }) + ] + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.NotNull(streamingUpdate.Usage); + Assert.Equal(20, streamingUpdate.Usage.OutputTokenCount); + Assert.Equal(10, streamingUpdate.Usage.InputTokenCount); + Assert.Equal(30, streamingUpdate.Usage.TotalTokenCount); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithFunctionCallContent_CreatesUpdateWithToolCalls() + { + var functionCallContent = new FunctionCallContent("call-123", "GetWeather", new Dictionary + { + ["location"] = "Seattle", + ["units"] = "celsius" + }); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, [functionCallContent]) + { + ResponseId = "response-123" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Single(streamingUpdate.ToolCallUpdates); + + var toolCallUpdate = streamingUpdate.ToolCallUpdates[0]; + Assert.Equal(0, toolCallUpdate.Index); + Assert.Equal("call-123", toolCallUpdate.ToolCallId); + Assert.Equal(ChatToolCallKind.Function, toolCallUpdate.Kind); + Assert.Equal("GetWeather", toolCallUpdate.FunctionName); + + var deserializedArgs = JsonSerializer.Deserialize>( + toolCallUpdate.FunctionArgumentsUpdate.ToMemory().Span); + Assert.Equal("Seattle", deserializedArgs?["location"]?.ToString()); + Assert.Equal("celsius", deserializedArgs?["units"]?.ToString()); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleFunctionCalls_CreatesCorrectIndexes() + { + var functionCall1 = new FunctionCallContent("call-1", "Function1", new Dictionary { ["param1"] = "value1" }); + var functionCall2 = new FunctionCallContent("call-2", "Function2", new Dictionary { ["param2"] = "value2" }); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, [functionCall1, functionCall2]) + { + ResponseId = "response-123" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal(2, streamingUpdate.ToolCallUpdates.Count); + + Assert.Equal(0, streamingUpdate.ToolCallUpdates[0].Index); + Assert.Equal("call-1", streamingUpdate.ToolCallUpdates[0].ToolCallId); + Assert.Equal("Function1", streamingUpdate.ToolCallUpdates[0].FunctionName); + + Assert.Equal(1, streamingUpdate.ToolCallUpdates[1].Index); + Assert.Equal("call-2", streamingUpdate.ToolCallUpdates[1].ToolCallId); + Assert.Equal("Function2", streamingUpdate.ToolCallUpdates[1].FunctionName); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMixedContent_IncludesAllContent() + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, + [ + new TextContent("Processing your request..."), + new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" }), + new UsageContent(new UsageDetails { TotalTokenCount = 50 }) + ]) + { + ResponseId = "response-123", + ModelId = "gpt-4" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Equal("gpt-4", streamingUpdate.Model); + + // Should have text content + Assert.Contains(streamingUpdate.ContentUpdate, c => c.Text == "Processing your request..."); + + // Should have tool call + Assert.Single(streamingUpdate.ToolCallUpdates); + Assert.Equal("call-123", streamingUpdate.ToolCallUpdates[0].ToolCallId); + + // Should have usage + Assert.NotNull(streamingUpdate.Usage); + Assert.Equal(50, streamingUpdate.Usage.TotalTokenCount); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithDifferentRoles_MapsCorrectly() + { + var testCases = new[] + { + (ChatRole.Assistant, ChatMessageRole.Assistant), + (ChatRole.User, ChatMessageRole.User), + (ChatRole.System, ChatMessageRole.System), + (ChatRole.Tool, ChatMessageRole.Tool) + }; + + foreach (var (inputRole, expectedOpenAIRole) in testCases) + { + var responseUpdate = new ChatResponseUpdate(inputRole, "Test message"); + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Equal(expectedOpenAIRole, result[0].Role); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithDifferentFinishReasons_MapsCorrectly() + { + var testCases = new[] + { + (ChatFinishReason.Stop, OpenAI.Chat.ChatFinishReason.Stop), + (ChatFinishReason.Length, OpenAI.Chat.ChatFinishReason.Length), + (ChatFinishReason.ContentFilter, OpenAI.Chat.ChatFinishReason.ContentFilter), + (ChatFinishReason.ToolCalls, OpenAI.Chat.ChatFinishReason.ToolCalls) + }; + + foreach (var (inputFinishReason, expectedOpenAIFinishReason) in testCases) + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Test") + { + FinishReason = inputFinishReason + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Equal(expectedOpenAIFinishReason, result[0].FinishReason); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleUpdates_ProcessesAllCorrectly() + { + var updates = new[] + { + new ChatResponseUpdate(ChatRole.Assistant, "Hello, ") + { + ResponseId = "response-123", + MessageId = "message-1" + + // No FinishReason set - null + }, + new ChatResponseUpdate(ChatRole.Assistant, "world!") + { + ResponseId = "response-123", + MessageId = "message-1", + FinishReason = ChatFinishReason.Stop + } + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(updates).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Equal(2, result.Count); + + Assert.Equal("response-123", result[0].CompletionId); + Assert.Equal("Hello, ", Assert.Single(result[0].ContentUpdate).Text); + + // The ToChatFinishReason method defaults null to Stop + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[0].FinishReason); + + Assert.Equal("response-123", result[1].CompletionId); + Assert.Equal("world!", Assert.Single(result[1].ContentUpdate).Text); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[1].FinishReason); + } + + [Fact] + public void AsOpenAIResponse_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponse()); + } + + [Fact] + public void AsOpenAIResponse_WithRawRepresentation_ReturnsOriginal() + { + var originalOpenAIResponse = OpenAIResponsesModelFactory.OpenAIResponse( + "original-response-id", + new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + ResponseStatus.Completed, + usage: null, + maxOutputTokenCount: 100, + outputItems: [], + parallelToolCallsEnabled: false, + model: "gpt-4", + temperature: 0.7f, + topP: 0.9f, + previousResponseId: "prev-id", + instructions: "Test instructions"); + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) + { + RawRepresentation = originalOpenAIResponse + }; + + var result = chatResponse.AsOpenAIResponse(); + + Assert.Same(originalOpenAIResponse, result); + } + + [Fact] + public void AsOpenAIResponse_WithBasicChatResponse_CreatesValidOpenAIResponse() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello, world!")) + { + ResponseId = "test-response-id", + ModelId = "gpt-4-turbo", + CreatedAt = new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero), + FinishReason = ChatFinishReason.Stop + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.NotNull(openAIResponse); + Assert.Equal("test-response-id", openAIResponse.Id); + Assert.Equal("gpt-4-turbo", openAIResponse.Model); + Assert.Equal(new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero), openAIResponse.CreatedAt); + Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); + Assert.NotNull(openAIResponse.OutputItems); + Assert.Single(openAIResponse.OutputItems); + + var outputItem = Assert.IsAssignableFrom(openAIResponse.OutputItems.First()); + Assert.Equal("Hello, world!", Assert.Single(outputItem.Content).Text); + } + + [Fact] + public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test message")) + { + ResponseId = "options-test", + ModelId = "gpt-3.5-turbo" + }; + + var options = new ChatOptions + { + MaxOutputTokens = 500, + AllowMultipleToolCalls = true, + ConversationId = "conversation-123", + Instructions = "You are a helpful assistant.", + Temperature = 0.8f, + TopP = 0.95f, + ModelId = "override-model" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("options-test", openAIResponse.Id); + Assert.Equal("gpt-3.5-turbo", openAIResponse.Model); + Assert.Equal(500, openAIResponse.MaxOutputTokenCount); + Assert.True(openAIResponse.ParallelToolCallsEnabled); + Assert.Equal("conversation-123", openAIResponse.PreviousResponseId); + Assert.Equal("You are a helpful assistant.", openAIResponse.Instructions); + Assert.Equal(0.8f, openAIResponse.Temperature); + Assert.Equal(0.95f, openAIResponse.TopP); + } + + [Fact] + public void AsOpenAIResponse_WithEmptyMessages_CreatesResponseWithEmptyOutputItems() + { + var chatResponse = new ChatResponse([]) + { + ResponseId = "empty-response", + ModelId = "gpt-4" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.Equal("empty-response", openAIResponse.Id); + Assert.Equal("gpt-4", openAIResponse.Model); + Assert.Empty(openAIResponse.OutputItems); + } + + [Fact] + public void AsOpenAIResponse_WithMultipleMessages_ConvertsAllMessages() + { + var messages = new List + { + new(ChatRole.Assistant, "First message"), + new(ChatRole.Assistant, "Second message"), + new(ChatRole.Assistant, + [ + new TextContent("Third message with function call"), + new FunctionCallContent("call-123", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "multi-message-response" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.Equal(4, openAIResponse.OutputItems.Count); + + var messageItems = openAIResponse.OutputItems.OfType().ToArray(); + var functionCallItems = openAIResponse.OutputItems.OfType().ToArray(); + + Assert.Equal(3, messageItems.Length); + Assert.Single(functionCallItems); + + Assert.Equal("First message", Assert.Single(messageItems[0].Content).Text); + Assert.Equal("Second message", Assert.Single(messageItems[1].Content).Text); + Assert.Equal("Third message with function call", Assert.Single(messageItems[2].Content).Text); + + Assert.Equal("call-123", functionCallItems[0].CallId); + Assert.Equal("TestFunction", functionCallItems[0].FunctionName); + } + + [Fact] + public void AsOpenAIResponse_WithToolMessages_ConvertsCorrectly() + { + var messages = new List + { + new(ChatRole.Assistant, + [ + new TextContent("I'll call a function"), + new FunctionCallContent("call-456", "GetWeather", new Dictionary { ["location"] = "Seattle" }) + ]), + new(ChatRole.Tool, [new FunctionResultContent("call-456", "The weather is sunny")]), + new(ChatRole.Assistant, "The weather in Seattle is sunny!") + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "tool-message-test" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + var outputItems = openAIResponse.OutputItems.ToArray(); + Assert.Equal(4, outputItems.Length); + + // Should have message, function call, function output, and final message + Assert.IsType(outputItems[0], exactMatch: false); + Assert.IsType(outputItems[1], exactMatch: false); + Assert.IsType(outputItems[2], exactMatch: false); + Assert.IsType(outputItems[3], exactMatch: false); + + var functionCallOutput = (FunctionCallOutputResponseItem)outputItems[2]; + Assert.Equal("call-456", functionCallOutput.CallId); + Assert.Equal("The weather is sunny", functionCallOutput.FunctionOutput); + } + + [Fact] + public void AsOpenAIResponse_WithSystemAndUserMessages_ConvertsCorrectly() + { + var messages = new List + { + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello, how are you?"), + new(ChatRole.Assistant, "I'm doing well, thank you for asking!") + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "system-user-test" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + var outputItems = openAIResponse.OutputItems.ToArray(); + Assert.Equal(3, outputItems.Length); + + var systemMessage = Assert.IsType(outputItems[0], exactMatch: false); + var userMessage = Assert.IsType(outputItems[1], exactMatch: false); + var assistantMessage = Assert.IsType(outputItems[2], exactMatch: false); + + Assert.Equal("You are a helpful assistant.", Assert.Single(systemMessage.Content).Text); + Assert.Equal("Hello, how are you?", Assert.Single(userMessage.Content).Text); + Assert.Equal("I'm doing well, thank you for asking!", Assert.Single(assistantMessage.Content).Text); + } + + [Fact] + public void AsOpenAIResponse_WithDefaultValues_UsesExpectedDefaults() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Default test")); + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.NotNull(openAIResponse); + Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); + Assert.False(openAIResponse.ParallelToolCallsEnabled); + Assert.Null(openAIResponse.MaxOutputTokenCount); + Assert.Null(openAIResponse.Temperature); + Assert.Null(openAIResponse.TopP); + Assert.Null(openAIResponse.PreviousResponseId); + Assert.Null(openAIResponse.Instructions); + Assert.NotNull(openAIResponse.OutputItems); + } + + [Fact] + public void AsOpenAIResponse_WithOptionsButNoModelId_UsesOptionsModelId() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Model test")); + + var options = new ChatOptions + { + ModelId = "options-model-id" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("options-model-id", openAIResponse.Model); + } + + [Fact] + public void AsOpenAIResponse_WithBothModelIds_PrefersChatResponseModelId() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Model priority test")) + { + ModelId = "response-model-id" + }; + + var options = new ChatOptions + { + ModelId = "options-model-id" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("response-model-id", openAIResponse.Model); + } + + [Fact] + public void ListAddResponseTool_AddsToolCorrectly() + { + Assert.Throws("tools", () => ((IList)null!).Add(ResponseTool.CreateWebSearchTool())); + Assert.Throws("tool", () => new List().Add((ResponseTool)null!)); + + Assert.Throws("tool", () => ((ResponseTool)null!).AsAITool()); + + ChatOptions options; + + options = new() + { + Tools = new List { ResponseTool.CreateWebSearchTool() }, + }; + Assert.Single(options.Tools); + Assert.NotNull(options.Tools[0]); + + var rawSearchTool = ResponseTool.CreateWebSearchTool(); + options = new() + { + Tools = [rawSearchTool.AsAITool()], + }; + Assert.Single(options.Tools); + Assert.NotNull(options.Tools[0]); + + Assert.Same(rawSearchTool, options.Tools[0].GetService()); + Assert.Same(rawSearchTool, options.Tools[0].GetService()); + Assert.Null(options.Tools[0].GetService("key")); + } + + private static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) + { + await Task.Yield(); + yield return item; + } + } + + private static string RemoveWhitespace(string input) => Regex.Replace(input, @"\s+", ""); + + /// Helper class for testing unknown tool types. + private sealed class UnknownAITool : AITool + { + public override string Name => "unknown_tool"; + } + + /// Helper class for testing WebSearchTool with additional properties. + private sealed class HostedWebSearchToolWithProperties : HostedWebSearchTool + { + private readonly Dictionary _additionalProperties; + + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties; + + public HostedWebSearchToolWithProperties(Dictionary additionalProperties) + { + _additionalProperties = additionalProperties; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 0caa50935f2..0db88d499e1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -6,7 +6,6 @@ using System.ClientModel.Primitives; using System.Net.Http; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -25,17 +24,13 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsIEmbeddingGenerator()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IEmbeddingGenerator> embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); var metadata = embeddingGenerator.GetService(); @@ -130,10 +125,7 @@ public async Task GetEmbeddingsAsync_ExpectedRequestResponse() using VerbatimHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions - { - Transport = new HttpClientPipelineTransport(httpClient), - }).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); var response = await generator.GenerateAsync([ "hello, world!", @@ -154,4 +146,93 @@ public async Task GetEmbeddingsAsync_ExpectedRequestResponse() Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); } } + + [Fact] + public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation() + { + const string Input = """ + { + "input":["hello, world!","red, white, blue"], + "dimensions":1536, + "model":"text-embedding-3-small", + "encoding_format":"base64", + "user":"MyEndUserID" + } + """; + + const string Output = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": "qjH+vMcj07wP1+U7kbwjOv4cwLyL3iy9DkgpvCkBQD0bthW98o6SvMMwmTrQRQa9r7b1uy4tuLzssJs7jZspPe0JG70KJy89ae4fPNLUwjytoHk9BX/1OlXCfTzc07M8JAMIPU7cibsUJiC8pTNGPWUbJztfwW69oNwOPQIQ+rwm60M7oAfOvDMAsTxb+fM77WIaPIverDqcu5S84f+rvFyr8rxqoB686/4cPVnj9ztLHw29mJqaPAhH8Lz/db86qga/PGhnYD1WST28YgWru1AdRTz/db899PIPPBzBE720ie47ujymPbh/Kb0scLs8V1Q7PGIFqzwVMR48xp+UOhNGYTxfwW67CaDvvOeEI7tgc228uQNoPXrLBztd2TI9HRqTvLuVJbytoPm8YVMsOvi6irzweJY7/WpBvI5NKL040ym95ccmPAfj8rxJCZG9bsGYvJkpVzszp7G8wOxcu6/ZN7xXrTo7Q90YvGTtZjz/SgA8RWxVPL/hXjynl8O8ZzGjvHK0Uj0dRVI954QjvaqKfTxmUeS8Abf6O0RhV7tr+R098rnRPAju8DtoiiK95SCmvGV0pjwQMOW9wJPdPPutxDxYivi8NLKvPI3pKj3UDYE9Fg5cvQsyrTz+HEC9uuMmPMEaHbzJ4E8778YXvVDERb2cFBS9tsIsPLU7bT3+R/+8b55WPLhRaTzsgls9Nb2tuhNG4btlzSW9Y7cpvO1iGr0lh0a8u8BkvadJQj24f6k9J51CvbAPdbwCEHq8CicvvIKROr0ESbg7GMvYPE6OCLxS2sG7/WrBPOzbWj3uP1i9TVXKPPJg0rtp7h87TSqLPCmowLxrfdy8XbbwPG06WT33jEo9uxlkvcQN17tAmVy8h72yPEdMFLz4Ewo7BPs2va35eLynScI8WpV2PENW2bwQBSa9lSufu32+wTwl4MU8vohfvRyT07ylCIe8dHHPPPg+ST0Ooag8EsIiO9F7w7ylM0Y7dfgOPADaPLwX7hq7iG8xPDW9Lb1Q8oU98twTPYDUvTomwIQ8akcfvUhXkj3mK6Q8syXxvAMb+DwfMI87bsGYPGUbJ71GHtS8XbbwvFQ+P70f14+7Uq+CPSXgxbvHfFK9icgwPQsEbbwm60O9EpRiPDjTKb3uFJm7p/BCPazDuzxh+iy8Xj2wvBqrl71a7nU9guq5PYNDOb1X2Pk8raD5u+bSpLsMD2u7C9ktPVS6gDzyjhI9vl2gPNO0AT0/vJ68XQTyvMMCWbubYhU9rzK3vLhRaToSlOK6qYIAvQAovrsa1la8CEdwPKOkCT1jEKm8Y7epvOv+HLsoJII704ZBPXbVTDubjVQ8aRnfOvspBr2imYs8MDi2vPFVVDxSrwK9hac2PYverLyxGnO9nqNQvfVLD71UEP+8tDDvurN+8Lzkbqc6tsKsu5WvXTtDKxo72b03PdDshryvXfY81JE/vLYbLL2Fp7Y7JbUGPEQ2GLyagla7fAxDPaVhhrxu7Ne7wzAZPOxXHDx5nUe9s35wPHcOizx1fM26FTGePAsEbbzzQBE9zCQMPW6TWDygucy8zPZLPM2oSjzfmy48EF4lvUttDj3NL4q8WIp4PRoEFzxKFA89uKpou9H3BDvK6009a33cPLq15rzv8VY9AQX8O1gxebzjCqo7EeJjPaA1DrxoZ2C65tIkvS0iOjxln2W8o0sKPMPXGb3Ak908cxhQvR8wDzzN1gq8DnNovMZGFbwUJiA9moJWPBl9VzkVA148TrlHO/nFCL1f7y68xe2VPIROtzvCJRu88YMUvaUzRj1qR5+7e6jFPGyrHL3/SgC9GMtYPJcT27yqMX688YOUO32+QT18iAS9cdeUPFbN+zvlx6a83d6xOzQLL7sZJNi8mSnXOuqan7uqin09CievvPw0hLyuq/c866Udu4T1t7wBXnu7zQFKvE5gyDxhUyw8qzx8vIrTLr0Kq+26TgdJPWmVoDzOiIk8aDwhPVug9Lq6iie9iSEwvOKxqjwMiyy7E59gPepMnjth+iw9ntGQOyDijbw76SW9i96sO7qKJ7ybYhU8R/6Su+GmLLzsgtu7inovPRG3pLwZUpi7YzvoucrAjjwOSKm8uuOmvLbt67wKUu68XCc0vbd0Kz0LXWy8lHmgPAAoPjxRpAS99oHMvOlBoDprUh09teLtOxoEl7z0mRA89tpLvVQQ/zyjdkk9ZZ/lvHLikrw76SW82LI5vXyIBLzVnL06NyGrPPXPzTta7nW8FTEePSVcB73FGFU9SFcSPbzL4rtXrbo84lirvcd8Urw9/yG9+63EvPdhCz2rPPw8PPQjvbXibbuo+0C8oWtLPWVG5juL3qw71Zw9PMUY1Tk3yKu8WWq3vLnYKL25A+i8zH2LvMW/1bxDr1g8Cqvtu3pPRr0FrbU8vVKiO0LSGj1b+fM7Why2ux1FUjwhv0s89lYNPUbFVLzJ4M88t/hpvdpvNj0EzfY7gC29u0HyW7yv2Tc8dSPOvNhZurzrpR28jUIqPM0vijxyDdK8iBYyvZ0fkrxalXa9JeBFPO/GF71dBHK8X8FuPKnY/jpQmQY9S5jNPGBz7TrpQaA87/FWvUHyWzwCEPq78HiWOhfuGr0ltYY9I/iJPamCgLwLBO28jZupu38ivzuIbzG8Cfnuu0dMlLypKQG7BzxyvR5QULwCEHo8k8ehPUXoFjzPvka9MDi2vPsphjwjfMi854QjvcW/VbzO4Yg7Li04vL/h3jsaL9a5iG8xuybrwzz3YYu8Gw8VvVGkBD1UugA99MRPuCjLArzvxhc8XICzPFyrcr0gDU296h7eu8jV0TxNKos8lSufuqT9CD1oDmE8sqGyu2PiaLz6osY5YjBqPBAFJrwIlfG8PlihOBE74zzzQJG8r112vJPHobyrPPw7YawrPb5doLqtzrk7qHcCPVIoQzz5l0i81UM+vFd/eryaVxc9xA3XO/6YgbweJZG7W840PF0Ecj19ZUI8x1GTOtb1vDyDnLg8yxkOvOywGz0kqgg8fTqDvKlUQL3Bnlu992ELvZPHobybCZa82LK5vf2NgzwnnUK8YMzsPKOkiTxDr9g6la/duz3/IbusR/q8lmFcvFbN+zztCRu95nklPVKBwjwEJnY6V9j5PPK50bz6okY7R6UTPPnFiDwCafk8N8grO/gTCr1iiWm8AhB6vHHXlLyV3Z08vtZgPMDsXDsck9O7mdBXvRLCojzkbqe8XxpuvDSyLzu0MO87cxhQvd3eMbxtDxo9JKqIvB8CT72zrDC7s37wPHvWhbuXQZs8UlYDu7ef6rzsV5y8IkYLvUo/Tjz+R/88PrGgujSyrzxsBJy8P7yeO7f46byfKpA8cFDVPLygIzsdGpO77LCbvLSJ7rtgzOy7sA91O0hXkrwhO408XKvyvMUYVT2mPsQ8d+DKu9lkuLy+iF89xZSWPJFjpDwIlfE8bC9bPBE7Y7z/+f08W6B0PAc8crhmquO7RvOUPDybJLwlXAe9cuKSvMPXGbxK5s48sZY0O+4UmT1/Ij+8oNyOvPIH07tNKos8yTnPO2RpKDwRO+O7vl2gvKSvB7xGmpW7nD9TPZpXFzyXQRs9InHKurhR6bwb4VS8iiwuO3pPxrxeD3A8CfluO//OPr0MaOq8r112vAwP6zynHgM9T+cHPJuNVLzLRE07EmkjvWHX6rzBGh285G4nPe6Y17sCafm8//n9PJkpVzv9P4K7IWbMPCtlvTxHKVK8JNXHO/uCBblAFZ48xyPTvGaqY7wXlRs9EDDlPHcOizyNQiq9W3W1O7iq6LxwqdQ69MRPvSJGC7n3CIy8HOxSvSjLAryU0p87QJncvEoUjzsi7Qu9U4xAOwn5brzfm668Wu71uu002rw/Y588o6SJPFfY+Tyfg4+8u5WlPMDBnTzVnD08ljadu3sBxbzfm668n4OPO9VDvrz0mZC8kFimPNiyOT134Mo8vquhvDA4Njyjz0i7zVpJu1rudbwmksQ794xKuhN0ITz/zj68Vvu7unBQ1bv8NAS97FecOyxwOzs1ZC68AIG9PKLyCryvtvU8ntEQPBkkWD2xwfO7QfLbOhqIVTykVog7lSufvKOkiTwpqEA9/RFCvKxHejx3tYu74woqPMS0VzoMtuu8ViZ7PL8PH72+L2C81JE/vN3eMTwoywK9z5OHOx4lkTwGBrW8c5QRu4khMDyvBPc8nR8SvdlkuLw0si+9S8aNvCkBwLsXwFo7Od4nPbo8pryp2P68GfkYPKpfvjrsV5w6zuEIvbHB8zxnMSM9C9mtu1nj97zjYym8XFJzPAiVcTyNm6m7X5YvPJ8qED1l+OS8WTx3vGKJ6bt+F0G9jk2oPAR0dzwIR/A8umdlvNLUwjzI1dE7yuvNvBdnW7zdhTI9xkaVPCVcB70Mtus7G7aVPDchK7xuwRi8oDWOu/SZkLxOuUe8c5QRPLBo9Dz/+f07zS+KvNBFBr1n2CO8TKNLO4ZZNbym5US5HsyRvGi1YTwxnDO71vW8PM3WCr3E4he816e7O7QFML2asBa8jZspPSVcBzvjvCi9ZGmoPHV8zbyyobK830KvOgw9q7xzZtG7R6WTPMpnjzxj4mg8mrAWPS+GN7xoZ2C8tsKsOVMIAj1fli89Zc0lO00qCzz+R/87XKvyvLxy4zy52Cg9YjBqvW9F1zybjVS8mwmWvLvA5DymugU9DOQrPJWvXbvT38C8TrnHvLbt67sgiQ49e32GPPTETzv7goW7cKnUOoOcuLpG85S8CoCuO7ef6rkaqxe90tTCPJ8qkDvuuxk8FFFfPK9ddrtAbh08roC4PAnOrztV8D08jemquwR09ziL3iy7xkaVumVG5rygNQ69CfnuPGBzbTyE9Tc9Z9ijPK8yNzxgoa084woqu1F2RLwN76m7hrI0vf7xgLwaXRY6JmeFO68ytzrrpR29XbZwPYI4uzvkFai8qHcCPRCJ5DxKFI+7dHHPPE65xzxvnta8BPs2vWaq4zwrvjy8tDDvvEq7D7076SU9q+N8PAsyLTxb+XM9xZQWPP7ufzxsXZu6BEk4vGXNJbwBXvu8xA3XO8lcEbuuJzk8GEeavGnun7sMPSs9ITsNu1yr8roj+Ik8To6IvKjQgbwIwzG8wqlZvDfIK7xln2W8B+Pyu1HPw7sBjDs9Ba01PGSU57w/Yx867FecPFdUu7w2b6w7X5avvA8l57ypKQE9oGBNPeyC27vGytM828i1PP9KAD2/4V68eZ1HvDHqtDvR94Q6UwgCPLMlcbz+w0C8HwJPu/I1k7yZ/pe8aLXhPHYDDT28oKO8p2wEvdVDvrxh+qy8WDF5vJBYpjpaR3U8vgQhPNItwrsJoG88UaQEu3e1C7yagtY6HOzSOw9+5ryYTBk9q+N8POMKqrwoywI9DLZrPCN8SDxYivi8b3MXPf/OvruvBHc8M6exvA3vKbxz7RA8Fdieu4rTrrwFVDa8Vvu7PF0Ecjs6N6e8BzzyPP/Ovrv2rww9t59qvEoUDz3HUZO7UJkGPRigmbz/+X28qjH+u3jACbxlzaW7DA9rvFLawbwLBO2547yoO1t1NTr1pI68Vs37PAI+Ojx8s8O8xnHUvPg+yTwLBO26ybUQPfUoTTw76SU8i96sPKWMRbwUqt46pj7EPGX4ZL3ILtG8AV77vM0BSjzKZ488CByxvIWnNjyIFrI83CwzPN2FsjzHUZO8rzK3O+iPIbyGCzQ98NGVuxpdlrxhrKs8hQC2vFWXvjsCaXm8oRJMPHyIBLz+HMA8W/nzvHkZCb0pqMC87m0YPCu+vDsM5Ks8VnR8vG0Pmrt0yk48y3KNvKcegzwGMXS9xZQWPDYWrTxxAtQ7IWZMPU4Hybw89CO8/eaCPPMSUTxuk9i8WAY6vGfYozsQMGW8Li24vI+mJzxKFI88HwJPPFru9btRz8O6L9+2u29F1zwC5bq7RGHXvMtyjbr5bIm7V626uxsPlTv1KE29UB3FPMwkDDupggC8SQkRvH4XQT1cJ7Q8nvzPvKsRvTu9+SI8JbUGuiP4iTx460i99JkQPNF7Qz26Dma8u+4kvHO/0LyzfvA8EIlkPUPdmLpmUWS8uxnku8f4E72ruL27BzxyvKeXwz1plSC8gpG6vEQ2mLvtYho91Zy9vLvA5DtnXGK7sZY0uyu+PLwXlZu8GquXvE2uSb0ezBG8wn6au470KD1Abh28YMzsvPQdT7xKP867Xg/wO81aSb0IarK7SY1PO5EKJTsMi6y8cH4VvcXtlbwdGhM8xTsXPQvZLbxgzOw7Pf8hPRsPlbzDMJm8ZGmoPM1aSb0HEbO8PPQjvX5wwDwQXiW9wlDaO7SJ7jxFE9a8FTEePG5omTvPkwc8vtZgux9bzrmwD3W8U2EBPAVUNj0hlIw7comTPAEF/DvKwI68YKGtPJ78Tz1boHQ9sOS1vHiSSTlVG307HsyRPHEwFDxQmQY8CaBvvB0aE70PfuY8+neHvHOUET3ssBu7+tCGPJl3WDx4wAk9d1yMPOqanzwGBjW8ZialPB7MEby1O+07J0RDu4yQq7xpGV88ZXQmPc3WCruRCqU8Xbbwu+0JG7kXGVq8SY1PvKblxDv/oH68r7Z1OynWgDklh0a8E/hfPBCJZL31/Y08sD21vA9+Zjy6DmY82WQ4PAJp+TxHTJQ8JKoIvUBunbwgDc26BzxyvVUb/bz+w8A8Wu51u8guUbyHZLM8Iu0LvJqCVj3nhKO96kwevVDyBb3UDYG79zNLO7KhMj1IgtE83NOzO0f+krw89CM9z5OHuz+OXj2TxyE8wOzcPP91v7zUZgA8DyVnvILqOTzn3aI8j/+mO8xPyzt1UQ48+R4IvQnOrzt1I067QtKau9vINb1+7AE8sA/1uy7UOLzpQSC8dqoNPSnWgDsJoO+8ANo8vfDRlbwefpC89wgMPI1CKrrYsrm78mBSvFFLBb1Pa0a8s1MxPHbVzLw+WCG9kbyjvNt6tLwfMA+8HwLPvGO3qTyyobK8DcFpPInIsLwXGdq7nBSUPGdc4ryTx6G8T+eHPBxolDvIqhK8rqv3u1fY+Tz3M0s9qNCBO/GDlL2N6Sq9XKtyPFMIgrw0Cy+7Y7epPLJzcrz/+X28la/du8MC2bwTn+C5YSXsvDneJzz/SoC8H9ePvHMY0Lx0nw+9lSsfvS3Jujz/SgC94rEqvQwP67zd3rE83NOzPKvj/DyYmpo8h2SzvF8abjye0ZC8vSRivCKfijs/vJ48NAuvvFIoQzzFGFU9dtVMPa2g+TtpGd88Uv2DO3kZiTwA2rw79f2Nu1ugdDx0nw+8di7MvIrTrjz08g+8j6anvGH6LLxQ8oW8LBc8Pf0/Ajxl+OQ8SQkRPYrTrrzyNRM8GquXu9ItQjz1Sw87C9mtuxXYnrwDl7m87Y1ZO2ChrbyhQIy4EsIiPWpHHz0inwo7teJtPJ0fEroHPPK7fp4APV/B7rwwODa8L4Y3OiaSxLsBBfw7RI8XvP5H/zxVlz68n1VPvEBuHbwTzSA8fOEDvV49sDs2b6y8mf6XPMVm1jvjvCg8ETvjPEQ2GLxK5s47Q92YuxOfYLyod4K8EDDlPHAlFj1zGFC8pWGGPE65R7wBMzy8nJjSvLoO5rwwkbU7Eu3hvLOsMDyyobI6YHNtPKs8fLzXp7s6AV57PV49MLsVMR68+4KFPIkhMLxeaG87mXdYulyAMzzQRQY9ljadu3YDDby7GWS7phOFPEJ5mzq6tea6Eu1hPJjzmTz+R388di5MvJn+F7wi7Qs8K768PFnj9zu5MSi8Gl2WvJfomzxHd1O8vw8fvONjqbxuaBk980ARPSNRiTwLMi272Fk6vDGcs7z60Ia8vX1hOzvppbuKLK48jZspvZkpV7pWJns7G7YVPdPfwLyruL08FFHfu7ZprbwT+N84+1TFPGpHn7y9JOI8xe2Vu08SR7zs29o8/RFCPCbAhDzfQi89OpCmvL194boeJZE8kQqlvES6VjrzEtE7eGeKu2kZX71rfdw8D6wmu6Y+xLzJXJE8DnPovJrbVbvkFai8KX0Bvfr7RbuXbNq8Gw+VPRCJ5LyA1D28uQPoPLygo7xENpi8/RHCvEOv2DwRtyS9o0uKPNshNbvmeSU8IyPJvCedQjy7GWQ8Wkf1vGKJ6bztYho8vHLju5cT2zzKZw+88jWTvFb7uznYCzm8" + }, + { + "object": "embedding", + "index": 1, + "embedding": "eyfbu150UDkC6hQ9ip9oPG7jWDw3AOm8DQlcvFiY5Lt3Z6W8BLPPOV0uOz3FlQk8h5AYvH6Aobv0z/E8nOQRvHI8H7rQA+s8F6X9vPplyDzuZ1u8T2cTvAUeoDt0v0Q9/xx5vOhqlT1EgXu8zfQavTK0CDxRxX08v3MIPAY29bzIpFm8bGAzvQkkazxCciu8mjyxvIK0rDx6mzC7Eqg3O8H2rTz9vo482RNiPUYRB7xaQMU80h8hu8kPqrtyPB+8dvxUvfplSD21bJY8oQ8YPZbCEDvxegw9bTJzvYNlEj0h2q+9mw5xPQ5P8TyWwpA7rmvvO2Go27xw2tO6luNqO2pEfTztTwa7KnbRvAbw37vkEU89uKAhPGfvF7u6I8c8DPGGvB1gjzxU2K48+oqDPLCo/zsskoc8PUclvXCUvjzOpQC9qxaKO1iY5LyT9XS9ZNzmvI74Lr03azk93CYTvFJVCTzd+FK8lwgmvcMzPr00q4O9k46FvEx5HbyIqO083xSJvC7PFzy/lOK7HPW+PF2ikDxeAHu9QnIrvSz59rl/UmG8ZNzmu2b4nD3V31Y5aXK9O/2+jrxljUw8y9jkPGuvTTxX5/48u44XPXFFpDwAiEm8lcuVvX6h+zwe7Lm8SUUSPHmkNTu9Eb08cP8OvYgcw7xU2C49Wm4FPeV8H72AA8c7eH/6vBI0Yj3L2GQ8/0G0PHg5ZTvHjAS9fNhAPcE8wzws2By6RWAhvWTcZjz+1uM8H1eKvHdnJT0TWR29KcVrPdu7wrvMQzW9VhW/Ozo09LvFtuM8OlmvPO5GAT3eHY68zTqwvIhiWLs1w1i9sGJqPaurOb0s2Jy8Z++XOwAU9Lggb988vnyNvVfGpLypKBS8IouVO60NBb26r/G6w+0ovbVslrz+kE68MQOjOxdf6DvoRdo8Z4RHPCvhIT3e7009P4Q1PQ0JXDyD8Ty8/ZnTuhu4Lj3X1lG9sVnlvMxDNb3wySY9cUWkPNZKJ73qyP+8rS7fPNhBojwpxes8kt0fPM7rlbwYEE68zoBFvdrExzsMzEu9BflkvF0uu7zNFfW8UyfJPPSJ3LrEBf68+6JYvef/xDpAe7C8f5h2vPqKA7xUTAS9eDllPVK8eL0+GeW7654gPQuGNr3/+x69YajbPAehRTyc5BE8pfQIPMGwGL2QoA87iGJYPYXoN7s4sc69f1JhPdYEkjxgkIa6uxpCvHtMljtYvR88uCzMPBeEo7wm1/U8GBDOvBkHybwyG3i7aeaSvQzMyzy3e2a9xZUJvVSSmTu7SII8x4yEPKAYHTxUTIQ8lcsVO5x5QT3VDRe963llO4K0rLqI1i07DX0xvQv6CznrniA9nL9WPTvl2Tw6WS+8NcPYvEL+VbzZfrK9NDcuO4wBNL0jXVW980PHvNZKJz1Oti09StG8vIZTiDwu8PE8zP0fO9340juv1j890vFgvMFqAz2kHui7PNxUPQehxTzjGlQ9vcunPL+U4jyfrUw8R+NGPHQF2jtSdmO8mYtLvF50ULyT1Bo9ONaJPC1kx7woznC83xQJvUdv8byEXA29keaku6Qe6Ly+fA29kKAPOxLuzLxjxJG9JnCGur58jTws2Jy8CkmmO3pVm7uwqH87Eu7Mu/SJXL0IUis9MFI9vGnmEr1Oti09Z+8XvH1DkbwcaZS8NDcuvT0BkLyPNT89Haakuza607wv5+w81KLGO80VdT3MiUq8J4hbPHHRzrwr4aG8PSJqvJOOBT3t2zC8eBgLvXchkLymOp66y9jkPDdG/jw2ulO983GHPDvl2Tt+Ooy9NwDpOzZ0Pr3xegw7bhGZvEpd57s5YjS9Gk1evIbfMjxBwcW8NnQ+PMlVPzxR6ji9M8zdPImHk7wQsby8u0gCPXtMFr22YxE9Wm4FPaXPzbygGJ093bK9OuYtBTxyXfk8iYeTvNH65byk/Q29QO+FvKbGyLxCcqs9nL/WvPtcQ72XTjs8kt2fuhaNKDxqRH08KX9WPbmXnDtXDDo96GoVPVw3QL0eeGS8ayOjvAIL7zywQZC9at0NvUMjET1Q8707eTDgvIio7Tv60Jg87kYBOw50LLx7BgE96qclPUXsSz0nQkY5aDUtvQF/RD1bZQC73fjSPHgYCzyPNT+9q315vbMvhjsvodc8tEdbPGcQ8jz8U768cYs5PIwBtL38x5M9PtPPvIex8jzfFIk9vsIivLsaQj2/uZ072y8YvSV5C7uoA9k8JA67PO5nWzvS8eC8av7nuxSWrbybpwE9f5h2vG3sXTmoA1k9sjiLvTBSPbxc8Sq9UpuePB+dHz2/cwg9BWS1vCrqJr2M3Pg86LAqPS/GEj3oRdq8GiyEvACISbuiJ+28FFAYuzBSvTzwDzy8K5uMvE5wmDpd6CW6dkJqPGlyvTwF2Iq9f1JhPSHarzwDdr88JXkLu4ADxzx5pDW7zqUAvdAoJj24wXs8doj/PH46jD2/2vc893fSuyxtTL0YnPg7IWbaPOiwqrxLDk27ZxDyPBpymbwW0z08M/odPTufRL1AVvU849Q+vBGDfD3JDyq6Z6kCPL9OzTz0rpe8FtM9vaDqXLx+W2Y7jHWJPGXT4TwJ3lW9M4bIPPCDkTwoZwE9XH1VOmksqLxLPI08cNrTvCyz4bz+Srm8kiO1vDP6nbvIpNk8MrSIvPe95zoTWR29SYsnPYC9MT2F6De93qm4PCbX9bqqhv47yky6PENE67x/DEw8JdYAvUdvcbywh6W8//ueO8fSmTyjTCi9yky6O/qr3TzvGEE8wqcTPeDmSDyuJVo8ip/ou1HqOLxOtq28y5LPuxk1Cb0Ddr+7c+2EvKQeaL1SVQk8XS47PGTcZjwdpiQ8uFqMO0QaDD1XxqS8mLmLuuSFJDz1xmy8PvgKvJAHf7yC+kE8VapuvetYC7tHCAI8oidtPOiwqjyoSW68xCo5vfzobTzz2HY88/0xPNkT4rty9om8RexLu9SiRrsVaG081gSSO5IjtTsOLpc72sTHPGCQBj0QJRI9BCclPI1sBDzCyO07QHuwvOYthTz4tGK5QHuwvWfvFz2CQNc8PviKPO8YwTuQoA89fjoMPBnBs7zGZ8m8uiPHvMdeRLx+gKE8keaku0wziDzZWfe8I4KQPJ0qpzs4sc47dyEQPEQaDDzVmcE8//uePJcIJjztTwa9ogaTOftcwztU2K48opvCuyz5drzqM1C7iYcTvfDJJjxXxiQ9o0wovO1PBrwqvGa7dSoVPbI4izvnuS88zzGrPH3POzzHXkQ9PSJqOXCUPryW4+o8ELE8PNZKp7z+Sjm8foChPPIGtzyTaUq8JA47vBiceDw3a7m6jWyEOmksKDwH59q5GMo4veALBL0SqDe7IaxvvBD3Ubxn7xc9+dkdPSBOBTxHCAI8mYvLOydCxjw5HB88zTqwvJXs77w9AZA9CxvmvIeQGL2rffm8JXkLPKqGfjyoSe464d1DPPd3UrpO/EK8qxYKvUuCojwhZlq8EPfRPKaAs7xKF9K85i0FvEYRhzyPNT88m6cBvdSiRjxnqQI9uOY2vcBFSLx4OeW7BxUbPCz59rt+W2Y7SWZsPGzUCLzE5KM7sIclvIdr3buoSW47AK0EPImHE7wgToU8IdovO7FZ5bxbzO+8uMF7PGayB7z6ioO8zzErPEcIgrxSm568FJYtvNf7jDyrffm8KaQRPcoGpTwleQu8EWKiPHPthLz44qI8pEOjvWh7QjzpPNU8lcuVPHCUPr3n/8Q8bNQIu0WmNr1Erzs95VfkPCeIW7vT0Aa7656gudH65bxw/w49ZrKHPHsn27sIUiu8mEU2vdUNF7wBf8Q809CGPFtlgDo1fcO85i2FPEcIAjwL+os653OavOu1AL2EN9K8H52fPKzoybuMdYk8T2cTO8lVPzyK5X07iNYtvD74ijzT0IY8RIF7vLLENbyZi8s8KwJ8vAne1TvGZ8k71gSSumJZwTybp4G8656gPG8IFL27SAI9arjSvKVbeDxljcy83fjSuxu4Lr2DZRK9G0TZvLFZ5bxR6ji8NPEYPbI4izyAvTE9riVaPCCUGrw0Ny48f1LhuzIb+DolBTY8UH9ou/4EpLyAvTG9CFIrvCBOBTlkIvy8WJhkvHIXZLkf47Q8GQfJvBpNXr1pcr07c8jJO2nmkrxOcJi8sy8GuzjWibu2Pta8WQO1PFPhs7z7XEO8pEMjvb9OzTz4bs08EWKiu0YyYbzeHQ695D+PPKVbeDzvGEG9B6HFO0uCojws+Xa7JQW2OpRgRbxjCqc8Sw7NPDTxmLwjXVW8sRNQvFPhszzM/Z88rVMavZPUGj06WS+8JpHgO3etursdx369uZccvKplJDws+Xa8fzGHPB1gj7yqZaQ887ecPBNZHbzoi2+7NwDpPMxDtbzfWh49H+O0PO+kaztI2kE8/xz5PImHE73fNWO8T60ovIPxPDvR2Yu8XH3VvMcYr7wfnR+9fUORPIdr3Tyn6wO9nkL8vM2uhTzGIbS66u26vE2/MrxFYKE8iwo5vLSNcLy+wiK9GTUJPK10dLzrniC8qkBpvPxTPrwzQLO8illTvFi9H7yMATS7ayOjO14Ae7z19Cy87dswPKbGyDzujJa93EdtPdsB2LYT5Ue9RhEHPKurubxm+By9+mVIvIy7HrxZj987yOpuvUdv8TvgCwS8TDMIO9xsqLsL+gs8BWS1PFRMBD1yXXm86GoVvK+QqjxRXg46TZHyu2ayhzx7TJa8uKAhPLyFkjsV3MI7niGiPGNQvDxgkIa887ccPUmLJ7yZsIa8KDnBvHgYi7yMR0m82ukCvRuK7junUvO8aeYSPXtt8LqXCKa84kgUPd5jIzxlRze93xQJPNNcMT2v1j889GiCPKRkfbxz7YQ8b06pO8cYL7xg9/U8yQ+qPGlyvbzfNWO8vZ3nPBGD/DtB5gC7yKRZPPTPcbz6q928bleuPI74rrzVDRe9CQORvMmb1Dzv0qs8DBLhu4dr3bta1fQ8aeYSvRD3UTugpMe8CxvmPP9BNDzHjAQ742DpOzXD2Dz4bk28c1T0Onxka7zEBf48uiNHvGayBz1pcj29NcPYvDnu3jz5kwg9WkBFvL58jTx/mHY8wTzDPDZ0Pru/uZ08PQGQPOFRmby4oKE8JktLPIx1iTsppBG9dyGQvHfzT7wzhki44KAzPSOCkDzv0iu8lGBFO2VHNzyKxKM72EEiPYtQzryT9fQ8UDnTPEx5nTzuZ9s8QO8FvG8IlDx7J9s6MUk4O9k4nbx7TBa7G7iuvCzYHDocr6k8/7UJPY2ymTwVIlg8KjC8OvSuFz2iJ+28cCBpvE0qAzw41ok7sgrLvPjiojyG37K6lwimvKcxGTwRHI28y5LPO/mTiDx82MC5VJIZPWkH7TwPusG8YhOsvH1DkbzUx4E8TQXIvO+ka7zKwI+8w+2oPNLxYLzxegy9zEM1PDo0dDxIINc8FdxCO46E2TwPRmw9+ooDvMmb1LwBf0S8CQMRvEXsS7zPvdU80qvLPLfvO7wbuK68iBzDO0cpXL2WndU7dXCqvOTLubytLl88LokCvZj/IDw0q4M8G7guvNkTYrq5UQe7vcunvIrEI7xuERm9RexLvAdbsDwLQCE7uVEHPYjWrbuM3Pi8g2WSO3R5L7x4XiC8vKZsu9Sixros+fa8UH/ouxxpFL3wyaa72sRHu2YZ9zuiJ2274o4pOjkcnzyagka7za4FvYrEozwCMCo7cJQ+vfqKAzzJ4em8fNhAPUB7sLylz80833v4vOU2ir1ty4M8UV4OPXQF2jyu30S9EjRivBVo7TwXX2g70ANrvEJyq7wQJRK99jE9O7c10brUxwE9SUUSPS4VLbzBsJg7FHHyPMz9n7latJo8bleuvBpN3jsF+WS8Ye7wO4nNKL0TWZ08iRM+vOn2v7sB8xm9jY3ePJ/zYbkLG+a7ZvicvGxgM73L2OS761iLPKcxmTrX+ww8J0JGu1MnyTtJZuw7pIm4PJbCED29V1K9PFCqPLBBkLxhYka8hXTiPEB7MDzrniA7h5CYvIR9ZzzARcg7TZHyu4sKOb1in9Y7nL9WO6gD2TxSduO8UaQjPQO81Lxw/w69KwL8O4FJ3D2XTju8SE6XPGDWGz0K1VC8YhMsvObCtDyndy49BCclu68cVbxemYu8sGLqOksOzTzj1L47ISBFvLly4Ttk3Oa8RhGHNwzxBj0v5+y7ogaTPA+6QbxiE6w8ubj2PDixzrstZEe9jbKZPPd30rwqMDw8TQXIPFurlTxx0c68jLsePfSJ3LuXTru8yeHpu6Ewcjx5D4a8BvBfvN8Uibs9R6W8lsIQvaEw8rvVUyw8SJQsPebCNDwu8PE8GMo4OxAlkjwJmMA8KaQRvdYlbDwNNxy9ouHXPDffDrxwZv46AK0EPJqCRrpWz6k8/0E0POAs3rxmsoe7zTqwO5mLyzyP7ym7wTzDvFB/aLx5D4a7doj/O67fxDtsO/g7uq9xvMWViTtC/tU7PhnlvIEogjxxRSQ9SJSsPIJA1zyBKAI9ockCPYC9MbxBTXC83xSJvPFVUb1n75c8uiNHOxdf6Drt27A8/FM+vJOvXz3a6QI8UaQjuvqKgzyOhNm831oevF+xYLxjCic8sn6gPDdrOTs3Rv66cP+Ou5785rycBew8J0JGPJOOBbw9Imq8q335O3MOX7xemQs8PtNPPE1L3Tx5dnU4A+EPPLrdsTzfFIm7LJIHPB4yz7zbAdi8FWjtu1h3Cj0oznA8kv55PKgDWbxIINc8xdsePa8cVbzmlHQ8IJSavAgMlrx4XiA8z3dAu2PEET3xm+a75//EvK2Zr7xbqxU8zP2fvOSFJD1xRSS7k44FvPzHkzz5+ne8+tAYvd5jIz1GMuE8yxSAO3KCNDyRuOS8wzO+vObCNDwzQLO7isQjva1TGrz6ioM79GgCPF66Zbx1KpW8qW6pu4RcDTzcJhO9SJQsO5G45LsAiMm8lRErvJqCxjzQbju7w3nTuTclpDywqP88ysCPvAF/xLxfa0u88cChPBjKODyaPLE8k69fvGFiRrvuRgG9ATmvvJEsOr21+EC9KX/WOrmXnDwDAuo8yky6PI1sBDvztxy8PviKPKInbbzbdS276mGQO2Kf1rwn/DC8ZrIHPBRxcj0z+h264d1DPdG0ULxvTqm5bDt4vToTmjuGJcg7tmMRO9YEEr3oJAC9THmdPKn607vcJhM8Zj6yvHR5r7ywYmq83fjSO5mLyzshIEU8EWKiuu9eVjw75dk7fzGHvNl+sjwJJOs8YllBPAtheztz7QQ92lDyvDEDozzEKrk7KnZRvG8pbjsdYI+7yky6OfWAVzzjYGk7NX3DOzrNhDyeIaI8joTZvFcMOryYRba8G7iuu893QDw9RyW7za6FvDUJ7rva6YK9D7rBPD1o/zxCLJa65TaKvHsGAT2g6ly8+tCYu+wqy7xeAHu8vZ1nPBv+QzwfVwo8CMYAvM+91TzKTDq8Ueo4u2uvzTsBf8Q8p+uDvKofDz12tj+8wP+yOlkDtTwYyji6ZdPhPGv14rwqdtE8YPf1vLIKy7yFLs28ouFXvO1PBj15pDU83xQJPdfWUTz8x5O64kgUPBQKA72eIaK6A3a/OyzYnLoYnPg4XMNqPdxsqLsKSaY7pfSIvBoshLupKJS8G0TZOu/SqzzFcE47cvaJPA19Mb14dQC8sVllvJmwhjycBey8cvaJOmSWUbvRtFC8WtX0O2r+57twIGm8yeFpvFuG2rzCyO08PUelPK5rbzouFS29uCxMPQAUdDqtma88wqeTu5gge7zH8/O7l067PJdOO7uKxCO8/xx5vKt9+TztTwa8OhOaO+Q/Dzw33w49CZhAvSubjDydttG8IdovPIADR7stHrI7ATmvvOAs3rzL2OQ69K4XvNccZ7zlV2S8c+0EPfNDxzydKqc6LLPhO8YhtDyJhxM9H1eKOaNMKLtOcBg9HPU+PTsrbzvT0Ia8BG26PB2mpDp7TJa8wP8yPVvM77t0ea86eTBgvFurFT1C/tW7CkkmvKOSPT2aPDG9lGDFPAhSq7u5UYc8l5TQPFh3ijz9vg68lGBFO4/vKTxViZS7eQ8GPTNAs7xmsoe8o0yoPJfaZbwlvyA8IazvO0XsS717TJY8flvmOgHFWbyWnVW8mdFgvJbCkDynDF68" + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 9, + "total_tokens": 9 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); + + var response = await generator.GenerateAsync([ + "hello, world!", + "red, white, blue", + ], new EmbeddingGenerationOptions + { + Dimensions = 3072, + RawRepresentationFactory = (e) => new OpenAI.Embeddings.EmbeddingGenerationOptions + { + Dimensions = 1536, + EndUserId = "MyEndUserID" + } + }); + + Assert.NotNull(response); + Assert.Equal(2, response.Count); + + Assert.NotNull(response.Usage); + Assert.Equal(9, response.Usage.InputTokenCount); + Assert.Equal(9, response.Usage.TotalTokenCount); + + foreach (Embedding e in response) + { + Assert.Equal("text-embedding-3-small", e.ModelId); + Assert.NotNull(e.CreatedAt); + Assert.Equal(1536, e.Vector.Length); + Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); + } + } + + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => generator.GenerateAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + private static IEmbeddingGenerator> CreateEmbeddingGenerator(HttpClient httpClient, string modelId) => + new OpenAIClient( + new ApiKeyCredential("apikey"), + new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetEmbeddingClient(modelId) + .AsIEmbeddingGenerator(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..7f9e6195aa3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// +/// OpenAI-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with OpenAI's chat client implementation. +/// +public class OpenAIImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOpenAIClient() + ?.GetChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini").AsIChatClient(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..ce0cdb7cf82 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public class OpenAIImageGeneratorIntegrationTests : ImageGeneratorIntegrationTests +{ + protected override IImageGenerator? CreateGenerator() + => IntegrationTestHelpers.GetOpenAIClient()? + .GetImageClient(TestRunnerConfiguration.Instance["OpenAI:ImageModel"] ?? "dall-e-3") + .AsIImageGenerator(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs new file mode 100644 index 00000000000..607b1e2859e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using OpenAI; +using OpenAI.Images; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIImageGeneratorTests +{ + [Fact] + public void AsIImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("imageClient", () => ((ImageClient)null!).AsIImageGenerator()); + } + + [Fact] + public void AsIImageGenerator_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "dall-e-3"; + + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IImageGenerator imageClient = client.GetImageClient(model).AsIImageGenerator(); + var metadata = imageClient.GetService(); + Assert.Equal(endpoint, metadata?.ProviderUri); + Assert.Equal(model, metadata?.DefaultModelId); + } + + [Fact] + public void GetService_ReturnsExpectedServices() + { + var client = new OpenAIClient(new ApiKeyCredential("key")); + IImageGenerator imageClient = client.GetImageClient("dall-e-3").AsIImageGenerator(); + + Assert.Same(imageClient, imageClient.GetService()); + Assert.Same(imageClient, imageClient.GetService()); + Assert.NotNull(imageClient.GetService()); + Assert.NotNull(imageClient.GetService()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 2c1d6cdc80e..6a7f82302ea 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -1,6 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + namespace Microsoft.Extensions.AI; public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests @@ -11,4 +18,536 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests .AsIChatClient(); public override bool FunctionInvokingChatClientSetsConversationId => true; + + // Test structure doesn't make sense with Responses. + public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask; + + [ConditionalFact] + public async Task UseCodeInterpreter_ProducesCodeExecutionResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync("Use the code interpreter to calculate the square root of 42. Return only the nearest integer value and no other text.", new() + { + Tools = [new HostedCodeInterpreterTool()], + }); + Assert.NotNull(response); + + ChatMessage message = Assert.Single(response.Messages); + + Assert.Equal("6", message.Text); + + // Validate CodeInterpreterToolCallContent + var toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().SingleOrDefault(); + Assert.NotNull(toolCallContent); + Assert.NotNull(toolCallContent.CallId); + Assert.NotEmpty(toolCallContent.CallId); + Assert.NotNull(toolCallContent.Inputs); + Assert.NotEmpty(toolCallContent.Inputs); + + var codeInput = toolCallContent.Inputs.OfType().FirstOrDefault(); + Assert.NotNull(codeInput); + Assert.Equal("text/x-python", codeInput.MediaType); + Assert.NotEmpty(codeInput.Data.ToArray()); + + // Validate CodeInterpreterToolResultContent + var toolResultContent = response.Messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); + Assert.NotNull(toolResultContent); + Assert.NotNull(toolResultContent.CallId); + Assert.NotEmpty(toolResultContent.CallId); + + if (toolResultContent.Outputs is not null) + { + Assert.NotEmpty(toolResultContent.Outputs); + if (toolResultContent.Outputs.OfType().FirstOrDefault() is { } resultOutput) + { + Assert.NotEmpty(resultOutput.Text); + } + } + } + + [ConditionalFact] + public async Task UseWebSearch_AnnotationsReflectResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync( + "Write a paragraph about .NET based on at least three recent news articles. Cite your sources.", + new() { Tools = [new HostedWebSearchTool()] }); + + ChatMessage m = Assert.Single(response.Messages); + TextContent tc = m.Contents.OfType().First(); + Assert.NotNull(tc.Annotations); + Assert.NotEmpty(tc.Annotations); + Assert.All(tc.Annotations, a => + { + CitationAnnotation ca = Assert.IsType(a); + var regions = Assert.IsType>(ca.AnnotatedRegions); + Assert.NotNull(regions); + Assert.Single(regions); + var region = Assert.IsType(regions[0]); + Assert.NotNull(region); + Assert.NotNull(region.StartIndex); + Assert.NotNull(region.EndIndex); + Assert.NotNull(ca.Url); + Assert.NotNull(ca.Title); + Assert.NotEmpty(ca.Title); + }); + } + + [ConditionalFact] + public async Task RemoteMCP_ListTools() + { + SkipIfNotEnabled(); + + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) { ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire }], + }; + + ChatResponse response = await CreateChatClient()!.GetResponseAsync("Which tools are available on the wiki_tools MCP server?", chatOptions); + + Assert.Contains("read_wiki_structure", response.Text); + Assert.Contains("read_wiki_contents", response.Text); + Assert.Contains("ask_question", response.Text); + } + + [ConditionalFact] + public async Task RemoteMCP_CallTool_ApprovalNeverRequired() + { + SkipIfNotEnabled(); + + await RunAsync(false, false); + await RunAsync(true, true); + + async Task RunAsync(bool streaming, bool requireSpecific) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = requireSpecific ? + HostedMcpServerToolApprovalMode.RequireSpecific(null, ["read_wiki_structure", "ask_question"]) : + HostedMcpServerToolApprovalMode.NeverRequire, + } + ], + }; + + using var client = CreateChatClient()!; + + const string Prompt = "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository"; + + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(Prompt, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(Prompt, chatOptions); + + Assert.NotNull(response); + Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType()); + + Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); + } + } + + [ConditionalFact] + public async Task RemoteMCP_CallTool_ApprovalRequired() + { + SkipIfNotEnabled(); + + await RunAsync(false, false, false); + await RunAsync(true, true, false); + await RunAsync(false, false, true); + await RunAsync(true, true, true); + + async Task RunAsync(bool streaming, bool requireSpecific, bool useConversationId) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = requireSpecific ? + HostedMcpServerToolApprovalMode.RequireSpecific(["read_wiki_structure", "ask_question"], null) : + HostedMcpServerToolApprovalMode.AlwaysRequire, + } + ], + }; + + using var client = CreateChatClient()!; + + // Initial request + List input = [new ChatMessage(ChatRole.User, "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository")]; + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + + // Handle approvals of up to two rounds of tool calls + int approvalsCount = 0; + for (int i = 0; i < 2; i++) + { + if (useConversationId) + { + chatOptions.ConversationId = response.ConversationId; + input.Clear(); + } + else + { + input.AddRange(response.Messages); + } + + var approvalResponse = new ChatMessage(ChatRole.Tool, + response.Messages + .SelectMany(m => m.Contents) + .OfType() + .Select(c => new McpServerToolApprovalResponseContent(c.ToolCall.CallId, true)) + .ToArray()); + if (approvalResponse.Contents.Count == 0) + { + break; + } + + approvalsCount += approvalResponse.Contents.Count; + input.Add(approvalResponse); + response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + } + + // Validate final response + Assert.Equal(2, approvalsCount); + Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); + } + } + + [ConditionalFact] + public async Task GetResponseAsync_BackgroundResponses() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + }; + + // Get initial response with continuation token + var response = await ChatClient.GetResponseAsync("What's the biggest animal?", chatOptions); + Assert.NotNull(response.ContinuationToken); + Assert.Empty(response.Messages); + + int attempts = 0; + + // Continue to poll until we get the final response + while (response.ContinuationToken is not null && ++attempts < 10) + { + chatOptions.ContinuationToken = response.ContinuationToken; + response = await ChatClient.GetResponseAsync([], chatOptions); + await Task.Delay(1000); + } + + Assert.Contains("whale", response.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task GetResponseAsync_BackgroundResponses_WithFunction() + { + SkipIfNotEnabled(); + + int callCount = 0; + + using var chatClient = new FunctionInvokingChatClient(ChatClient); + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + Tools = [AIFunctionFactory.Create(() => { callCount++; return "5:43"; }, new AIFunctionFactoryOptions { Name = "GetCurrentTime" })] + }; + + // Get initial response with continuation token + var response = await chatClient.GetResponseAsync("What time is it?", chatOptions); + Assert.NotNull(response.ContinuationToken); + Assert.Empty(response.Messages); + + int attempts = 0; + + // Poll until the result is received + while (response.ContinuationToken is not null && ++attempts < 10) + { + chatOptions.ContinuationToken = response.ContinuationToken; + + response = await chatClient.GetResponseAsync([], chatOptions); + await Task.Delay(1000); + } + + Assert.Contains("5:43", response.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, callCount); + } + + [ConditionalFact] + public async Task GetStreamingResponseAsync_BackgroundResponses() + { + SkipIfNotEnabled(); + + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + }; + + string responseText = ""; + + await foreach (var update in ChatClient.GetStreamingResponseAsync("What is the capital of France?", chatOptions)) + { + responseText += update; + } + + // Assert + Assert.Contains("Paris", responseText, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption() + { + SkipIfNotEnabled(); + + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + }; + + int updateNumber = 0; + string responseText = ""; + object? continuationToken = null; + + await foreach (var update in ChatClient.GetStreamingResponseAsync("What is the capital of France?", chatOptions)) + { + responseText += update; + + // Simulate an interruption after receiving 8 updates. + if (updateNumber++ == 8) + { + continuationToken = update.ContinuationToken; + break; + } + } + + Assert.DoesNotContain("Paris", responseText); + + // Resume streaming from the point of interruption captured by the continuation token. + chatOptions.ContinuationToken = continuationToken; + await foreach (var update in ChatClient.GetStreamingResponseAsync([], chatOptions)) + { + responseText += update; + } + + Assert.Contains("Paris", responseText, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task GetStreamingResponseAsync_BackgroundResponses_WithFunction() + { + SkipIfNotEnabled(); + + int callCount = 0; + + using var chatClient = new FunctionInvokingChatClient(ChatClient); + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + Tools = [AIFunctionFactory.Create(() => { callCount++; return "5:43"; }, new AIFunctionFactoryOptions { Name = "GetCurrentTime" })] + }; + + string responseText = ""; + + await foreach (var update in chatClient.GetStreamingResponseAsync("What time is it?", chatOptions)) + { + responseText += update; + } + + Assert.Contains("5:43", responseText); + Assert.Equal(1, callCount); + } + + [ConditionalFact] + public async Task RemoteMCP_Connector() + { + SkipIfNotEnabled(); + + if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string accessToken) + { + throw new SkipTestException( + "To run this test, set a value for RemoteMCP:ConnectorAccessToken. " + + "You can obtain one by following https://platform.openai.com/docs/guides/tools-connectors-mcp?quickstart-panels=connector#authorizing-a-connector."); + } + + await RunAsync(false, false); + await RunAsync(true, true); + + async Task RunAsync(bool streaming, bool approval) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("calendar", "connector_googlecalendar") + { + ApprovalMode = approval ? + HostedMcpServerToolApprovalMode.AlwaysRequire : + HostedMcpServerToolApprovalMode.NeverRequire, + AuthorizationToken = accessToken + } + ], + }; + + using var client = CreateChatClient()!; + + List input = [new ChatMessage(ChatRole.User, "What is on my calendar for today?")]; + + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + + if (approval) + { + input.AddRange(response.Messages); + var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("search_events", approvalRequest.ToolCall.ToolName); + input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)])); + + response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + } + + Assert.NotNull(response); + var toolCall = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("search_events", toolCall.ToolName); + + var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + var content = Assert.IsType(Assert.Single(toolResult.Output!)); + Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text); + } + } + + [ConditionalFact] + public async Task ToolCallResult_TextContent() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create((int a, int b) => new TextContent($"The sum is {a + b}"), "AddNumbers", "Adds two numbers together")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("What is 25 plus 17? Use the AddNumbers function.", chatOptions); + + Assert.NotNull(response); + + // The model should have called the function and received "The sum is 42" + Assert.Contains("42", response.Text); + } + + [ConditionalFact] + public async Task ToolCallResult_MultipleAIContents() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create((string city) => new List + { + new TextContent($"Weather in {city}: "), + new TextContent("Sunny, 72°F") + }, "GetWeather", "Gets the weather for a city")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("What's the weather in Seattle? Use GetWeather.", chatOptions); + + Assert.NotNull(response); + + // Verify the function was called and both parts were included + var messages = response.Messages.ToList(); + Assert.NotEmpty(messages); + + // Check that we got a response mentioning the weather data + Assert.Contains("72", response.Text); + } + + [ConditionalFact] + public async Task ToolCallResult_ImageDataContent() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => new DataContent(ImageDataUri.GetImageDataUri(), "image/png"), "GetDotnetLogo", "Returns the .NET logo image")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("Call GetDotnetLogo and tell me what you see in the image.", chatOptions); + + Assert.NotNull(response); + + // The model should describe seeing the .NET logo or purple/related colors + Assert.True( + response.Text.Contains("logo", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("purple", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("dot", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("net", StringComparison.OrdinalIgnoreCase), + $"Expected response to mention logo or colors, but got: {response.Text}"); + } + + [ConditionalFact] + public async Task ToolCallResult_PdfDataContent() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf") { Name = "document.pdf" }, "GetDocument", "Returns a PDF document")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("Call GetDocument and tell me what text is in the PDF.", chatOptions); + + Assert.NotNull(response); + + // The PDF contains "Hello World!" text + Assert.Contains("Hello World", response.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task ToolCallResult_MixedContentWithImage() + { + SkipIfNotEnabled(); + + var imageUri = ImageDataUri.GetImageDataUri(); + var imageBytes = Convert.FromBase64String(imageUri.ToString().Split(',')[1]); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => new List + { + new TextContent("Analysis result: "), + new DataContent(imageBytes, "image/png"), + new TextContent(" - Image provided above") + }, "AnalyzeImage", "Analyzes an image and returns results")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("Call AnalyzeImage and describe what you see.", chatOptions); + + Assert.NotNull(response); + + // Should mention the analysis and describe the image + Assert.True( + response.Text.Contains("analysis", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("image", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("logo", StringComparison.OrdinalIgnoreCase), + $"Expected response to mention analysis or image content, but got: {response.Text}"); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 8e4229937ee..1ee738bd6a0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -5,10 +5,14 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.ComponentModel; +using System.IO; using System.Linq; using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -27,17 +31,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -171,6 +171,186 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal(36, response.Usage.TotalTokenCount); } + [Fact] + public async Task BasicReasoningResponse_Streaming() + { + const string Input = """ + { + "input":[{ + "type":"message", + "role":"user", + "content":[{ + "type":"input_text", + "text":"Calculate the sum of the first 5 positive integers." + }] + }], + "reasoning": { + "summary": "detailed", + "effort": "low" + }, + "model": "o4-mini", + "stream": true + } + """; + + // Compressed down for testing purposes; real-world output would be larger. + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[]}} + + event: response.reasoning_summary_part.added + data: {"type":"response.reasoning_summary_part.added","sequence_number":3,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":"**Calcul","obfuscation":"sLkbFySM"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":5,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":"ating","obfuscation":"dkm1f6DKqUj"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":6,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" a","obfuscation":"X8ahc2lfCf9eA1"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":7,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" simple","obfuscation":"1rLVyIaNl"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":8,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" sum","obfuscation":"jCK7mgNR80Re"} + + event: response.reasoning_summary_text.done + data: {"type":"response.reasoning_summary_text.done","sequence_number":9,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"text":"**Calculating a simple sum**"} + + event: response.reasoning_summary_part.done + data: {"type":"response.reasoning_summary_part.done","sequence_number":10,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":"**Calculating a simple sum**"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":11,"output_index":0,"item":{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[{"type":"summary_text","text":"**Calculating a simple sum**"}]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":12,"output_index":1,"item":{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":13,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":14,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"japg2KaCkjNsp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":15,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" sum","logprobs":[],"obfuscation":"1BEqjKQ0KU41"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"GUqom1rsdZsnT"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":17,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"UmCms91yrTlg"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":18,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" first","logprobs":[],"obfuscation":"AyNbZpfTXo"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":19,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"tuyz4HkKODFQRtk"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":"5","logprobs":[],"obfuscation":"QAwyISolmjXfTlc"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" positive","logprobs":[],"obfuscation":"2Euge1H"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" integers","logprobs":[],"obfuscation":"ih0Znt8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":23,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"oQihR5Pw8jRz5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":24,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" 15","logprobs":[],"obfuscation":"7TdJ1FWlZF8lTd"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":25,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":".","logprobs":[],"obfuscation":"x2VAJKlWI8qjgYq"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":26,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"text":"The sum of the first 5 positive integers is 15.","logprobs":[]} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":27,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":28,"output_index":1,"item":{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}],"role":"assistant"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":29,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[{"type":"summary_text","text":"**Calculating a simple sum**"}]},{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":17,"input_tokens_details":{"cached_tokens":0},"output_tokens":122,"output_tokens_details":{"reasoning_tokens":64},"total_tokens":139},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "o4-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("Calculate the sum of the first 5 positive integers.", new() + { + RawRepresentationFactory = options => new ResponseCreationOptions + { + ReasoningOptions = new() + { + ReasoningEffortLevel = ResponseReasoningEffortLevel.Low, + ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Detailed + } + } + })) + { + updates.Add(update); + } + + Assert.Equal("The sum of the first 5 positive integers is 15.", string.Concat(updates.Select(u => u.Text))); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_756_752_811); + Assert.Equal(30, updates.Count); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704", updates[i].ResponseId); + Assert.Equal("resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704", updates[i].ConversationId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.Equal("o4-mini-2025-04-16", updates[i].ModelId); + Assert.Null(updates[i].AdditionalProperties); + + if (i is (>= 4 and <= 8)) + { + // Reasoning updates + Assert.Single(updates[i].Contents); + Assert.Null(updates[i].Role); + + var reasoning = Assert.IsType(updates[i].Contents.Single()); + Assert.NotNull(reasoning); + Assert.NotNull(reasoning.Text); + } + else if (i is (>= 14 and <= 25) or 29) + { + // Response Complete and Assistant message updates + Assert.Single(updates[i].Contents); + } + else + { + // Other updates + Assert.Empty(updates[i].Contents); + } + + Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Stop, updates[i].FinishReason); + } + + UsageContent usage = updates.SelectMany(u => u.Contents).OfType().Single(); + Assert.Equal(17, usage.Details.InputTokenCount); + Assert.Equal(122, usage.Details.OutputTokenCount); + Assert.Equal(139, usage.Details.TotalTokenCount); + } + [Fact] public async Task BasicRequestResponse_Streaming() { @@ -262,29 +442,4655 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal("Hello! How can I assist you today?", string.Concat(updates.Select(u => u.Text))); var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_741_892_091); - Assert.Equal(10, updates.Count); + Assert.Equal(17, updates.Count); + for (int i = 0; i < updates.Count; i++) { Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ConversationId); Assert.Equal(createdAt, updates[i].CreatedAt); Assert.Equal("gpt-4o-mini-2024-07-18", updates[i].ModelId); - Assert.Equal(ChatRole.Assistant, updates[i].Role); Assert.Null(updates[i].AdditionalProperties); - Assert.Equal(i == 10 ? 0 : 1, updates[i].Contents.Count); + Assert.Equal((i >= 4 && i <= 12) || i == 16 ? 1 : 0, updates[i].Contents.Count); Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Stop, updates[i].FinishReason); } + for (int i = 4; i < updates.Count; i++) + { + Assert.Equal(ChatRole.Assistant, updates[i].Role); + } + UsageContent usage = updates.SelectMany(u => u.Contents).OfType().Single(); Assert.Equal(26, usage.Details.InputTokenCount); Assert.Equal(10, usage.Details.OutputTokenCount); Assert.Equal(36, usage.Details.TotalTokenCount); } - private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => - new OpenAIClient( - new ApiKeyCredential("apikey"), - new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) - .GetOpenAIResponseClient(modelId) - .AsIChatClient(); + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + + [Fact] + public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() + { + const string Input = """ + { + "temperature": 0.5, + "top_p": 0.5, + "previous_response_id": "resp_42", + "model": "gpt-4o-mini", + "max_output_tokens": 10, + "text": { + "format": { + "type": "text" + } + }, + "tools": [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": [ + "personName" + ], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + }, + "additionalProperties": false + }, + "strict": null + }, + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": [ + "personName" + ], + "properties": { + "personName": { + "description": "The person whose age is being requested", + "type": "string" + } + }, + "additionalProperties": false + }, + "strict": null + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "parallel_tool_calls": true + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": 20, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": { + "input_tokens": 26, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 10, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 36 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, modelId: "gpt-4o-mini"); + AIFunction tool = AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person."); + + ChatOptions chatOptions = new() + { + RawRepresentationFactory = (c) => + { + ResponseCreationOptions openAIOptions = new() + { + MaxOutputTokenCount = 10, + PreviousResponseId = "resp_42", + TopP = 0.5f, + Temperature = 0.5f, + ParallelToolCallsEnabled = true, + ToolChoice = ResponseToolChoice.CreateAutoChoice(), + TextOptions = new ResponseTextOptions + { + TextFormat = ResponseTextFormat.CreateTextFormat() + }, + }; + openAIOptions.Tools.Add(tool.AsOpenAIResponseTool()); + return openAIOptions; + }, + ModelId = null, + MaxOutputTokens = 1, + ConversationId = "foo", + TopP = 0.125f, + Temperature = 0.125f, + AllowMultipleToolCalls = false, + Tools = [tool], + ToolMode = ChatToolMode.None, + ResponseFormat = ChatResponseFormat.Json + }; + + var response = await client.GetResponseAsync("hello", chatOptions); + Assert.NotNull(response); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task MultipleOutputItems_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": 20, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello!", + "annotations": [] + } + ] + }, + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a182e", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": " How can I assist you today?", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": { + "input_tokens": 26, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 10, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 36 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + }); + Assert.NotNull(response); + + Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ResponseId); + Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_741_891_428), response.CreatedAt); + Assert.Null(response.FinishReason); + + Assert.Equal(2, response.Messages.Count); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("Hello!", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + Assert.Equal(" How can I assist you today?", response.Messages[1].Text); + + Assert.NotNull(response.Usage); + Assert.Equal(26, response.Usage.InputTokenCount); + Assert.Equal(10, response.Usage.OutputTokenCount); + Assert.Equal(36, response.Usage.TotalTokenCount); + } + + [Theory] + [InlineData("user")] + [InlineData("tool")] + public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) + { + string input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ] + } + """; + + string output = """ + { + "id": "resp_04e29d5bdd80bd9f0068e6b01f786081a29148febb92892aee", + "object": "response", + "created_at": 1759948831, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", + "type": "mcp_approval_request", + "arguments": "{\"repoName\":\"dotnet/extensions\"}", + "name": "ask_question", + "server_label": "deepwiki" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "always", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 193, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 23, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 216 + }, + "user": null, + "metadata": {} + } + """; + + var chatOptions = new ChatOptions + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))] + }; + McpServerToolApprovalRequestContent approvalRequest; + + using (VerbatimHttpHandler handler = new(input, output)) + using (HttpClient httpClient = new(handler)) + using (IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini")) + { + var response = await client.GetResponseAsync( + "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", + chatOptions); + + approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + chatOptions.ConversationId = response.ConversationId; + } + + input = $$""" + { + "previous_response_id": "resp_04e29d5bdd80bd9f0068e6b01f786081a29148febb92892aee", + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "mcp_approval_response", + "approval_request_id": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", + "approve": true + } + ] + } + """; + + output = """ + { + "id": "resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", + "object": "response", + "created_at": 1759949340, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", + "type": "mcp_call", + "status": "completed", + "approval_request_id": "mcpr_06ee3b1962eeb8470068e6b192985c81a383a16059ecd8230e", + "arguments": "{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}", + "error": null, + "name": "ask_question", + "output": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` within the `dotnet/extensions` repository. This file provides an overview of the package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`. \n\n## Path to README.md\n\nThe specific path to the `README.md` file for the `Microsoft.Extensions.AI.Abstractions` project is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md`. This path is also referenced in the `AI Extensions Framework` wiki page as a relevant source file. \n\n## Notes\n\nThe `Packaging.targets` file in the `eng/MSBuild` directory indicates that `README.md` files are included in packages when `IsPackable` and `IsShipping` properties are true. This suggests that the `README.md` file located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` is intended to be part of the distributed NuGet package for `Microsoft.Extensions.AI.Abstractions`. \n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_315595bd-9b39-4f04-9fa3-42dc778fa9f3\n", + "server_label": "deepwiki" + }, + { + "id": "msg_06ee3b1962eeb8470068e6b226ab0081a39fccce9aa47aedbc", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": "resp_06ee3b1962eeb8470068e6b18e0db881a3bdfd255a60327cdc", + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "always", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 542, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 72, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 614 + }, + "user": null, + "metadata": {} + } + """; + + using (VerbatimHttpHandler handler = new(input, output)) + using (HttpClient httpClient = new(handler)) + using (IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini")) + { + var response = await client.GetResponseAsync( + new ChatMessage(new ChatRole(role), [approvalRequest.CreateResponse(true)]), chatOptions); + + Assert.NotNull(response); + + Assert.Equal("resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", response.ResponseId); + Assert.Equal("resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_759_949_340), response.CreatedAt); + Assert.Null(response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`.", response.Messages[0].Text); + + Assert.Equal(3, message.Contents.Count); + + var call = Assert.IsType(message.Contents[0]); + Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", call.CallId); + Assert.Equal("deepwiki", call.ServerName); + Assert.Equal("ask_question", call.ToolName); + Assert.NotNull(call.Arguments); + Assert.Equal(2, call.Arguments.Count); + Assert.Equal("dotnet/extensions", ((JsonElement)call.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)call.Arguments["question"]!).GetString()); + + var result = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", result.CallId); + Assert.NotNull(result.Output); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(result.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(542, response.Usage.InputTokenCount); + Assert.Equal(72, response.Usage.OutputTokenCount); + Assert.Equal(614, response.Usage.TotalTokenCount); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) + { + const string Input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "require_approval": "never" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_68be416397ec81918c48ef286530b8140384f747588fc3f5", + "object": "response", + "created_at": 1757299043, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcpl_68be4163aa80819185e792abdcde71670384f747588fc3f5", + "type": "mcp_list_tools", + "server_label": "deepwiki", + "tools": [ + { + "annotations": { + "read_only": false + }, + "description": "Get a list of documentation topics for a GitHub repository", + "input_schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string", + "description": "GitHub repository: owner/repo (e.g. \"facebook/react\")" + } + }, + "required": [ + "repoName" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "read_wiki_structure" + }, + { + "annotations": { + "read_only": false + }, + "description": "View documentation about a GitHub repository", + "input_schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string", + "description": "GitHub repository: owner/repo (e.g. \"facebook/react\")" + } + }, + "required": [ + "repoName" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "read_wiki_contents" + }, + { + "annotations": { + "read_only": false + }, + "description": "Ask any question about a GitHub repository", + "input_schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string", + "description": "GitHub repository: owner/repo (e.g. \"facebook/react\")" + }, + "question": { + "type": "string", + "description": "The question to ask about the repository" + } + }, + "required": [ + "repoName", + "question" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "ask_question" + } + ] + }, + { + "id": "mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", + "type": "mcp_call", + "approval_request_id": null, + "arguments": "{\"repoName\":\"dotnet/extensions\"}", + "error": null, + "name": "read_wiki_structure", + "output": "Available pages for dotnet/extensions:\n\n- 1 Overview\n- 2 Build System and CI/CD\n- 3 AI Extensions Framework\n - 3.1 Core Abstractions\n - 3.2 AI Function System\n - 3.3 Chat Completion\n - 3.4 Caching System\n - 3.5 Evaluation and Reporting\n- 4 HTTP Resilience and Diagnostics\n - 4.1 Standard Resilience\n - 4.2 Hedging Strategies\n- 5 Telemetry and Compliance\n- 6 Testing Infrastructure\n - 6.1 AI Service Integration Testing\n - 6.2 Time Provider Testing", + "server_label": "deepwiki" + }, + { + "id": "mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", + "type": "mcp_call", + "approval_request_id": null, + "arguments": "{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}", + "error": null, + "name": "ask_question", + "output": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` within the `dotnet/extensions` repository. This file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`. \n\n## Path to README.md\n\nThe specific path to the `README.md` file for the `Microsoft.Extensions.AI.Abstractions` project is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md`. This path is also referenced in the `AI Extensions Framework` wiki page as a relevant source file. \n\n## Notes\n\nThe `Packaging.targets` file in the `eng/MSBuild` directory indicates that `README.md` files are included in packages when `IsPackable` and `IsShipping` properties are true. This suggests that the `README.md` file located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` is intended to be part of the distributed NuGet package for `Microsoft.Extensions.AI.Abstractions`. \n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_315595bd-9b39-4f04-9fa3-42dc778fa9f3\n", + "server_label": "deepwiki" + }, + { + "id": "msg_68be416fb43c819194a1d4ace2643a7e0384f747588fc3f5", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file includes an overview, installation instructions, and usage examples related to the package." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "never", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 1329, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 123, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 1452 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + AITool mcpTool = rawTool ? + ResponseTool.CreateMcpTool("deepwiki", serverUri: new("https://mcp.deepwiki.com/mcp"), toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)).AsAITool() : + new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + }; + + ChatOptions chatOptions = new() + { + Tools = [mcpTool], + }; + + var response = await client.GetResponseAsync("Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions); + Assert.NotNull(response); + + Assert.Equal("resp_68be416397ec81918c48ef286530b8140384f747588fc3f5", response.ResponseId); + Assert.Equal("resp_68be416397ec81918c48ef286530b8140384f747588fc3f5", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_757_299_043), response.CreatedAt); + Assert.Null(response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file includes an overview, installation instructions, and usage examples related to the package.", response.Messages[0].Text); + + Assert.Equal(6, message.Contents.Count); + + var firstCall = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", firstCall.CallId); + Assert.Equal("deepwiki", firstCall.ServerName); + Assert.Equal("read_wiki_structure", firstCall.ToolName); + Assert.NotNull(firstCall.Arguments); + Assert.Single(firstCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)firstCall.Arguments["repoName"]!).GetString()); + + var firstResult = Assert.IsType(message.Contents[2]); + Assert.Equal("mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", firstResult.CallId); + Assert.NotNull(firstResult.Output); + Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(Assert.Single(firstResult.Output)).Text); + + var secondCall = Assert.IsType(message.Contents[3]); + Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondCall.CallId); + Assert.Equal("deepwiki", secondCall.ServerName); + Assert.Equal("ask_question", secondCall.ToolName); + Assert.NotNull(secondCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)secondCall.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)secondCall.Arguments["question"]!).GetString()); + + var secondResult = Assert.IsType(message.Contents[4]); + Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondResult.CallId); + Assert.NotNull(secondResult.Output); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(secondResult.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(1329, response.Usage.InputTokenCount); + Assert.Equal(123, response.Usage.OutputTokenCount); + Assert.Equal(1452, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task McpToolCall_ApprovalNotRequired_Streaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "require_approval": "never" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ], + "stream": true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54","object":"response","created_at":1757299965,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"mcp","allowed_tools":null,"headers":null,"require_approval":"never","server_description":null,"server_label":"deepwiki","server_url":"https://mcp.deepwiki.com/"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54","object":"response","created_at":1757299965,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"mcp","allowed_tools":null,"headers":null,"require_approval":"never","server_description":null,"server_label":"deepwiki","server_url":"https://mcp.deepwiki.com/"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54","type":"mcp_list_tools","server_label":"deepwiki","tools":[]}} + + event: response.mcp_list_tools.in_progress + data: {"type":"response.mcp_list_tools.in_progress","sequence_number":3,"output_index":0,"item_id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54"} + + event: response.mcp_list_tools.completed + data: {"type":"response.mcp_list_tools.completed","sequence_number":4,"output_index":0,"item_id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54","type":"mcp_list_tools","server_label":"deepwiki","tools":[{"annotations":{"read_only":false},"description":"Get a list of documentation topics for a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_structure"},{"annotations":{"read_only":false},"description":"View documentation about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_contents"},{"annotations":{"read_only":false},"description":"Ask any question about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"ask_question"}]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":6,"output_index":1,"item":{"id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"","error":null,"name":"read_wiki_structure","output":null,"server_label":"deepwiki"}} + + event: response.mcp_call.in_progress + data: {"type":"response.mcp_call.in_progress","sequence_number":7,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54"} + + event: response.mcp_call_arguments.delta + data: {"type":"response.mcp_call_arguments.delta","sequence_number":8,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","delta":"{\"repoName\":\"dotnet/extensions\"}","obfuscation":""} + + event: response.mcp_call_arguments.done + data: {"type":"response.mcp_call_arguments.done","sequence_number":9,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","arguments":"{\"repoName\":\"dotnet/extensions\"}"} + + event: response.mcp_call.completed + data: {"type":"response.mcp_call.completed","sequence_number":10,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":11,"output_index":1,"item":{"id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\"}","error":null,"name":"read_wiki_structure","output":"Available pages for dotnet/extensions:\n\n- 1 Overview\n- 2 Build System and CI/CD\n- 3 AI Extensions Framework\n - 3.1 Core Abstractions\n - 3.2 AI Function System\n - 3.3 Chat Completion\n - 3.4 Caching System\n - 3.5 Evaluation and Reporting\n- 4 HTTP Resilience and Diagnostics\n - 4.1 Standard Resilience\n - 4.2 Hedging Strategies\n- 5 Telemetry and Compliance\n- 6 Testing Infrastructure\n - 6.1 AI Service Integration Testing\n - 6.2 Time Provider Testing","server_label":"deepwiki"}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":12,"output_index":2,"item":{"id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"","error":null,"name":"ask_question","output":null,"server_label":"deepwiki"}} + + event: response.mcp_call.in_progress + data: {"type":"response.mcp_call.in_progress","sequence_number":13,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54"} + + event: response.mcp_call_arguments.delta + data: {"type":"response.mcp_call_arguments.delta","sequence_number":14,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","delta":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}","obfuscation":"IT"} + + event: response.mcp_call_arguments.done + data: {"type":"response.mcp_call_arguments.done","sequence_number":15,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","arguments":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}"} + + event: response.mcp_call.completed + data: {"type":"response.mcp_call.completed","sequence_number":16,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":17,"output_index":2,"item":{"id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}","error":null,"name":"ask_question","output":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` . This file provides an overview of the `Microsoft.Extensions.AI.Abstractions` library, including installation instructions and usage examples for its core components like `IChatClient` and `IEmbeddingGenerator` .\n\n## README.md Content Overview\nThe `README.md` file for `Microsoft.Extensions.AI.Abstractions` details the purpose of the library, which is to provide abstractions for generative AI components . It includes instructions on how to install the NuGet package `Microsoft.Extensions.AI.Abstractions` .\n\nThe document also provides usage examples for the `IChatClient` interface, which defines methods for interacting with AI services that offer \"chat\" capabilities . This includes examples for requesting both complete and streaming chat responses .\n\nFurthermore, the `README.md` explains the `IEmbeddingGenerator` interface, which is used for generating vector embeddings from input values . It demonstrates how to use `GenerateAsync` to create embeddings . The file also discusses how both `IChatClient` and `IEmbeddingGenerator` implementations can be layered to create pipelines of functionality, incorporating features like caching and telemetry .\n\nNotes:\nThe user's query specifically asked for the path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions`. The provided codebase context, particularly the wiki page for \"AI Extensions Framework\", directly lists this file as a relevant source file . The content of the `README.md` file itself further confirms its relevance to the `Microsoft.Extensions.AI.Abstractions` library.\n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_bb6bee43-3136-4b21-bc5d-02ca1611d857\n","server_label":"deepwiki"}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":18,"output_index":3,"item":{"id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":19,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"a5sNdjeWpJXIK"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" path","logprobs":[],"obfuscation":"2oWbALsHrtv"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"K8lRBCaiusvjP"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":23,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"LP7Xp4jDWA5w"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":24,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" `","logprobs":[],"obfuscation":"2rUNEj0h3wLlee"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":25,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"README","logprobs":[],"obfuscation":"PSbOrCj8y6"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":26,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".md","logprobs":[],"obfuscation":"Do0BCY4kJ6wQW"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":27,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`","logprobs":[],"obfuscation":"3fTPkjHu1Oq83DT"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":28,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" file","logprobs":[],"obfuscation":"CI9PXx3sH06"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":29,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" for","logprobs":[],"obfuscation":"fJuaoSPsMge8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":30,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" `","logprobs":[],"obfuscation":"O1h4Q0T72OM4e7"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":31,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"Microsoft","logprobs":[],"obfuscation":"E2YPgfE"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":32,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".Extensions","logprobs":[],"obfuscation":"vfVX8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":33,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"EwDmSMHqymBRl1"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":34,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"I","logprobs":[],"obfuscation":"QQfjze1z7QhvcJE"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":35,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"7fLbFXKbxOMkBi"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":36,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"bst","logprobs":[],"obfuscation":"3p1svK7Jd1N7C"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":37,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"ractions","logprobs":[],"obfuscation":"Cl2xCwTC"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":38,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`","logprobs":[],"obfuscation":"ObDOKE72QOlXSx9"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":39,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" in","logprobs":[],"obfuscation":"FJwPbDYgh4XjL"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":40,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"e8cV5qt7hEsz"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":41,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" `","logprobs":[],"obfuscation":"Hf8ZQDFLfImh3e"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":42,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"dot","logprobs":[],"obfuscation":"0lh2vLiYye2JI"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":43,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"net","logprobs":[],"obfuscation":"g5fzb2qtk4Piz"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":44,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/extensions","logprobs":[],"obfuscation":"egpos"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":45,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`","logprobs":[],"obfuscation":"gXw3bKveEVIKXux"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":46,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" repository","logprobs":[],"obfuscation":"rqhlC"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":47,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"YZq9zsRja0g2M"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":48,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":":\n\n","logprobs":[],"obfuscation":"mhDAmaHJUvLGl"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":49,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"``","logprobs":[],"obfuscation":"3XmO5YTsWjzHHf"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":50,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`\n","logprobs":[],"obfuscation":"4fmXZmdkPxNn8K"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":51,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"src","logprobs":[],"obfuscation":"ifGf4yLEg5pMZ"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":52,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/L","logprobs":[],"obfuscation":"C1k1toBElpgxyW"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":53,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"ibraries","logprobs":[],"obfuscation":"fdOTYTyp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":54,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/M","logprobs":[],"obfuscation":"DyscJIQYaPJugC"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":55,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"icrosoft","logprobs":[],"obfuscation":"PQxU7muP"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":56,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".Extensions","logprobs":[],"obfuscation":"RCJB8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":57,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"i92CWxnAkwS4C9"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":58,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"I","logprobs":[],"obfuscation":"qfH8wVJN74vCfBM"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":59,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"LcuBP89lZVCCH9"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":60,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"bst","logprobs":[],"obfuscation":"I8rKDbKN0zylv"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":61,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"ractions","logprobs":[],"obfuscation":"tOgiCPs5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":62,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/","logprobs":[],"obfuscation":"jgJjLruTbFJGDhU"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":63,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"README","logprobs":[],"obfuscation":"D5VSEFNde7"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":64,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".md","logprobs":[],"obfuscation":"7ZGJO5sZOTPBs"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":65,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"\n","logprobs":[],"obfuscation":"7Sv80haKTTwfEWj"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":66,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"``","logprobs":[],"obfuscation":"m1JSvZ8rrpJnH5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":67,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`\n\n","logprobs":[],"obfuscation":"U93PMKtCB5Pb5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":68,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"This","logprobs":[],"obfuscation":"f5veTGedo9nM"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":69,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" file","logprobs":[],"obfuscation":"oEBwvP5FnPK"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":70,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" provides","logprobs":[],"obfuscation":"IVNCYwr"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":71,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" an","logprobs":[],"obfuscation":"3x6WquURIJ3ld"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":72,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" overview","logprobs":[],"obfuscation":"VR9yeiD"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":73,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"z46dC1o2FC8Rs"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":74,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"YfZGabvmgyoI"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":75,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" library","logprobs":[],"obfuscation":"TamElgEp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":76,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":",","logprobs":[],"obfuscation":"VfVfqbnHAfsJyJn"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":77,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" installation","logprobs":[],"obfuscation":"CGR"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":78,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" instructions","logprobs":[],"obfuscation":"xst"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":79,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":",","logprobs":[],"obfuscation":"3u5wqRA2RXh2QP8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":80,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" and","logprobs":[],"obfuscation":"tD4WZmOhepzQ"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":81,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" usage","logprobs":[],"obfuscation":"SadOK826mZ"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":82,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" examples","logprobs":[],"obfuscation":"5VpLKav"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":83,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" for","logprobs":[],"obfuscation":"xPvtjDSUic9E"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":84,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" its","logprobs":[],"obfuscation":"6duK61DX14vx"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":85,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" core","logprobs":[],"obfuscation":"Cz8trPLsCWu"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":86,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" components","logprobs":[],"obfuscation":"Gexuy"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":87,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".","logprobs":[],"obfuscation":"HVeWkHoX1cc6hVh"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":88,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" If","logprobs":[],"obfuscation":"G1TOxxwvSEq4L"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":89,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" you","logprobs":[],"obfuscation":"xQlKeOixd1hv"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":90,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" have","logprobs":[],"obfuscation":"bX6P0qgFPnR"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":91,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" any","logprobs":[],"obfuscation":"KxH8EiMzXa1N"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":92,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" more","logprobs":[],"obfuscation":"kA0kxRPPqru"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":93,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" questions","logprobs":[],"obfuscation":"9HRCyD"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":94,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" about","logprobs":[],"obfuscation":"yYFZhtsSfc"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":95,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" it","logprobs":[],"obfuscation":"zpyEAwPWl8Ozh"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":96,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":",","logprobs":[],"obfuscation":"ivjn00lbmzDHiFU"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":97,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" feel","logprobs":[],"obfuscation":"O2edXDmkBqt"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":98,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" free","logprobs":[],"obfuscation":"MlpWh7p0P1F"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":99,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"uMNfozGkKe6xW"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":100,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" ask","logprobs":[],"obfuscation":"6rMOxwXhR8RY"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":101,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"!","logprobs":[],"obfuscation":"QPZMdhS0e5vYuRl"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":102,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!","logprobs":[]} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":103,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":104,"output_index":3,"item":{"id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!"}],"role":"assistant"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":105,"response":{"id":"resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54","object":"response","created_at":1757299965,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[{"id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54","type":"mcp_list_tools","server_label":"deepwiki","tools":[{"annotations":{"read_only":false},"description":"Get a list of documentation topics for a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_structure"},{"annotations":{"read_only":false},"description":"View documentation about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_contents"},{"annotations":{"read_only":false},"description":"Ask any question about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"ask_question"}]},{"id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\"}","error":null,"name":"read_wiki_structure","output":"Available pages for dotnet/extensions:\n\n- 1 Overview\n- 2 Build System and CI/CD\n- 3 AI Extensions Framework\n - 3.1 Core Abstractions\n - 3.2 AI Function System\n - 3.3 Chat Completion\n - 3.4 Caching System\n - 3.5 Evaluation and Reporting\n- 4 HTTP Resilience and Diagnostics\n - 4.1 Standard Resilience\n - 4.2 Hedging Strategies\n- 5 Telemetry and Compliance\n- 6 Testing Infrastructure\n - 6.1 AI Service Integration Testing\n - 6.2 Time Provider Testing","server_label":"deepwiki"},{"id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}","error":null,"name":"ask_question","output":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` . This file provides an overview of the `Microsoft.Extensions.AI.Abstractions` library, including installation instructions and usage examples for its core components like `IChatClient` and `IEmbeddingGenerator` .\n\n## README.md Content Overview\nThe `README.md` file for `Microsoft.Extensions.AI.Abstractions` details the purpose of the library, which is to provide abstractions for generative AI components . It includes instructions on how to install the NuGet package `Microsoft.Extensions.AI.Abstractions` .\n\nThe document also provides usage examples for the `IChatClient` interface, which defines methods for interacting with AI services that offer \"chat\" capabilities . This includes examples for requesting both complete and streaming chat responses .\n\nFurthermore, the `README.md` explains the `IEmbeddingGenerator` interface, which is used for generating vector embeddings from input values . It demonstrates how to use `GenerateAsync` to create embeddings . The file also discusses how both `IChatClient` and `IEmbeddingGenerator` implementations can be layered to create pipelines of functionality, incorporating features like caching and telemetry .\n\nNotes:\nThe user's query specifically asked for the path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions`. The provided codebase context, particularly the wiki page for \"AI Extensions Framework\", directly lists this file as a relevant source file . The content of the `README.md` file itself further confirms its relevance to the `Microsoft.Extensions.AI.Abstractions` library.\n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_bb6bee43-3136-4b21-bc5d-02ca1611d857\n","server_label":"deepwiki"},{"id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"mcp","allowed_tools":null,"headers":null,"require_approval":"never","server_description":null,"server_label":"deepwiki","server_url":"https://mcp.deepwiki.com/"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":1420,"input_tokens_details":{"cached_tokens":0},"output_tokens":149,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1569},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + } + ], + }; + + var response = await client.GetStreamingResponseAsync("Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions) + .ToChatResponseAsync(); + Assert.NotNull(response); + + Assert.Equal("resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54", response.ResponseId); + Assert.Equal("resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_757_299_965), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.StartsWith("The path to the `README.md` file", response.Messages[0].Text); + + Assert.Equal(6, message.Contents.Count); + + var firstCall = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54", firstCall.CallId); + Assert.Equal("deepwiki", firstCall.ServerName); + Assert.Equal("read_wiki_structure", firstCall.ToolName); + Assert.NotNull(firstCall.Arguments); + Assert.Single(firstCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)firstCall.Arguments["repoName"]!).GetString()); + + var firstResult = Assert.IsType(message.Contents[2]); + Assert.Equal("mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54", firstResult.CallId); + Assert.NotNull(firstResult.Output); + Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(Assert.Single(firstResult.Output)).Text); + + var secondCall = Assert.IsType(message.Contents[3]); + Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondCall.CallId); + Assert.Equal("deepwiki", secondCall.ServerName); + Assert.Equal("ask_question", secondCall.ToolName); + Assert.NotNull(secondCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)secondCall.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)secondCall.Arguments["question"]!).GetString()); + + var secondResult = Assert.IsType(message.Contents[4]); + Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondResult.CallId); + Assert.NotNull(secondResult.Output); + Assert.StartsWith("The path to the `README.md` file", Assert.IsType(Assert.Single(secondResult.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(1420, response.Usage.InputTokenCount); + Assert.Equal(149, response.Usage.OutputTokenCount); + Assert.Equal(1569, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task GetResponseAsync_BackgroundResponses_FirstCall() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "background":true, + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", + "object": "response", + "created_at": 1758712522, + "status": "queued", + "background": true, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": null, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + AllowBackgroundResponses = true, + }); + Assert.NotNull(response); + + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", response.ResponseId); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", response.ConversationId); + Assert.Empty(response.Messages); + + Assert.NotNull(response.ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(response.ContinuationToken); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", responsesContinuationToken.ResponseId); + Assert.Null(responsesContinuationToken.SequenceNumber); + } + + [Theory] + [InlineData(ResponseStatus.Queued)] + [InlineData(ResponseStatus.InProgress)] + [InlineData(ResponseStatus.Completed)] + [InlineData(ResponseStatus.Cancelled)] + [InlineData(ResponseStatus.Failed)] + [InlineData(ResponseStatus.Incomplete)] + public async Task GetResponseAsync_BackgroundResponses_PollingCall(ResponseStatus expectedStatus) + { + var expectedInput = new HttpHandlerExpectedInput + { + Uri = new Uri("https://api.openai.com/v1/responses/resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8"), + Method = HttpMethod.Get, + }; + + string output = $$"""" + { + "id": "resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", + "object": "response", + "created_at": 1758712522, + "status": "{{ResponseStatusToRequestValue(expectedStatus)}}", + "background": true, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "model": "gpt-4o-mini-2024-07-18", + "output": {{(expectedStatus is (ResponseStatus.Queued or ResponseStatus.InProgress) + ? "[]" + : """ + [{ + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "The background response result.", + "annotations": [] + } + ] + }] + """ + )}}, + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": null, + "user": null, + "metadata": {} + } + """"; + + using VerbatimHttpHandler handler = new(expectedInput, output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var continuationToken = new TestOpenAIResponsesContinuationToken("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8"); + + var response = await client.GetResponseAsync([], new() + { + ContinuationToken = continuationToken, + AllowBackgroundResponses = true, + }); + Assert.NotNull(response); + + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", response.ResponseId); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", response.ConversationId); + + switch (expectedStatus) + { + case ResponseStatus.Queued: + case ResponseStatus.InProgress: + { + Assert.NotNull(response.ContinuationToken); + + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(response.ContinuationToken); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", responsesContinuationToken.ResponseId); + Assert.Null(responsesContinuationToken.SequenceNumber); + + Assert.Empty(response.Messages); + break; + } + + case ResponseStatus.Completed: + case ResponseStatus.Cancelled: + case ResponseStatus.Failed: + case ResponseStatus.Incomplete: + { + Assert.Null(response.ContinuationToken); + + Assert.Equal("The background response result.", response.Text); + Assert.Single(response.Messages.Single().Contents); + Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); + break; + } + + default: + throw new ArgumentOutOfRangeException(nameof(expectedStatus), expectedStatus, null); + } + } + + [Fact] + public async Task GetResponseAsync_BackgroundResponses_PollingCall_WithMessages() + { + using VerbatimHttpHandler handler = new(string.Empty, string.Empty); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var options = new ChatOptions + { + ContinuationToken = new TestOpenAIResponsesContinuationToken("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8"), + AllowBackgroundResponses = true, + }; + + // A try to update a background response with new messages should fail. + await Assert.ThrowsAsync(async () => + { + await client.GetResponseAsync("Please book hotel as well", options); + }); + } + + [Fact] + public async Task GetStreamingResponseAsync_BackgroundResponses() + { + const string Input = """ + { + "model": "gpt-4o-2024-08-06", + "background": true, + "input":[{ + "type":"message", + "role":"user", + "content":[{ + "type":"input_text", + "text":"hello" + }] + }], + + "stream": true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","object":"response","created_at":1758724519,"status":"queued","background":true,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.queued + data: {"type":"response.queued","sequence_number":1,"response":{"id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","object":"response","created_at":1758724519,"status":"queued","background":true,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":2,"response":{"truncation":"disabled","id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","tool_choice":"auto","temperature":1.0,"top_p":1.0,"status":"in_progress","top_logprobs":0,"usage":null,"object":"response","created_at":1758724519,"prompt_cache_key":null,"text":{"format":{"type":"text"},"verbosity":"medium"},"incomplete_details":null,"model":"gpt-4o-2024-08-06","previous_response_id":null,"safety_identifier":null,"metadata":{},"store":true,"output":[],"parallel_tool_calls":true,"error":null,"background":true,"instructions":null,"service_tier":"auto","max_tool_calls":null,"max_output_tokens":null,"tools":[],"user":null,"reasoning":{"effort":null,"summary":null}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":3,"item":{"id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content":[],"role":"assistant","status":"in_progress","type":"message"},"output_index":0} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":4,"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"part":{"text":"","logprobs":[],"type":"output_text","annotations":[]},"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":5,"delta":"Hello","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":6,"delta":"!","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":7,"delta":" How","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":8,"delta":" can","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":9,"delta":" I","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":10,"delta":" assist","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":11,"delta":" you","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":12,"delta":" today","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":13,"delta":"?","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":14,"text":"Hello! How can I assist you today?","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":15,"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"part":{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]},"output_index":0} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":16,"item":{"id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"},"output_index":0} + + event: response.completed + data: {"type":"response.completed","sequence_number":17,"response":{"truncation":"disabled","id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","tool_choice":"auto","temperature":1.0,"top_p":1.0,"status":"completed","top_logprobs":0,"usage":{"total_tokens":18,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0},"output_tokens":10,"input_tokens":8},"object":"response","created_at":1758724519,"prompt_cache_key":null,"text":{"format":{"type":"text"},"verbosity":"medium"},"incomplete_details":null,"model":"gpt-4o-2024-08-06","previous_response_id":null,"safety_identifier":null,"metadata":{},"store":true,"output":[{"id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"error":null,"background":true,"instructions":null,"service_tier":"default","max_tool_calls":null,"max_output_tokens":null,"tools":[],"user":null,"reasoning":{"effort":null,"summary":null}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + AllowBackgroundResponses = true, + })) + { + updates.Add(update); + } + + Assert.Equal("Hello! How can I assist you today?", string.Concat(updates.Select(u => u.Text))); + Assert.Equal(18, updates.Count); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_758_724_519); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559", updates[i].ResponseId); + Assert.Equal("resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559", updates[i].ConversationId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.Equal("gpt-4o-2024-08-06", updates[i].ModelId); + Assert.Null(updates[i].AdditionalProperties); + + if (i < updates.Count - 1) + { + Assert.NotNull(updates[i].ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(updates[i].ContinuationToken!); + Assert.Equal("resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559", responsesContinuationToken.ResponseId); + Assert.Equal(i, responsesContinuationToken.SequenceNumber); + Assert.Null(updates[i].FinishReason); + } + else + { + Assert.Null(updates[i].ContinuationToken); + Assert.Equal(ChatFinishReason.Stop, updates[i].FinishReason); + } + + Assert.Equal((i >= 5 && i <= 13) || i == 17 ? 1 : 0, updates[i].Contents.Count); + } + } + + [Fact] + public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption() + { + var expectedInput = new HttpHandlerExpectedInput + { + Uri = new Uri("https://api.openai.com/v1/responses/resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b?stream=true&starting_after=9"), + Method = HttpMethod.Get, + }; + + const string Output = """ + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":10,"delta":" assist","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":11,"delta":" you","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":12,"delta":" today","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":13,"delta":"?","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":14,"text":"Hello! How can I assist you today?","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":15,"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"part":{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]},"output_index":0} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":16,"item":{"id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"},"output_index":0} + + event: response.completed + data: {"type":"response.completed","sequence_number":17,"response":{"truncation":"disabled","id":"resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b","tool_choice":"auto","temperature":1.0,"top_p":1.0,"status":"completed","top_logprobs":0,"usage":{"total_tokens":18,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0},"output_tokens":10,"input_tokens":8},"object":"response","created_at":1758727622,"prompt_cache_key":null,"text":{"format":{"type":"text"},"verbosity":"medium"},"incomplete_details":null,"model":"gpt-4o-2024-08-06","previous_response_id":null,"safety_identifier":null,"metadata":{},"store":true,"output":[{"id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"error":null,"background":true,"instructions":null,"service_tier":"default","max_tool_calls":null,"max_output_tokens":null,"tools":[],"user":null,"reasoning":{"effort":null,"summary":null}}} + + + """; + + using VerbatimHttpHandler handler = new(expectedInput, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + // Emulating resumption of the stream after receiving the first 9 updates that provided the text "Hello! How can I" + var continuationToken = new TestOpenAIResponsesContinuationToken("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b") + { + SequenceNumber = 9 + }; + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken, + }; + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync([], chatOptions)) + { + updates.Add(update); + } + + // Receiving the remaining updates to complete the response "Hello! How can I assist you today?" + Assert.Equal(" assist you today?", string.Concat(updates.Select(u => u.Text))); + Assert.Equal(8, updates.Count); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_758_727_622); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", updates[i].ResponseId); + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", updates[i].ConversationId); + + var sequenceNumber = i + 10; + + if (sequenceNumber is (>= 10 and <= 13)) + { + // Text deltas + Assert.NotNull(updates[i].ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(updates[i].ContinuationToken!); + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", responsesContinuationToken.ResponseId); + Assert.Equal(sequenceNumber, responsesContinuationToken.SequenceNumber); + + Assert.Single(updates[i].Contents); + } + else if (sequenceNumber is (>= 14 and <= 16)) + { + // Response Complete and Assistant message updates + Assert.NotNull(updates[i].ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(updates[i].ContinuationToken!); + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", responsesContinuationToken.ResponseId); + Assert.Equal(sequenceNumber, responsesContinuationToken.SequenceNumber); + + Assert.Empty(updates[i].Contents); + } + else + { + // The last update with the response completion + Assert.Null(updates[i].ContinuationToken); + Assert.Single(updates[i].Contents); + } + } + } + + [Fact] + public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption_WithMessages() + { + using VerbatimHttpHandler handler = new(string.Empty, string.Empty); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + // Emulating resumption of the stream after receiving the first 9 updates that provided the text "Hello! How can I" + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + ContinuationToken = new TestOpenAIResponsesContinuationToken("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b") + { + SequenceNumber = 9 + } + }; + + await Assert.ThrowsAsync(async () => + { + await foreach (var update in client.GetStreamingResponseAsync("Please book a hotel for me", chatOptions)) +#pragma warning disable S108 // Nested blocks of code should not be left empty + { + } +#pragma warning restore S108 // Nested blocks of code should not be left empty + }); + } + + [Fact] + public async Task CodeInterpreterTool_NonStreaming() + { + const string Input = """ + { + "model":"gpt-4o-2024-08-06", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 5"}] + }], + "tool_choice":"auto", + "tools":[{ + "type":"code_interpreter", + "container":{"type":"auto"} + }] + } + """; + + const string Output = """ + { + "id": "resp_0e599e83cc6642210068fb7475165481a08efc750483c7048f", + "object": "response", + "created_at": 1761309813, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "ci_0e599e83cc6642210068fb7477fb9881a0811e8b0dc054b2fa", + "type": "code_interpreter_call", + "status": "completed", + "code": "# Calculating the sum of numbers from 1 to 5\nresult = sum(range(1, 6))\nresult", + "container_id": "cntr_68fb7476c384819186524b78cdc3180000a9a0fdd06b3cd4", + "outputs": null + }, + { + "id": "msg_0e599e83cc6642210068fb747e118081a08c3ed46daa9d9dcb", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "15" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "code_interpreter", + "container": { + "type": "auto" + } + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 225, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 34, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 259 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + var response = await client.GetResponseAsync("Calculate the sum of numbers from 1 to 5", new() + { + Tools = [new HostedCodeInterpreterTool()], + }); + + Assert.NotNull(response); + Assert.Single(response.Messages); + + var message = response.Messages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal(3, message.Contents.Count); + + var codeCall = Assert.IsType(message.Contents[0]); + Assert.NotNull(codeCall.Inputs); + var codeInput = Assert.IsType(Assert.Single(codeCall.Inputs)); + Assert.Equal("text/x-python", codeInput.MediaType); + + var codeResult = Assert.IsType(message.Contents[1]); + Assert.Equal(codeCall.CallId, codeResult.CallId); + + var textContent = Assert.IsType(message.Contents[2]); + Assert.Equal("15", textContent.Text); + } + + [Fact] + public async Task CodeInterpreterTool_Streaming() + { + const string Input = """ + { + "model":"gpt-4o-2024-08-06", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 10 using Python"}] + }], + "tool_choice":"auto", + "tools":[{ + "type":"code_interpreter", + "container":{"type":"auto"} + }], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_05d8f42f04f94cb80068fc3b7e07bc819eaf0d6e2c1923e564","object":"response","created_at":1761360766,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"code_interpreter","container":{"type":"auto"}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_05d8f42f04f94cb80068fc3b7e07bc819eaf0d6e2c1923e564","object":"response","created_at":1761360766,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"code_interpreter","container":{"type":"auto"}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","type":"code_interpreter_call","status":"in_progress","code":"","container_id":"cntr_68fc3b80043c8191990a5837d7617af704511ed77cec9447","outputs":null}} + + event: response.code_interpreter_call.in_progress + data: {"type":"response.code_interpreter_call.in_progress","sequence_number":3,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":4,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"#","obfuscation":"sl1L6kjYGbL3W7b"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":5,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" Calculate","obfuscation":"nXS0Oz"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":6,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" the","obfuscation":"BeywG4wkYSPO"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":7,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" sum","obfuscation":"lQQwYY1jVUku"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":8,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" of","obfuscation":"B7ZYHyd1bTIIr"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":9,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" numbers","obfuscation":"c9P1UFe4"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":10,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" from","obfuscation":"jARdbqvpfdt"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":11,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" ","obfuscation":"wciF7LWnjGSdWPb"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":12,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"1","obfuscation":"KLuWFhT8xPOyTNH"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":13,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" to","obfuscation":"5QCNr2nNt72Hg"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":14,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" ","obfuscation":"F3vctEo2cPUvnhe"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":15,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"10","obfuscation":"JBwcgWLYbSTskz"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":16,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"\n","obfuscation":"AgU5f5WddGwHDJg"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":17,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"sum","obfuscation":"Vey8vqQPTbewO"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":18,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_of","obfuscation":"Lyrmc5oOwdmsp"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":19,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_numbers","obfuscation":"YxvseUG4"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":20,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" =","obfuscation":"yoo1CBUhRbgI36"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":21,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" sum","obfuscation":"pKTBmkNEE4SA"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":22,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"(range","obfuscation":"BixU5bs5ms"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":23,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"(","obfuscation":"nsarVNP46dpVYMb"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":24,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"1","obfuscation":"qkth7DCPzS6mfEd"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":25,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":",","obfuscation":"J5uAitISxtjRSQA"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":26,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" ","obfuscation":"thr4ylmBRbAk0PY"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":27,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"11","obfuscation":"FWxcmwOFHJKEPJ"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":28,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"))\n","obfuscation":"ifI2JoREexe3t"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":29,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"sum","obfuscation":"VI7RlYoKWGMKP"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":30,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_of","obfuscation":"lkv27YflY8GLq"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":31,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_numbers","obfuscation":"xAQFrVav"} + + event: response.code_interpreter_call_code.done + data: {"type":"response.code_interpreter_call_code.done","sequence_number":32,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","code":"# Calculate the sum of numbers from 1 to 10\nsum_of_numbers = sum(range(1, 11))\nsum_of_numbers"} + + event: response.code_interpreter_call.interpreting + data: {"type":"response.code_interpreter_call.interpreting","sequence_number":33,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3"} + + event: response.code_interpreter_call.completed + data: {"type":"response.code_interpreter_call.completed","sequence_number":34,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":35,"output_index":0,"item":{"id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","type":"code_interpreter_call","status":"completed","code":"# Calculate the sum of numbers from 1 to 10\nsum_of_numbers = sum(range(1, 11))\nsum_of_numbers","container_id":"cntr_68fc3b80043c8191990a5837d7617af704511ed77cec9447","outputs":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":36,"output_index":1,"item":{"id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":37,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":38,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"r7iIr1QJ50aER"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":39,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" sum","logprobs":[],"obfuscation":"AQlXzWBYj2nu"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":40,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"PpVAep2w6YBTd"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":41,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" numbers","logprobs":[],"obfuscation":"q2oosiA3"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":42,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" from","logprobs":[],"obfuscation":"BBLhSYyDDUG"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":43,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"itOENAMFwzo6ESp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":44,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"1","logprobs":[],"obfuscation":"g93CJ2MyMSbq26V"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":45,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"WyzXmwaUVATTs"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":46,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"DBgQSKk2myfDWpq"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":47,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"10","logprobs":[],"obfuscation":"RA4RYQSLNug4pg"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":48,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"CgEfKJVj1DJtz"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":49,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"TVg4ccd4ZMwEiru"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":50,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"55","logprobs":[],"obfuscation":"CVN92VujTbUiZ0"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":51,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":".","logprobs":[],"obfuscation":"7YegRUzag3K6fdV"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":52,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"text":"The sum of numbers from 1 to 10 is 55.","logprobs":[]} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":53,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of numbers from 1 to 10 is 55."}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":54,"output_index":1,"item":{"id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of numbers from 1 to 10 is 55."}],"role":"assistant"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":55,"response":{"id":"resp_05d8f42f04f94cb80068fc3b7e07bc819eaf0d6e2c1923e564","object":"response","created_at":1761360766,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","type":"code_interpreter_call","status":"completed","code":"# Calculate the sum of numbers from 1 to 10\nsum_of_numbers = sum(range(1, 11))\nsum_of_numbers","container_id":"cntr_68fc3b80043c8191990a5837d7617af704511ed77cec9447","outputs":null},{"id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of numbers from 1 to 10 is 55."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"code_interpreter","container":{"type":"auto"}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":219,"input_tokens_details":{"cached_tokens":0},"output_tokens":50,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":269},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + var response = await client.GetStreamingResponseAsync("Calculate the sum of numbers from 1 to 10 using Python", new() + { + Tools = [new HostedCodeInterpreterTool()], + }).ToChatResponseAsync(); + + Assert.NotNull(response); + Assert.Single(response.Messages); + + var message = response.Messages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal(3, message.Contents.Count); + + var codeCall = Assert.IsType(message.Contents[0]); + Assert.NotNull(codeCall.Inputs); + var codeInput = Assert.IsType(Assert.Single(codeCall.Inputs)); + Assert.Equal("text/x-python", codeInput.MediaType); + Assert.Contains("sum_of_numbers", Encoding.UTF8.GetString(codeInput.Data.ToArray())); + + var codeResult = Assert.IsType(message.Contents[1]); + Assert.Equal(codeCall.CallId, codeResult.CallId); + + var textContent = Assert.IsType(message.Contents[2]); + Assert.Equal("The sum of numbers from 1 to 10 is 55.", textContent.Text); + + Assert.NotNull(response.Usage); + Assert.Equal(219, response.Usage.InputTokenCount); + Assert.Equal(50, response.Usage.OutputTokenCount); + Assert.Equal(269, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => client.GetResponseAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + [Fact] + public async Task ConversationId_AsResponseId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_12345", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "resp_12345", + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_AsConversationId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "conversation":"conv_12345", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_12345", + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("conv_12345", response.ConversationId); + } + + [Fact] + public async Task ConversationId_WhenStoreDisabled_ReturnsNull_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":false, + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "store": false, + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = false + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Null(response.ConversationId); + } + + [Fact] + public async Task ConversationId_ChatOptionsOverridesRawRepresentationResponseId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_override", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "resp_override", + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + PreviousResponseId = null + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_RawRepresentationPreviousResponseIdTakesPrecedence_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_fromraw", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_ignored", + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + PreviousResponseId = "resp_fromraw" + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":true, + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "store": true, + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = true + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":true, + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = true + } + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_WhenStoreDisabled_ReturnsNull_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":false, + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = false + } + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Null(updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_AsConversationId_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "conversation":"conv_12345", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_12345", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Equal("conv_12345", updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_AsResponseId_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_12345", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "resp_12345", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_RawRepresentationConversationIdTakesPrecedence_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "conversation":"conv_12345", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var rcoJsonModel = (IJsonModel)new ResponseCreationOptions(); + BinaryData rcoJsonBinaryData = rcoJsonModel.Write(ModelReaderWriterOptions.Json); + JsonObject rcoJsonObject = Assert.IsType(JsonNode.Parse(rcoJsonBinaryData.ToMemory().Span)); + Assert.Null(rcoJsonObject["conversation"]); + rcoJsonObject["conversation"] = "conv_12345"; + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_ignored", + RawRepresentationFactory = (c) => rcoJsonModel.Create( + new BinaryData(JsonSerializer.SerializeToUtf8Bytes(rcoJsonObject)), + ModelReaderWriterOptions.Json) + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("conv_12345", response.ConversationId); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ModelId = "gpt-4o", + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("gpt-4o-2024-08-06", response.ModelId); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ModelId = "gpt-4o", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + Assert.All(updates, u => Assert.Equal("gpt-4o-2024-08-06", u.ModelId)); + } + + [Fact] + public async Task ToolCallResult_SingleTextContent_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_123", + "output":[{"type":"input_text","text":"Result text"}] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_001", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Done","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_123", new TextContent("Result text"))]) + ]); + + Assert.NotNull(response); + Assert.Equal("Done", response.Text); + } + + [Fact] + public async Task ToolCallResult_MultipleTextContents_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_456", + "output":[ + {"type":"input_text","text":"First part. "}, + {"type":"input_text","text":"Second part."} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_002", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_002", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_456", new List + { + new TextContent("First part. "), + new TextContent("Second part.") + }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_DataContent_SerializesAsInputImage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_789", + "output":[ + {"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_003", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_003", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Image processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var imageData = Convert.FromBase64String("iVBORw0KGgo="); + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_789", new DataContent(imageData, "image/png")) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Image processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_UriContent_SerializesAsInputImage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_uri", + "output":[ + {"type":"input_image","image_url":"https://example.com/image.png"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_004", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_004", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"URI processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_uri", new UriContent(new Uri("https://example.com/image.png"), "image/png")) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("URI processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_HostedFileContent_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_file", + "output":[ + {"type":"input_image","file_id":"file-abc123","filename":"result.png"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_005", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_005", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"File processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_file", new HostedFileContent("file-abc123") { MediaType = "image/png", Name = "result.png" }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("File processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_MixedContent_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_mixed", + "output":[ + {"type":"input_text","text":"Text result: "}, + {"type":"input_image","image_url":"https://example.com/result.png"}, + {"type":"input_text","text":" - Image uploaded"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_006", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_006", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Mixed content processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_mixed", new List + { + new TextContent("Text result: "), + new UriContent(new Uri("https://example.com/result.png"), "image/png"), + new TextContent(" - Image uploaded") + }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Mixed content processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_DataContentPDF_SerializesAsInputFile() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_pdf", + "output":[ + {"type":"input_file","file_data":"data:application/pdf;base64,cGRmZGF0YQ==","filename":"report.pdf"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_007", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_007", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"PDF processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var pdfData = Encoding.UTF8.GetBytes("pdfdata"); + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_pdf", new DataContent(pdfData, "application/pdf") { Name = "report.pdf" }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("PDF processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_ObjectSerialization_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_obj", + "output":"{\"name\":\"John\",\"age\":30}" + } + ] + } + """; + + const string Output = """ + { + "id":"resp_obj", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_obj", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Object processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_obj", new { name = "John", age = 30 }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Object processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_StringFallback_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_string", + "output":"Simple string result" + } + ] + } + """; + + const string Output = """ + { + "id":"resp_008", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_008", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"String processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_string", "Simple string result") + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("String processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_UriContentNonImage_SerializesAsInputFile() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_file_uri", + "output":[ + {"type":"input_file","file_url":"https://example.com/document.pdf"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_009", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_009", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"File URI processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_file_uri", new UriContent(new Uri("https://example.com/document.pdf"), "application/pdf")) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("File URI processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_HostedFileContentNonImage_SerializesAsInputFile() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_hosted_file", + "output":[ + {"type":"input_file","file_id":"file-xyz789","filename":"document.txt"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_010", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_010", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Hosted file processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_hosted_file", new HostedFileContent("file-xyz789") { MediaType = "text/plain", Name = "document.txt" }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Hosted file processed", response.Text); + } + + [Fact] + public async Task ResponseWithEndUserId_IncludesInAdditionalProperties() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}], + "user":"user_123" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("user_123", response.AdditionalProperties["EndUserId"]); + } + + [Fact] + public async Task ResponseWithError_IncludesInAdditionalPropertiesAndMessage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"failed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Processing","annotations":[]}]}], + "error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded"} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response.AdditionalProperties); + Assert.NotNull(response.AdditionalProperties["Error"]); + + var lastMessage = response.Messages.Last(); + var errorContent = lastMessage.Contents.OfType().FirstOrDefault(); + Assert.NotNull(errorContent); + Assert.Equal("Rate limit exceeded", errorContent.Message); + Assert.Equal("rate_limit_exceeded", errorContent.ErrorCode); + } + + [Fact] + public async Task ResponseWithUsageDetails_ParsesTokenCounts() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}], + "usage":{ + "input_tokens":50, + "input_tokens_details":{"cached_tokens":10}, + "output_tokens":25, + "output_tokens_details":{"reasoning_tokens":5}, + "total_tokens":75 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response.Usage); + Assert.Equal(50, response.Usage.InputTokenCount); + Assert.Equal(25, response.Usage.OutputTokenCount); + Assert.Equal(75, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalCounts); + Assert.Equal(10, response.Usage.AdditionalCounts["InputTokenDetails.CachedTokenCount"]); + Assert.Equal(5, response.Usage.AdditionalCounts["OutputTokenDetails.ReasoningTokenCount"]); + } + + [Fact] + public async Task UserMessageWithVariousContentTypes_ConvertsCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[ + {"type":"input_text","text":"Check this image: "}, + {"type":"input_image","image_url":"https://example.com/image.png"}, + {"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="}, + {"type":"input_file","file_data":"data:application/pdf;base64,cGRmZGF0YQ==","filename":"doc.pdf"}, + {"type":"input_file","file_id":"file-123"}, + {"type":"refusal","refusal":"I cannot process this"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var imageData = Convert.FromBase64String("iVBORw0KGgo="); + var pdfData = Convert.FromBase64String("cGRmZGF0YQ=="); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, [ + new TextContent("Check this image: "), + new UriContent(new Uri("https://example.com/image.png"), "image/png"), + new DataContent(imageData, "image/png"), + new DataContent(pdfData, "application/pdf") { Name = "doc.pdf" }, + new HostedFileContent("file-123"), + new ErrorContent("I cannot process this") { ErrorCode = "Refusal" } + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Done", response.Text); + } + + [Fact] + public async Task NonStreamingResponseWithIncompleteReason_MapsFinishReason() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"incomplete", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Partial","annotations":[]}]}], + "incomplete_details":{"reason":"max_output_tokens"} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response); + Assert.Equal("Partial", response.Text); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + } + + [Fact] + public async Task StreamingResponseWithQueuedUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.queued + data: {"type":"response.queued","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"queued","model":"gpt-4o-mini","output":[]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 3); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithFailedUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.failed + data: {"type":"response.failed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"failed","model":"gpt-4o-mini","output":[],"error":{"code":"internal_error","message":"Internal error"}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 2); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithIncompleteUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.incomplete + data: {"type":"response.incomplete","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"incomplete","model":"gpt-4o-mini","output":[],"incomplete_details":{"reason":"max_output_tokens"}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 2); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithInProgressUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 3); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithRefusalUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.refusal.done + data: {"type":"response.refusal.done","refusal":"I cannot provide that information"} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + var refusalUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is ErrorContent ec && ec.ErrorCode == "Refusal")); + Assert.NotNull(refusalUpdate); + + var errorContent = refusalUpdate.Contents.OfType().First(); + Assert.Equal("I cannot provide that information", errorContent.Message); + Assert.Equal("Refusal", errorContent.ErrorCode); + } + + [Fact] + public async Task GetContinuationToken_WithMessages_ThrowsException() + { + using HttpClient httpClient = new(); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var token = new TestOpenAIResponsesContinuationToken("resp_123"); + + await Assert.ThrowsAsync(async () => + { + await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "test")], + new ChatOptions { ContinuationToken = token }); + }); + } + + [Fact] + public async Task StreamingResponseWithAnnotations_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","response_id":"resp_001","output_index":0,"item":{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Annotated text","annotations":[{"type":"file_citation","file_id":"file_123","start_index":0,"end_index":14}]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Annotated text","annotations":[{"type":"file_citation","file_id":"file_123","start_index":0,"end_index":14}]}]}]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + var annotatedUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c.Annotations?.Count > 0)); + Assert.NotNull(annotatedUpdate); + Assert.NotEmpty(annotatedUpdate.Contents.First().Annotations!); + } + + [Fact] + public async Task UserMessageWithEmptyText_CreatesEmptyInputPart() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Ok","annotations":[]}]}] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "")]); + + Assert.NotNull(response); + Assert.Equal("Ok", response.Text); + } + + [Fact] + public async Task ResponseWithRefusalContent_ParsesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"harmful request"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_001", + "status":"completed", + "role":"assistant", + "content":[ + {"type":"refusal","refusal":"I cannot help with that request"} + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("harmful request"); + + Assert.NotNull(response); + var errorContent = response.Messages.Last().Contents.OfType().FirstOrDefault(); + Assert.NotNull(errorContent); + Assert.Equal("I cannot help with that request", errorContent.Message); + Assert.Equal("Refusal", errorContent.ErrorCode); + } + + [Fact] + public async Task HostedImageGenerationTool_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "output_format": "png" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a cat" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_abc123", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-2024-11-20", + "output": [ + { + "type": "image_generation_call", + "id": "img_call_abc123", + "result": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + ], + "usage": { + "input_tokens": 15, + "output_tokens": 0, + "total_tokens": 15 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(1024, 1024), + MediaType = "image/png" + } + }; + var response = await client.GetResponseAsync("Generate an image of a cat", new ChatOptions + { + Tools = [imageTool] + }); + + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + var contents = response.Messages[0].Contents; + Assert.Equal(2, contents.Count); + + // First content should be the tool call + var toolCall = contents[0] as ImageGenerationToolCallContent; + Assert.NotNull(toolCall); + Assert.Equal("img_call_abc123", toolCall.ImageId); + + // Second content should be the result with image data + var toolResult = contents[1] as ImageGenerationToolResultContent; + Assert.NotNull(toolResult); + Assert.Equal("img_call_abc123", toolResult.ImageId); + Assert.Single(toolResult.Outputs!); + + var imageData = toolResult.Outputs![0] as DataContent; + Assert.NotNull(imageData); + Assert.Equal("image/png", imageData.MediaType); + Assert.True(imageData.Data.Length > 0); + } + + [Fact] + public async Task HostedImageGenerationTool_Streaming() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "output_format": "png" + } + ], + "tool_choice": "auto", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a dog" + } + ] + } + ] + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[],"tools":[{"type":"image_generation","image_generation":{"model":"gpt-image-1","size":{"width":1024,"height":1024},"output_format":"png"}}]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"image_generation_call","id":"img_call_def456","status":"in_progress"}} + + event: response.image_generation_call.in_progress + data: {"type":"response.image_generation_call.in_progress","item_id":"img_call_def456","output_index":0} + + event: response.image_generation_call.generating + data: {"type":"response.image_generation_call.generating","item_id":"img_call_def456","output_index":0} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_def456","output_index":0,"partial_image_index":0,"partial_image_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_call_def456","image_result_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-2024-11-20","output":[{"type":"image_generation_call","id":"img_call_def456","image_result_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}],"usage":{"input_tokens":15,"output_tokens":0,"total_tokens":15}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + List updates = []; + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(1024, 1024), + MediaType = "image/png" + } + }; + await foreach (var update in client.GetStreamingResponseAsync("Generate an image of a dog", new ChatOptions + { + Tools = [imageTool] + })) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 6); + + // Should have updates for: created, in_progress, tool call start, generating, partial image, completion + var createdUpdate = updates.First(u => u.CreatedAt.HasValue); + Assert.Equal("resp_def456", createdUpdate.ResponseId); + Assert.Equal("gpt-4o-2024-11-20", createdUpdate.ModelId); + + // Should have tool call content + var toolCallUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolCallContent)); + Assert.NotNull(toolCallUpdate); + var toolCall = toolCallUpdate.Contents.OfType().First(); + Assert.Equal("img_call_def456", toolCall.ImageId); + + // Should have partial image content + var partialImageUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolResultContent result && + result.Outputs != null && result.Outputs.Any(o => o.AdditionalProperties != null && o.AdditionalProperties.ContainsKey("PartialImageIndex")))); + Assert.NotNull(partialImageUpdate); + + // Should have final completion with usage + var completionUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is UsageContent)); + Assert.NotNull(completionUpdate); + var usage = completionUpdate.Contents.OfType().First(); + Assert.Equal(15, usage.Details.InputTokenCount); + Assert.Equal(0, usage.Details.OutputTokenCount); + Assert.Equal(15, usage.Details.TotalTokenCount); + } + + [Fact] + public async Task HostedImageGenerationTool_StreamingMultipleImages() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "512x512", + "output_format": "webp", + "partial_images": 3 + } + ], + "tool_choice": "auto", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a sunset" + } + ] + } + ] + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[],"tools":[{"type":"image_generation","image_generation":{"model":"gpt-image-1","size":{"width":512,"height":512},"output_format":"webp","partial_images":3}}]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"image_generation_call","id":"img_call_ghi789","status":"in_progress"}} + + event: response.image_generation_call.in_progress + data: {"type":"response.image_generation_call.in_progress","item_id":"img_call_ghi789","output_index":0} + + event: response.image_generation_call.generating + data: {"type":"response.image_generation_call.generating","item_id":"img_call_ghi789","output_index":0} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":0,"partial_image_b64":"SGVsbG8x"} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":1,"partial_image_b64":"SGVsbG8y"} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":2,"partial_image_b64":"SGVsbG8z"} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_call_ghi789","image_result_b64":"SGVsbG8z"}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-2024-11-20","output":[{"type":"image_generation_call","id":"img_call_ghi789","image_result_b64":"SGVsbG8z"}],"usage":{"input_tokens":18,"output_tokens":0,"total_tokens":18}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + List updates = []; + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(512, 512), + MediaType = "image/webp", + StreamingCount = 3 + } + }; + await foreach (var update in client.GetStreamingResponseAsync("Generate an image of a sunset", new ChatOptions + { + Tools = [imageTool] + })) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 8); // Should have multiple partial image updates plus generating event + + // Should have multiple partial images with different indices + var partialImageUpdates = updates.Where(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolResultContent result && + result.Outputs != null && result.Outputs.Any(o => o.AdditionalProperties != null && o.AdditionalProperties.ContainsKey("PartialImageIndex")))).ToList(); + + Assert.True(partialImageUpdates.Count >= 3); + + // Verify partial images have correct indices and WebP format + for (int i = 0; i < 3; i++) + { + var partialUpdate = partialImageUpdates.FirstOrDefault(u => + u.Contents.OfType().Any(result => + HasPartialImageWithIndex(result, i))); + Assert.NotNull(partialUpdate); + } + + static bool HasPartialImageWithIndex(ImageGenerationToolResultContent result, int index) + { + if (result.Outputs == null) + { + return false; + } + + return result.Outputs.Any(o => HasCorrectImageData(o, index)); + } + + static bool HasCorrectImageData(AIContent o, int index) + { + if (o.AdditionalProperties == null) + { + return false; + } + + if (!o.AdditionalProperties.TryGetValue("PartialImageIndex", out var imageIndex)) + { + return false; + } + + if (imageIndex == null || !imageIndex.Equals(index)) + { + return false; + } + + return o is DataContent dataContent && dataContent.MediaType == "image/webp"; + } + + // Verify tool call uses correct settings + var toolCallUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolCallContent)); + Assert.NotNull(toolCallUpdate); + var toolCall = toolCallUpdate.Contents.OfType().First(); + Assert.Equal("img_call_ghi789", toolCall.ImageId); + } + + private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => + new OpenAIClient( + new ApiKeyCredential("apikey"), + new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetOpenAIResponseClient(modelId) + .AsIChatClient(); + + private static string ResponseStatusToRequestValue(ResponseStatus status) + { + if (status == ResponseStatus.InProgress) + { + return "in_progress"; + } + + return status.ToString().ToLowerInvariant(); + } + + private sealed class TestOpenAIResponsesContinuationToken : ResponseContinuationToken + { + internal TestOpenAIResponsesContinuationToken(string responseId) + { + ResponseId = responseId; + } + + /// Gets or sets the Id of the response. + internal string ResponseId { get; set; } + + /// Gets or sets the sequence number of a streamed update. + internal int? SequenceNumber { get; set; } + + internal static TestOpenAIResponsesContinuationToken FromToken(object token) + { + if (token is TestOpenAIResponsesContinuationToken testOpenAIResponsesContinuationToken) + { + return testOpenAIResponsesContinuationToken; + } + + if (token is not ResponseContinuationToken) + { + throw new ArgumentException("Failed to create OpenAIResponsesResumptionToken from provided token because it is not of type ResponseContinuationToken.", nameof(token)); + } + + ReadOnlyMemory data = ((ResponseContinuationToken)token).ToBytes(); + + Utf8JsonReader reader = new(data.Span); + + string responseId = null!; + int? startAfter = null; + + _ = reader.Read(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string propertyName = reader.GetString()!; + + switch (propertyName) + { + case "responseId": + _ = reader.Read(); + responseId = reader.GetString()!; + break; + case "sequenceNumber": + _ = reader.Read(); + startAfter = reader.GetInt32(); + break; + default: + throw new JsonException($"Unrecognized property '{propertyName}'."); + } + } + + return new(responseId) + { + SequenceNumber = startAfter + }; + } + + public override ReadOnlyMemory ToBytes() + { + using MemoryStream stream = new(); + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + writer.WriteString("responseId", ResponseId); + + if (SequenceNumber.HasValue) + { + writer.WriteNumber("sequenceNumber", SequenceNumber.Value); + } + + writer.WriteEndObject(); + + writer.Flush(); + stream.Position = 0; + + return stream.ToArray(); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs index c80b37c865e..bb721806ff6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs @@ -7,6 +7,6 @@ public class OpenAISpeechToTextClientIntegrationTests : SpeechToTextClientIntegr { protected override ISpeechToTextClient? CreateClient() => IntegrationTestHelpers.GetOpenAIClient()? - .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "whisper-1") + .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "gpt-4o-mini-transcribe") .AsISpeechToTextClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index 4587c3a5524..2ba70995a86 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -8,7 +8,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Logging; using OpenAI; using OpenAI.Audio; @@ -26,17 +25,13 @@ public void AsISpeechToTextClient_InvalidArgs_Throws() Assert.Throws("audioClient", () => ((AudioClient)null!).AsISpeechToTextClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); ISpeechToTextClient speechToTextClient = client.GetAudioClient(model).AsISpeechToTextClient(); var metadata = speechToTextClient.GetService(); @@ -73,7 +68,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri { string input = $$""" { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "{{speechLanguage}}" } """; @@ -86,7 +81,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri using VerbatimMultiPartHttpHandler handler = new(input, Output) { ExpectedRequestUriContains = "audio/transcriptions" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); var response = await client.GetTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -107,7 +102,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri public async Task GetTextAsync_Cancelled_Throws() { using HttpClient httpClient = new(); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var fileStream = GetAudioStream(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -121,7 +116,7 @@ await Assert.ThrowsAsync(() public async Task GetStreamingTextAsync_Cancelled_Throws() { using HttpClient httpClient = new(); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var fileStream = GetAudioStream(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -147,8 +142,9 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { - "model": "whisper-1", - "language": "{{speechLanguage}}" + "model": "gpt-4o-transcribe", + "language": "{{speechLanguage}}", + "stream":true } """; @@ -160,7 +156,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu using VerbatimMultiPartHttpHandler handler = new(input, Output) { ExpectedRequestUriContains = "audio/transcriptions" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); await foreach (var update in client.GetStreamingTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -171,7 +167,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu { Assert.Contains("I finally got back to the gym the other day", update.Text); Assert.NotNull(update.RawRepresentation); - Assert.IsType(update.RawRepresentation); + Assert.IsType(update.RawRepresentation); } } @@ -183,7 +179,7 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() // There's no support for non english translations, so no language is passed to the API. const string Input = $$""" { - "model": "whisper-1" + "model": "gpt-4o-transcribe" } """; @@ -195,7 +191,7 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() using VerbatimMultiPartHttpHandler handler = new(Input, Output) { ExpectedRequestUriContains = "audio/translations" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); await foreach (var update in client.GetStreamingTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -211,11 +207,12 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() } [Fact] - public async Task GetTextAsync_NonStronglyTypedOptions_AllSent() + public async Task GetTextAsync_Transcription_StronglyTypedOptions_AllSent() { const string Input = """ { - "model": "whisper-1", + "model": "gpt-4o-transcribe", + "language": "pt", "prompt":"Hide any bad words with ", "temperature": 0.5, "response_format": "vtt", @@ -231,29 +228,32 @@ public async Task GetTextAsync_NonStronglyTypedOptions_AllSent() using VerbatimMultiPartHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() { - AdditionalProperties = new() + SpeechLanguage = "en", + RawRepresentationFactory = (s) => + new AudioTranscriptionOptions { - ["Prompt"] = "Hide any bad words with ", - ["SpeechLanguage"] = "pt", - ["Temperature"] = 0.5f, - ["TimestampGranularities"] = AudioTimestampGranularities.Segment | AudioTimestampGranularities.Word, - ["ResponseFormat"] = AudioTranscriptionFormat.Vtt, - }, + Prompt = "Hide any bad words with ", + Language = "pt", + Temperature = 0.5f, + TimestampGranularities = AudioTimestampGranularities.Segment | AudioTimestampGranularities.Word, + ResponseFormat = AudioTranscriptionFormat.Vtt + } })); } [Fact] - public async Task GetTextAsync_StronglyTypedOptions_AllSent() + public async Task GetTextAsync_Translation_StronglyTypedOptions_AllSent() { const string Input = """ { - "model": "whisper-1", - "language": "pt" + "model": "gpt-4o-transcribe", + "prompt":"Hide any bad words with ", + "response_format": "vtt" } """; @@ -265,12 +265,20 @@ public async Task GetTextAsync_StronglyTypedOptions_AllSent() using VerbatimMultiPartHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() { - SpeechLanguage = "pt", + SpeechLanguage = null, + TextLanguage = "pt", + RawRepresentationFactory = (s) => + new AudioTranslationOptions + { + Prompt = "Hide any bad words with ", + Temperature = 0.5f, // Temperature is ignored by OpenAI. + ResponseFormat = AudioTranslationFormat.Vtt + } })); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs new file mode 100644 index 00000000000..6b4b0fa0f1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +internal sealed class ThrowUserAgentExceptionHandler : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new InvalidOperationException($"User-Agent header: {request.Headers.UserAgent}"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index edd22edc41e..1f7e7f5084a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -37,34 +37,28 @@ public async Task SuccessUsage_Default() Assert.NotNull(responseFormat.Schema); AssertDeepEquals(JsonDocument.Parse(""" { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Some test description", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "fullName": { - "type": [ - "string", - "null" - ] - }, - "species": { - "type": "string", - "enum": [ - "Bear", - "Tiger", - "Walrus" - ] + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Some test description", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "fullName": { + "type": [ + "string", + "null" + ] + }, + "species": { + "type": "string", + "enum": [ + "Bear", + "Tiger", + "Walrus" + ] + } } - }, - "additionalProperties": false, - "required": [ - "id", - "fullName", - "species" - ] } """).RootElement, responseFormat.Schema.Value); Assert.Equal(nameof(Animal), responseFormat.SchemaName); @@ -195,6 +189,50 @@ public async Task WrapsNonObjectValuesInDataProperty() Assert.Equal(123, response.Result); } + [Fact] + public async Task OnlyUsesLastMessage() + { + var expectedResult = new Envelope { data = 123 }; + var expectedResponse = new ChatResponse( + [ + new ChatMessage(ChatRole.Assistant, + [ + new TextContent("I'm going to invoke a function to get the data."), + new FunctionCallContent("callid123", "get_data"), + ]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callid123", "result")]), + new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options)) + ]); + + using var client = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var responseFormat = Assert.IsType(options!.ResponseFormat); + Assert.Equal(""" + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "data": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "data" + ] + } + """, responseFormat.Schema.ToString()); + return Task.FromResult(expectedResponse); + }, + }; + + var response = await client.GetResponseAsync("Hello"); + Assert.Equal(123, response.Result); + } + [Fact] public async Task FailureUsage_InvalidJson() { @@ -336,29 +374,23 @@ public async Task CanSpecifyCustomJsonSerializationOptions() Assert.NotNull(responseFormat.Schema); AssertDeepEquals(JsonDocument.Parse(""" { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Some test description", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": [ - "string", - "null" - ] - }, - "species": { - "type": "integer" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Some test description", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": [ + "string", + "null" + ] + }, + "species": { + "type": "integer" + } } - }, - "additionalProperties": false, - "required": [ - "id", - "full_name", - "species" - ] } """).RootElement, responseFormat.Schema.Value); @@ -450,12 +482,6 @@ Elements are not equal. private static bool DeepEquals(JsonElement element1, JsonElement element2) { -#if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); -#else - return System.Text.Json.Nodes.JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); -#endif } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs index 4f2427d133c..4b6f9bc87e6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/DistributedCachingChatClientTest.cs @@ -33,9 +33,11 @@ public void Ctor_ExpectedDefaults() } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task CachesSuccessResultsAsync(bool conversationIdSet) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task CachesSuccessResultsAsync(bool conversationIdSet, bool customCaching) { // Arrange ChatOptions options = new() { ConversationId = conversationIdSet ? "123" : null }; @@ -79,10 +81,16 @@ public async Task CachesSuccessResultsAsync(bool conversationIdSet) return Task.FromResult(expectedResponse); } }; - using var outer = new DistributedCachingChatClient(testClient, _storage) - { - JsonSerializerOptions = TestJsonSerializerContext.Default.Options - }; + + int enableCachingInvocations = 0; + using var outer = customCaching ? + new CustomCachingChatClient(testClient, _storage, (m, o) => + { + return ++enableCachingInvocations % 2 == 0; + }) : + new DistributedCachingChatClient(testClient, _storage); + + outer.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; // Make the initial request and do a quick sanity check var result1 = await outer.GetResponseAsync("some input", options); @@ -93,12 +101,28 @@ public async Task CachesSuccessResultsAsync(bool conversationIdSet) var result2 = await outer.GetResponseAsync("some input", options); // Assert - Assert.Equal(conversationIdSet ? 2 : 1, innerCallCount); + if (customCaching) + { + Assert.Equal(enableCachingInvocations % 2 == 0 ? 2 : 1, innerCallCount); + } + else + { + Assert.Equal(conversationIdSet ? 2 : 1, innerCallCount); + } + AssertResponsesEqual(expectedResponse, result2); // Act/Assert 2: Cache misses do not return cached results await outer.GetResponseAsync("some modified input", options); - Assert.Equal(conversationIdSet ? 3 : 2, innerCallCount); + Assert.Equal(conversationIdSet || customCaching ? 3 : 2, innerCallCount); + + Assert.Equal(customCaching ? 3 : 0, enableCachingInvocations); + } + + private sealed class CustomCachingChatClient(IChatClient innerClient, IDistributedCache storage, Func, ChatOptions?, bool> enableCaching) : + DistributedCachingChatClient(innerClient, storage) + { + protected override bool EnableCaching(IEnumerable messages, ChatOptions? options) => enableCaching(messages, options); } [Fact] @@ -571,6 +595,52 @@ public async Task CacheKeyVariesByChatOptionsAsync() Assert.Equal("value 2", result4.Text); } + [Fact] + public async Task CacheKeyVariesByAdditionalKeyValuesAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var testClient = new TestChatClient + { + GetResponseAsyncCallback = async (_, options, _) => + { + innerCallCount++; + await Task.Yield(); + return new(new ChatMessage(ChatRole.Assistant, innerCallCount.ToString())); + } + }; + using var outer = new DistributedCachingChatClient(testClient, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options + }; + + var result1 = await outer.GetResponseAsync([]); + var result2 = await outer.GetResponseAsync([]); + + Assert.Equal(1, innerCallCount); + Assert.Equal("1", result1.Text); + Assert.Equal("1", result2.Text); + + // Change key + outer.CacheKeyAdditionalValues = ["extraKey"]; + + var result3 = await outer.GetResponseAsync([]); + var result4 = await outer.GetResponseAsync([]); + + Assert.Equal(2, innerCallCount); + Assert.Equal("2", result3.Text); + Assert.Equal("2", result4.Text); + + // Remove key + outer.CacheKeyAdditionalValues = []; + + var result5 = await outer.GetResponseAsync([]); + + Assert.Equal(2, innerCallCount); + Assert.Equal("1", result5.Text); + } + [Fact] public async Task SubclassCanOverrideCacheKeyToVaryByChatOptionsAsync() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs new file mode 100644 index 00000000000..7c42c0edaf9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -0,0 +1,1004 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI.ChatCompletion; + +public class FunctionInvokingChatClientApprovalsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsync(bool useAdditionalTools) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(() => "Result 1", "Func1")), + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + } + + [Fact] + public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditionalRequireApprovalAsync(bool additionalToolsRequireApproval) + { + AIFunction func1 = AIFunctionFactory.Create(() => "Result 1", "Func1"); + AIFunction func2 = AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"); + AITool[] additionalTools = + [ + additionalToolsRequireApproval ? new ApprovalRequiredAIFunction(func1) : func1, + ]; + + var options = new ChatOptions + { + Tools = + [ + additionalToolsRequireApproval ? func2 : new ApprovalRequiredAIFunction(func2), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); + } + + [Fact] + public async Task ApprovedApprovalResponsesAreExecutedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + ]), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task RejectedApprovalResponsesAreFailedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."), + new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.") + ]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."), + new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.") + ]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List nonStreamingOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List streamingOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."), + new FunctionResultContent("callId2", result: "Result 2: 42") + ]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput); + } + + [Fact] + public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })]), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })) + ]), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task AlreadyExecutedApprovalsAreIgnoredAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func1")), + ]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func1")), + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "World"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "World"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + /// + /// This verifies the following scenario: + /// 1. We are streaming (also including non-streaming in the test for completeness). + /// 2. There is one function that requires approval and one that does not. + /// 3. We only get back FCC for the function that does not require approval. + /// 4. This means that once we receive this FCC, we need to buffer all updates until the end, because we might receive more FCCs and some may require approval. + /// 5. We then need to verify that we will still stream all updates once we reach the end, including the buffered FCC. + /// + [Fact] + public async Task MixedApprovalRequiredToolsWithNonApprovalRequiringFunctionCallAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + Func>> expectedDownstreamClientInput = () => new Queue>( + [ + new List + { + new ChatMessage(ChatRole.User, "hello"), + }, + new List + { + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]) + } + ]); + + Func>> downstreamClientOutput = () => new Queue>( + [ + new List + { + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + }, + new List + { + new ChatMessage(ChatRole.Assistant, "World again"), + } + ]); + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "World again"), + ]; + + await InvokeAndAssertMultiRoundAsync(options, input, downstreamClientOutput(), output, expectedDownstreamClientInput()); + + await InvokeAndAssertStreamingMultiRoundAsync(options, input, downstreamClientOutput(), output, expectedDownstreamClientInput()); + } + + [Fact] + public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + ]) { MessageId = "resp1" }, + ]; + + var invokeException = await Assert.ThrowsAsync( + async () => await InvokeAndAssertAsync(options, input, [], [], [])); + Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message); + + var invokeStreamingException = await Assert.ThrowsAsync( + async () => await InvokeAndAssertStreamingAsync(options, input, [], [], [])); + Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message); + } + + [Fact] + public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceThreadsAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ], + ConversationId = "test-conversation", + }; + + List input = + [ + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + /// + /// Since we do not have a way of supporting both functions that require approval and those that do not + /// in one invocation, we always require all function calls to be approved if any require approval. + /// If we are therefore unsure as to whether we will encounter a function call that requires approval, + /// we have to wait until we find one before yielding any function call content. + /// If we don't have any function calls that require approval at all though, we can just yield all content normally + /// since this issue won't apply. + /// + [Fact] + public async Task FunctionCallContentIsYieldedImmediatelyIfNoApprovalRequiredWhenStreamingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = [new ChatMessage(ChatRole.User, "hello")]; + + Func configurePipeline = b => b.Use(s => new FunctionInvokingChatClient(s)); + using CancellationTokenSource cts = new(); + + var updateYieldCount = 0; + + async IAsyncEnumerable YieldInnerClientUpdates( + IEnumerable contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken) + { + Assert.Equal(cts.Token, actualCancellationToken); + await Task.Yield(); + var messageId = Guid.NewGuid().ToString("N"); + + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId }; + updateYieldCount++; + yield return + new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }) + ]) + { MessageId = messageId }; + } + + using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates }; + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + + var updateCount = 0; + await foreach (var update in updates) + { + if (updateCount < 2) + { + var functionCall = update.Contents.OfType().First(); + if (functionCall.CallId == "callId1") + { + Assert.Equal("Func1", functionCall.Name); + Assert.Equal(1, updateYieldCount); + } + else if (functionCall.CallId == "callId2") + { + Assert.Equal("Func2", functionCall.Name); + Assert.Equal(2, updateYieldCount); + } + } + + updateCount++; + } + } + + /// + /// Since we do not have a way of supporting both functions that require approval and those that do not + /// in one invocation, we always require all function calls to be approved if any require approval. + /// If we are therefore unsure as to whether we will encounter a function call that requires approval, + /// we have to wait until we find one before yielding any function call content. + /// We can however, yield any other content until we encounter the first function call. + /// + [Fact] + public async Task FunctionCalsAreBufferedUntilApprovalRequirementEncounteredWhenStreamingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + AIFunctionFactory.Create(() => "Result 3", "Func3"), + ] + }; + + List input = [new ChatMessage(ChatRole.User, "hello")]; + + Func configurePipeline = b => b.Use(s => new FunctionInvokingChatClient(s)); + using CancellationTokenSource cts = new(); + + var updateYieldCount = 0; + + async IAsyncEnumerable YieldInnerClientUpdates( + IEnumerable contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken) + { + Assert.Equal(cts.Token, actualCancellationToken); + await Task.Yield(); + var messageId = Guid.NewGuid().ToString("N"); + + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 1")]) { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 2")]) { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }) + ]) + { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func3")]) { MessageId = messageId }; + } + + using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates }; + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + + var updateCount = 0; + await foreach (var update in updates) + { + switch (updateCount) + { + case 0: + Assert.Equal("Text 1", update.Contents.OfType().First().Text); + + // First content should be yielded immedately, since we don't have any function calls yet. + Assert.Equal(1, updateYieldCount); + break; + case 1: + Assert.Equal("Text 2", update.Contents.OfType().First().Text); + + // Second content should be yielded immedately, since we don't have any function calls yet. + Assert.Equal(2, updateYieldCount); + break; + case 2: + var approvalRequest1 = update.Contents.OfType().First(); + Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId); + Assert.Equal("Func1", approvalRequest1.FunctionCall.Name); + + // Third content should have been buffered, since we have not yet encountered a function call that requires approval. + Assert.Equal(4, updateYieldCount); + break; + case 3: + var approvalRequest2 = update.Contents.OfType().First(); + Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId); + Assert.Equal("Func2", approvalRequest2.FunctionCall.Name); + + // Fourth content can be yielded immediately, since it is the first function call that requires approval. + Assert.Equal(4, updateYieldCount); + break; + case 4: + var approvalRequest3 = update.Contents.OfType().First(); + Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId); + Assert.Equal("Func3", approvalRequest3.FunctionCall.Name); + + // Fifth content can be yielded immediately, since we previously encountered a function call that requires approval. + Assert.Equal(5, updateYieldCount); + break; + } + + updateCount++; + } + } + + private static Task> InvokeAndAssertAsync( + ChatOptions? options, + List input, + List downstreamClientOutput, + List expectedOutput, + List? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + => InvokeAndAssertMultiRoundAsync( + options, + input, + new Queue>(new[] { downstreamClientOutput }), + expectedOutput, + expectedDownstreamClientInput is null ? null : new Queue>(new[] { expectedDownstreamClientInput }), + configurePipeline, + additionalTools); + + private static async Task> InvokeAndAssertMultiRoundAsync( + ChatOptions? options, + List input, + Queue> downstreamClientOutput, + List expectedOutput, + Queue>? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + { + Assert.NotEmpty(input); + + configurePipeline ??= b => b.Use(s => new FunctionInvokingChatClient(s) { AdditionalTools = additionalTools }); + + using CancellationTokenSource cts = new(); + long expectedTotalTokenCounts = 0; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + Assert.Equal(cts.Token, actualCancellationToken); + if (expectedDownstreamClientInput is not null) + { + AssertExtensions.EqualMessageLists(expectedDownstreamClientInput.Dequeue(), contents.ToList()); + } + + await Task.Yield(); + + var usage = CreateRandomUsage(); + expectedTotalTokenCounts += usage.InputTokenCount!.Value; + + var output = downstreamClientOutput.Dequeue(); + output.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N")); + return new ChatResponse(output) { Usage = usage }; + } + }; + + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + Assert.NotNull(result); + + var actualOutput = result.Messages as List ?? result.Messages.ToList(); + AssertExtensions.EqualMessageLists(expectedOutput, actualOutput); + + // Usage should be aggregated over all responses, including AdditionalUsage + var actualUsage = result.Usage!; + Assert.Equal(expectedTotalTokenCounts, actualUsage.InputTokenCount); + Assert.Equal(expectedTotalTokenCounts, actualUsage.OutputTokenCount); + Assert.Equal(expectedTotalTokenCounts, actualUsage.TotalTokenCount); + Assert.Equal(2, actualUsage.AdditionalCounts!.Count); + Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["firstValue"]); + Assert.Equal(expectedTotalTokenCounts, actualUsage.AdditionalCounts["secondValue"]); + + return actualOutput; + } + + private static UsageDetails CreateRandomUsage() + { + // We'll set the same random number on all the properties so that, when determining the + // correct sum in tests, we only have to total the values once + var value = new Random().Next(100); + return new UsageDetails + { + InputTokenCount = value, + OutputTokenCount = value, + TotalTokenCount = value, + AdditionalCounts = new() { ["firstValue"] = value, ["secondValue"] = value }, + }; + } + + private static Task> InvokeAndAssertStreamingAsync( + ChatOptions? options, + List input, + List downstreamClientOutput, + List expectedOutput, + List? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + => InvokeAndAssertStreamingMultiRoundAsync( + options, + input, + new Queue>(new[] { downstreamClientOutput }), + expectedOutput, + expectedDownstreamClientInput is null ? null : new Queue>(new[] { expectedDownstreamClientInput }), + configurePipeline, + additionalTools); + + private static async Task> InvokeAndAssertStreamingMultiRoundAsync( + ChatOptions? options, + List input, + Queue> downstreamClientOutput, + List expectedOutput, + Queue>? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + { + Assert.NotEmpty(input); + + configurePipeline ??= b => b.Use(s => new FunctionInvokingChatClient(s) { AdditionalTools = additionalTools }); + + using CancellationTokenSource cts = new(); + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + { + Assert.Equal(cts.Token, actualCancellationToken); + if (expectedDownstreamClientInput is not null) + { + AssertExtensions.EqualMessageLists(expectedDownstreamClientInput.Dequeue(), contents.ToList()); + } + + var output = downstreamClientOutput.Dequeue(); + output.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N")); + return YieldAsync(new ChatResponse(output).ToChatResponseUpdates()); + } + }; + + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token).ToChatResponseAsync(); + Assert.NotNull(result); + + var actualOutput = result.Messages as List ?? result.Messages.ToList(); + + expectedOutput ??= input; + AssertExtensions.EqualMessageLists(expectedOutput, actualOutput); + + return actualOutput; + } + + private static async IAsyncEnumerable YieldAsync(params T[] items) + { + await Task.Yield(); + foreach (var item in items) + { + yield return item; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 26554946dca..2308a921ab3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -2,10 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -35,8 +35,43 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.AllowConcurrentInvocation); Assert.False(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + Assert.Null(client.FunctionInvoker); + Assert.Null(client.AdditionalTools); + } + + [Fact] + public void Properties_Roundtrip() + { + using TestChatClient innerClient = new(); + using FunctionInvokingChatClient client = new(innerClient); + + Assert.False(client.AllowConcurrentInvocation); + client.AllowConcurrentInvocation = true; + Assert.True(client.AllowConcurrentInvocation); + + Assert.False(client.IncludeDetailedErrors); + client.IncludeDetailedErrors = true; + Assert.True(client.IncludeDetailedErrors); + + Assert.Equal(40, client.MaximumIterationsPerRequest); + client.MaximumIterationsPerRequest = 5; + Assert.Equal(5, client.MaximumIterationsPerRequest); + + Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + client.MaximumConsecutiveErrorsPerRequest = 1; + Assert.Equal(1, client.MaximumConsecutiveErrorsPerRequest); + + Assert.Null(client.FunctionInvoker); + Func> invoker = (ctx, ct) => new ValueTask("test"); + client.FunctionInvoker = invoker; + Assert.Same(invoker, client.FunctionInvoker); + + Assert.Null(client.AdditionalTools); + IList additionalTools = [AIFunctionFactory.Create(() => "Additional Tool")]; + client.AdditionalTools = additionalTools; + Assert.Same(additionalTools, client.AdditionalTools); } [Fact] @@ -69,6 +104,73 @@ public async Task SupportsSingleFunctionCallPerRequestAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SupportsToolsProvidedByAdditionalTools(bool provideOptions) + { + ChatOptions? options = provideOptions ? + new() { Tools = [AIFunctionFactory.Create(() => "Shouldn't be invoked", "ChatOptionsFunc")] } : + null; + + Func configure = builder => + builder.UseFunctionInvocation(configure: c => c.AdditionalTools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ]); + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + + [Fact] + public async Task PrefersToolsProvidedByChatOptions() + { + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + Func configure = builder => + builder.UseFunctionInvocation(configure: c => c.AdditionalTools = + [ + AIFunctionFactory.Create(() => "Should never be invoked", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ]); + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -208,6 +310,49 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Fact] + public async Task FunctionInvokerDelegateOverridesHandlingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) + { + FunctionInvoker = async (ctx, cancellationToken) => + { + Assert.NotNull(ctx); + var result = await ctx.Function.InvokeAsync(ctx.Arguments, cancellationToken); + return result is JsonElement e ? + JsonSerializer.SerializeToElement($"{e.GetString()} from delegate", AIJsonUtilities.DefaultOptions) : + result; + } + }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Fact] public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() { @@ -510,9 +655,10 @@ async Task InvokeAsync(Func work) } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry, bool enableSensitiveData) { string sourceName = Guid.NewGuid().ToString(); @@ -530,13 +676,13 @@ public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry) }; Func configure = b => b.Use(c => - new FunctionInvokingChatClient(new OpenTelemetryChatClient(c, sourceName: sourceName))); + new FunctionInvokingChatClient(new OpenTelemetryChatClient(c, sourceName: sourceName) { EnableSensitiveData = enableSensitiveData })); - await InvokeAsync(() => InvokeAndAssertAsync(options, plan, configurePipeline: configure), streaming: false); + await InvokeAsync(() => InvokeAndAssertAsync(options, plan, configurePipeline: configure)); - await InvokeAsync(() => InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure), streaming: true); + await InvokeAsync(() => InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure)); - async Task InvokeAsync(Func work, bool streaming) + async Task InvokeAsync(Func work) { var activities = new List(); using TracerProvider? tracerProvider = enableTelemetry ? @@ -554,7 +700,24 @@ async Task InvokeAsync(Func work, bool streaming) activity => Assert.Equal("chat", activity.DisplayName), activity => Assert.Equal("execute_tool Func1", activity.DisplayName), activity => Assert.Equal("chat", activity.DisplayName), - activity => Assert.Equal(streaming ? "FunctionInvokingChatClient.GetStreamingResponseAsync" : "FunctionInvokingChatClient.GetResponseAsync", activity.DisplayName)); + activity => Assert.Equal("orchestrate_tools", activity.DisplayName)); + + var executeTool = activities[1]; + if (enableSensitiveData) + { + var args = Assert.Single(executeTool.Tags, t => t.Key == "gen_ai.tool.call.arguments"); + Assert.Equal( + JsonSerializer.Serialize(new Dictionary { ["arg1"] = "value1" }, AIJsonUtilities.DefaultOptions), + args.Value); + + var result = Assert.Single(executeTool.Tags, t => t.Key == "gen_ai.tool.call.result"); + Assert.Equal("Result 1", JsonSerializer.Deserialize(result.Value!, AIJsonUtilities.DefaultOptions)); + } + else + { + Assert.DoesNotContain(executeTool.Tags, t => t.Key == "gen_ai.tool.call.arguments"); + Assert.DoesNotContain(executeTool.Tags, t => t.Key == "gen_ai.tool.call.result"); + } for (int i = 0; i < activities.Count - 1; i++) { @@ -916,6 +1079,159 @@ public async Task FunctionInvocations_InvokedOnOriginalSynchronizationContext() await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configurePipeline); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminateOnUnknownCalls_ControlsBehaviorForUnknownFunctions(bool terminateOnUnknown) + { + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create((int i) => $"Known: {i}", "KnownFunc")] + }; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = terminateOnUnknown }); + + if (!terminateOnUnknown) + { + List planForContinue = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "UnknownFunc", new Dictionary { ["i"] = 1 }), + new FunctionCallContent("callId2", "KnownFunc", new Dictionary { ["i"] = 2 }) + ]), + new(ChatRole.Tool, [ + new FunctionResultContent("callId1", result: "Error: Requested function \"UnknownFunc\" not found."), + new FunctionResultContent("callId2", result: "Known: 2") + ]), + new(ChatRole.Assistant, "done"), + ]; + + await InvokeAndAssertAsync(options, planForContinue, configurePipeline: configure); + await InvokeAndAssertStreamingAsync(options, planForContinue, configurePipeline: configure); + } + else + { + List fullPlanWithUnknown = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "UnknownFunc", new Dictionary { ["i"] = 1 }), + new FunctionCallContent("callId2", "KnownFunc", new Dictionary { ["i"] = 2 }) + ]), + new(ChatRole.Tool, [ + new FunctionResultContent("callId1", result: "Error: Requested function \"UnknownFunc\" not found."), + new FunctionResultContent("callId2", result: "Known: 2") + ]), + new(ChatRole.Assistant, "done"), + ]; + + var expected = fullPlanWithUnknown.Take(2).ToList(); + await InvokeAndAssertAsync(options, fullPlanWithUnknown, expected, configure); + await InvokeAndAssertStreamingAsync(options, fullPlanWithUnknown, expected, configure); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RequestsWithOnlyFunctionDeclarations_TerminatesRegardlessOfTerminateOnUnknownCalls(bool terminateOnUnknown) + { + var declarationOnly = AIFunctionFactory.Create(() => "unused", "DefOnly").AsDeclarationOnly(); + + ChatOptions options = new() { Tools = [declarationOnly] }; + + List fullPlan = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [new FunctionCallContent("callId1", "DefOnly")]), + new(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Should not be produced")]), + new(ChatRole.Assistant, "world"), + ]; + + List expected = fullPlan.Take(2).ToList(); + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = terminateOnUnknown }); + + await InvokeAndAssertAsync(options, fullPlan, expected, configure); + await InvokeAndAssertStreamingAsync(options, fullPlan, expected, configure); + } + + [Fact] + public async Task MixedKnownFunctionAndDeclaration_TerminatesWithoutInvokingKnown() + { + int invoked = 0; + var known = AIFunctionFactory.Create(() => { invoked++; return "OK"; }, "Known"); + var defOnly = AIFunctionFactory.Create(() => "unused", "DefOnly").AsDeclarationOnly(); + + var options = new ChatOptions + { + Tools = [known, defOnly] + }; + + List fullPlan = + [ + new(ChatRole.User, "hi"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Known"), + new FunctionCallContent("callId2", "DefOnly") + ]), + new(ChatRole.Tool, [new FunctionResultContent("callId1", result: "OK"), new FunctionResultContent("callId2", result: "nope")]), + new(ChatRole.Assistant, "done"), + ]; + + List expected = fullPlan.Take(2).ToList(); + + Func configure = b => b.Use(s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = false }); + await InvokeAndAssertAsync(options, fullPlan, expected, configure); + Assert.Equal(0, invoked); + + invoked = 0; + configure = b => b.Use(s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = true }); + await InvokeAndAssertStreamingAsync(options, fullPlan, expected, configure); + Assert.Equal(0, invoked); + } + + [Fact] + public async Task ClonesChatOptionsAndResetContinuationTokenForBackgroundResponsesAsync() + { + ChatOptions? actualChatOptions = null; + + using var innerChatClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + actualChatOptions = chatOptions; + + List messages = []; + + // Simulate the model returning a function call for the first call only + if (!chatContents.Any(m => m.Contents.OfType().Any())) + { + messages.Add(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])); + } + + return Task.FromResult(new ChatResponse { Messages = messages }); + } + }; + + using var chatClient = new FunctionInvokingChatClient(innerChatClient); + + var originalChatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { }, "Func1")], + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }), + }; + + await chatClient.GetResponseAsync("hi", originalChatOptions); + + // The original options should be cloned and have a null ContinuationToken + Assert.NotSame(originalChatOptions, actualChatOptions); + Assert.Null(actualChatOptions!.ContinuationToken); + } + private sealed class CustomSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state) @@ -929,7 +1245,7 @@ public override void Post(SendOrPostCallback d, object? state) } private static async Task> InvokeAndAssertAsync( - ChatOptions options, + ChatOptions? options, List plan, List? expected = null, Func? configurePipeline = null, @@ -970,37 +1286,7 @@ private static async Task> InvokeAndAssertAsync( chat.AddRange(result.Messages); expected ??= plan; - Assert.Equal(expected.Count, chat.Count); - for (int i = 0; i < expected.Count; i++) - { - var expectedMessage = expected[i]; - var chatMessage = chat[i]; - - Assert.Equal(expectedMessage.Role, chatMessage.Role); - Assert.Equal(expectedMessage.Text, chatMessage.Text); - Assert.Equal(expectedMessage.GetType(), chatMessage.GetType()); - - Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count); - for (int j = 0; j < expectedMessage.Contents.Count; j++) - { - var expectedItem = expectedMessage.Contents[j]; - var chatItem = chatMessage.Contents[j]; - - Assert.Equal(expectedItem.GetType(), chatItem.GetType()); - Assert.Equal(expectedItem.ToString(), chatItem.ToString()); - if (expectedItem is FunctionCallContent expectedFunctionCall) - { - var chatFunctionCall = (FunctionCallContent)chatItem; - Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name); - AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments); - } - else if (expectedItem is FunctionResultContent expectedFunctionResult) - { - var chatFunctionResult = (FunctionResultContent)chatItem; - AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result); - } - } - } + AssertExtensions.EqualMessageLists(expected, chat); // Usage should be aggregated over all responses, including AdditionalUsage var actualUsage = result.Usage!; @@ -1029,7 +1315,7 @@ private static UsageDetails CreateRandomUsage() } private static async Task> InvokeAndAssertStreamingAsync( - ChatOptions options, + ChatOptions? options, List plan, List? expected = null, Func? configurePipeline = null, @@ -1064,38 +1350,8 @@ private static async Task> InvokeAndAssertStreamingAsync( chat.AddRange(result.Messages); expected ??= plan; - Assert.Equal(expected.Count, chat.Count); - for (int i = 0; i < expected.Count; i++) - { - var expectedMessage = expected[i]; - var chatMessage = chat[i]; - - Assert.Equal(expectedMessage.Role, chatMessage.Role); - Assert.Equal(expectedMessage.Text, chatMessage.Text); - Assert.Equal(expectedMessage.GetType(), chatMessage.GetType()); - - Assert.Equal(expectedMessage.Contents.Count, chatMessage.Contents.Count); - for (int j = 0; j < expectedMessage.Contents.Count; j++) - { - var expectedItem = expectedMessage.Contents[j]; - var chatItem = chatMessage.Contents[j]; - - Assert.Equal(expectedItem.GetType(), chatItem.GetType()); - Assert.Equal(expectedItem.ToString(), chatItem.ToString()); - if (expectedItem is FunctionCallContent expectedFunctionCall) - { - var chatFunctionCall = (FunctionCallContent)chatItem; - Assert.Equal(expectedFunctionCall.Name, chatFunctionCall.Name); - AssertExtensions.EqualFunctionCallParameters(expectedFunctionCall.Arguments, chatFunctionCall.Arguments); - } - else if (expectedItem is FunctionResultContent expectedFunctionResult) - { - var chatFunctionResult = (FunctionResultContent)chatItem; - AssertExtensions.EqualFunctionCallResults(expectedFunctionResult.Result, chatFunctionResult.Result); - } - } - } + AssertExtensions.EqualMessageLists(expected, chat); return chat; } @@ -1107,24 +1363,4 @@ private static async IAsyncEnumerable YieldAsync(params IEnumerable ite yield return item; } } - - private sealed class EnumeratedOnceEnumerable(IEnumerable items) : IEnumerable - { - private int _iterated; - - public IEnumerator GetEnumerator() - { - if (Interlocked.Exchange(ref _iterated, 1) != 0) - { - throw new InvalidOperationException("This enumerable can only be enumerated once."); - } - - foreach (var item in items) - { - yield return item; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs new file mode 100644 index 00000000000..a70d19abffc --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs @@ -0,0 +1,386 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratingChatClientTests +{ + [Fact] + public void ImageGeneratingChatClient_InvalidArgs_Throws() + { + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + + Assert.Throws("innerClient", () => new ImageGeneratingChatClient(null!, imageGenerator)); + Assert.Throws("imageGenerator", () => new ImageGeneratingChatClient(innerClient, null!)); + } + + [Fact] + public void UseImageGeneration_WithNullBuilder_Throws() + { + Assert.Throws("builder", () => ((ChatClientBuilder)null!).UseImageGeneration()); + } + + [Fact] + public async Task GetResponseAsync_WithoutImageGenerationTool_PassesThrough() + { + // Arrange + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "dummy function", name: "DummyFunction")] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.NotNull(response); + Assert.Equal("test response", response.Messages[0].Text); + + // Verify that tools collection still has the original function, not replaced + Assert.Single(chatOptions.Tools); + Assert.IsAssignableFrom(chatOptions.Tools[0]); + } + + [Fact] + public async Task GetResponseAsync_WithImageGenerationTool_ReplacesTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(3, capturedOptions.Tools.Count); + + // Verify the functions are properly created + var generateImageFunction = capturedOptions.Tools[0] as AIFunction; + var editImageFunction = capturedOptions.Tools[1] as AIFunction; + var getImagesForEditImageFunction = capturedOptions.Tools[2] as AIFunction; + + Assert.NotNull(generateImageFunction); + Assert.NotNull(editImageFunction); + Assert.NotNull(getImagesForEditImageFunction); + Assert.Equal("GenerateImage", generateImageFunction.Name); + Assert.Equal("EditImage", editImageFunction.Name); + Assert.Equal("GetImagesForEdit", getImagesForEditImageFunction.Name); + } + + [Fact] + public async Task GetResponseAsync_WithMixedTools_ReplacesOnlyImageGenerationTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var dummyFunction = AIFunctionFactory.Create(() => "dummy", name: "DummyFunction"); + var chatOptions = new ChatOptions + { + Tools = [dummyFunction, new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(4, capturedOptions.Tools.Count); // DummyFunction + GenerateImage + EditImage + GetImagesForEdit + + Assert.Same(dummyFunction, capturedOptions.Tools[0]); // Original function preserved + Assert.IsAssignableFrom(capturedOptions.Tools[1]); // GenerateImage function + Assert.IsAssignableFrom(capturedOptions.Tools[2]); // EditImage function + } + + [Fact] + public void UseImageGeneration_ServiceProviderIntegration_Works() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + + using var serviceProvider = services.BuildServiceProvider(); + using var innerClient = new TestChatClient(); + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration() + .Build(serviceProvider); + + // Assert + Assert.IsType(client); + } + + [Fact] + public void UseImageGeneration_WithProvidedImageGenerator_Works() + { + // Arrange + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration(imageGenerator) + .Build(); + + // Assert + Assert.IsType(client); + } + + [Fact] + public void UseImageGeneration_WithConfigureCallback_CallsCallback() + { + // Arrange + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + bool configureCallbackInvoked = false; + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration(imageGenerator, configure: c => + { + Assert.NotNull(c); + configureCallbackInvoked = true; + }) + .Build(); + + // Assert + Assert.True(configureCallbackInvoked); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithImageGenerationTool_ReplacesTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return GetUpdatesAsync(); + } + }; + + static async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new(ChatRole.Assistant, "test"); + } + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + await foreach (var update in client.GetStreamingResponseAsync([new(ChatRole.User, "test")], chatOptions)) + { + // Process updates + } + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(3, capturedOptions.Tools.Count); + } + + [Fact] + public async Task GetResponseAsync_WithNullOptions_DoesNotThrow() + { + // Arrange + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + // Act & Assert + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], null); + Assert.NotNull(response); + } + + [Fact] + public async Task GetResponseAsync_WithEmptyTools_DoesNotModify() + { + // Arrange + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [] + }; + + // Act + await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.Same(chatOptions, capturedOptions); +#pragma warning disable CA1508 + Assert.NotNull(capturedOptions?.Tools); +#pragma warning restore CA1508 + Assert.Empty(capturedOptions.Tools); + } + + [Fact] + public async Task GetResponseAsync_WithFunctionCallContent_ReplacesWithImageGenerationToolCallContent() + { + // Arrange + var callId = "test-call-id"; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var responseMessage = new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent(callId, "GenerateImage", new Dictionary { ["prompt"] = "a cat" })]); + return Task.FromResult(new ChatResponse(responseMessage)); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + var message = response.Messages[0]; + Assert.Single(message.Contents); + + var imageToolCallContent = Assert.IsType(message.Contents[0]); + Assert.Equal(callId, imageToolCallContent.ImageId); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithFunctionCallContent_ReplacesWithImageGenerationToolCallContent() + { + // Arrange + var callId = "test-call-id"; + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + return GetUpdatesAsync(); + } + }; + + async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, + [new FunctionCallContent(callId, "GenerateImage", new Dictionary { ["prompt"] = "a cat" })]); + } + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var updates = new List(); + await foreach (var responseUpdate in client.GetStreamingResponseAsync([new(ChatRole.User, "test")], chatOptions)) + { + updates.Add(responseUpdate); + } + + // Assert + Assert.Single(updates); + var update = updates[0]; + Assert.Single(update.Contents); + + var imageToolCallContent = Assert.IsType(update.Contents[0]); + Assert.Equal(callId, imageToolCallContent.ImageId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index 4d0122c7c92..e7206b05ff5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -4,11 +4,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -30,9 +30,6 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData, bool .AddInMemoryExporter(activities) .Build(); - var collector = new FakeLogCollector(); - using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); - using var innerClient = new TestChatClient { GetResponseAsyncCallback = async (messages, options, cancellationToken) => @@ -98,7 +95,7 @@ async static IAsyncEnumerable CallbackAsync( using var chatClient = innerClient .AsBuilder() - .UseOpenTelemetry(loggerFactory, sourceName, configure: instance => + .UseOpenTelemetry(null, sourceName, configure: instance => { instance.EnableSensitiveData = enableSensitiveData; instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; @@ -108,10 +105,10 @@ async static IAsyncEnumerable CallbackAsync( List messages = [ new(ChatRole.System, "You are a close friend."), - new(ChatRole.User, "Hey!"), + new(ChatRole.User, "Hey!") { AuthorName = "Alice" }, new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]), new(ChatRole.Tool, [new FunctionResultContent("12345", "John")]), - new(ChatRole.Assistant, "Hey John, what's up?"), + new(ChatRole.Assistant, "Hey John, what's up?") { AuthorName = "BotAssistant" }, new(ChatRole.User, "What's the biggest animal?") ]; @@ -132,6 +129,16 @@ async static IAsyncEnumerable CallbackAsync( ["service_tier"] = "value1", ["SomethingElse"] = "value2", }, + Instructions = "You are helpful.", + Tools = + [ + AIFunctionFactory.Create((string personName) => personName, "GetPersonAge", "Gets the age of a person by name."), + new HostedWebSearchTool(), + new HostedFileSearchTool(), + new HostedCodeInterpreterTool(), + new HostedMcpServerTool("myAwesomeServer", "http://localhost:1234/somewhere"), + AIFunctionFactory.Create((string location) => "", "GetCurrentWeather", "Gets the current weather for a location.").AsDeclarationOnly(), + ], }; if (streaming) @@ -151,11 +158,11 @@ async static IAsyncEnumerable CallbackAsync( Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); - Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal("localhost", activity.GetTagItem("server.address")); Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); Assert.Equal("chat replacementmodel", activity.DisplayName); - Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty")); @@ -165,55 +172,422 @@ async static IAsyncEnumerable CallbackAsync( Assert.Equal(7, activity.GetTagItem("gen_ai.request.top_k")); Assert.Equal(123, activity.GetTagItem("gen_ai.request.max_tokens")); Assert.Equal("""["hello", "world"]""", activity.GetTagItem("gen_ai.request.stop_sequences")); - Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("gen_ai.testservice.request.service_tier")); - Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("gen_ai.testservice.request.something_else")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); Assert.Equal(42L, activity.GetTagItem("gen_ai.request.seed")); Assert.Equal("id123", activity.GetTagItem("gen_ai.response.id")); Assert.Equal("""["stop"]""", activity.GetTagItem("gen_ai.response.finish_reasons")); - Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); - Assert.Equal(20, activity.GetTagItem("gen_ai.response.output_tokens")); - Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("gen_ai.testservice.response.system_fingerprint")); - Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("gen_ai.testservice.response.and_something_else")); + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("AndSomethingElse")); Assert.True(activity.Duration.TotalMilliseconds > 0); - var logs = collector.GetSnapshot(); + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); if (enableSensitiveData) { - Assert.Collection(logs, - log => Assert.Equal("""{"content":"You are a close friend."}""", log.Message), - log => Assert.Equal("""{"content":"Hey!"}""", log.Message), - log => Assert.Equal("""{"tool_calls":[{"id":"12345","type":"function","function":{"name":"GetPersonName"}}]}""", log.Message), - log => Assert.Equal("""{"id":"12345","content":"John"}""", log.Message), - log => Assert.Equal("""{"content":"Hey John, what\u0027s up?"}""", log.Message), - log => Assert.Equal("""{"content":"What\u0027s the biggest animal?"}""", log.Message), - log => Assert.Equal("""{"finish_reason":"stop","index":0,"message":{"content":"The blue whale, I think."}}""", log.Message)); + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "system", + "parts": [ + { + "type": "text", + "content": "You are a close friend." + } + ] + }, + { + "role": "user", + "name": "Alice", + "parts": [ + { + "type": "text", + "content": "Hey!" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "12345", + "name": "GetPersonName" + } + ] + }, + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": "12345", + "response": "John" + } + ] + }, + { + "role": "assistant", + "name": "BotAssistant", + "parts": [ + { + "type": "text", + "content": "Hey John, what's up?" + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "What's the biggest animal?" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.input.messages"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "The blue whale, I think." + } + ], + "finish_reason": "stop" + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "text", + "content": "You are helpful." + } + ] + """), ReplaceWhitespace(tags["gen_ai.system_instructions"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of a person by name.", + "parameters": { + "type": "object", + "properties": { + "personName": { + "type": "string" + } + }, + "required": [ + "personName" + ] + } + }, + { + "type": "web_search" + }, + { + "type": "file_search" + }, + { + "type": "code_interpreter" + }, + { + "type": "mcp" + }, + { + "type": "function", + "name": "GetCurrentWeather", + "description": "Gets the current weather for a location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ] + } + } + ] + """), ReplaceWhitespace(tags["gen_ai.tool.definitions"])); } else { - Assert.Collection(logs, - log => Assert.Equal("""{}""", log.Message), - log => Assert.Equal("""{}""", log.Message), - log => Assert.Equal("""{"tool_calls":[{"id":"12345","type":"function","function":{"name":"GetPersonName"}}]}""", log.Message), - log => Assert.Equal("""{"id":"12345"}""", log.Message), - log => Assert.Equal("""{}""", log.Message), - log => Assert.Equal("""{}""", log.Message), - log => Assert.Equal("""{"finish_reason":"stop","index":0,"message":{}}""", log.Message)); + Assert.False(tags.ContainsKey("gen_ai.input.messages")); + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + Assert.False(tags.ContainsKey("gen_ai.system_instructions")); + Assert.False(tags.ContainsKey("gen_ai.tool.definitions")); } + } - Assert.Collection(logs, - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.system.message"), ((IList>)log.State!)[0]), - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.user.message"), ((IList>)log.State!)[0]), - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.assistant.message"), ((IList>)log.State!)[0]), - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.tool.message"), ((IList>)log.State!)[0]), - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.assistant.message"), ((IList>)log.State!)[0]), - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.user.message"), ((IList>)log.State!)[0]), - log => Assert.Equal(new KeyValuePair("event.name", "gen_ai.choice"), ((IList>)log.State!)[0])); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AllOfficialOtelContentPartTypes_SerializedCorrectly(bool streaming) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); - Assert.All(logs, log => + using var innerClient = new TestChatClient { - Assert.Equal(new KeyValuePair("gen_ai.system", "testservice"), ((IList>)log.State!)[1]); - }); + GetResponseAsyncCallback = async (messages, options, cancellationToken) => + { + await Task.Yield(); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Assistant response text"), + new TextReasoningContent("This is reasoning"), + new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" }), + new FunctionResultContent("call-123", "72°F and sunny"), + new DataContent(Convert.FromBase64String("aGVsbG8gd29ybGQ="), "image/png"), + new UriContent(new Uri("https://example.com/image.jpg"), "image/jpeg"), + new HostedFileContent("file-abc123"), + ])); + }, + GetStreamingResponseAsyncCallback = CallbackAsync, + }; + + async static IAsyncEnumerable CallbackAsync( + IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return new(ChatRole.Assistant, "Assistant response text"); + yield return new() { Contents = [new TextReasoningContent("This is reasoning")] }; + yield return new() { Contents = [new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" })] }; + yield return new() { Contents = [new FunctionResultContent("call-123", "72°F and sunny")] }; + yield return new() { Contents = [new DataContent(Convert.FromBase64String("aGVsbG8gd29ybGQ="), "image/png")] }; + yield return new() { Contents = [new UriContent(new Uri("https://example.com/image.jpg"), "image/jpeg")] }; + yield return new() { Contents = [new HostedFileContent("file-abc123")] }; + } + + using var chatClient = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = true; + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Build(); + + List messages = + [ + new(ChatRole.User, + [ + new TextContent("User request text"), + new TextReasoningContent("User reasoning"), + new DataContent(Convert.FromBase64String("ZGF0YSBjb250ZW50"), "audio/mp3"), + new UriContent(new Uri("https://example.com/video.mp4"), "video/mp4"), + new HostedFileContent("file-xyz789"), + ]), + new(ChatRole.Assistant, [new FunctionCallContent("call-456", "SearchFiles")]), + new(ChatRole.Tool, [new FunctionResultContent("call-456", "Found 3 files")]), + ]; + + if (streaming) + { + await foreach (var update in chatClient.GetStreamingResponseAsync(messages)) + { + await Task.Yield(); + } + } + else + { + await chatClient.GetResponseAsync(messages); + } + + var activity = Assert.Single(activities); + Assert.NotNull(activity); + + var inputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.input.messages").Value; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "User request text" + }, + { + "type": "reasoning", + "content": "User reasoning" + }, + { + "type": "blob", + "content": "ZGF0YSBjb250ZW50", + "mime_type": "audio/mp3", + "modality": "audio" + }, + { + "type": "uri", + "uri": "https://example.com/video.mp4", + "mime_type": "video/mp4", + "modality": "video" + }, + { + "type": "file", + "file_id": "file-xyz789" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "call-456", + "name": "SearchFiles" + } + ] + }, + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": "call-456", + "response": "Found 3 files" + } + ] + } + ] + """), ReplaceWhitespace(inputMessages)); + + var outputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.output.messages").Value; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "Assistant response text" + }, + { + "type": "reasoning", + "content": "This is reasoning" + }, + { + "type": "tool_call", + "id": "call-123", + "name": "GetWeather", + "arguments": { + "location": "Seattle" + } + }, + { + "type": "tool_call_response", + "id": "call-123", + "response": "72°F and sunny" + }, + { + "type": "blob", + "content": "aGVsbG8gd29ybGQ=", + "mime_type": "image/png", + "modality": "image" + }, + { + "type": "uri", + "uri": "https://example.com/image.jpg", + "mime_type": "image/jpeg", + "modality": "image" + }, + { + "type": "file", + "file_id": "file-abc123" + } + ] + } + ] + """), ReplaceWhitespace(outputMessages)); + } + + [Fact] + public async Task UnknownContentTypes_Ignored() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (messages, options, cancellationToken) => + { + await Task.Yield(); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "The blue whale, I think.")); + }, + }; + + using var chatClient = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = true; + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Build(); + + List messages = + [ + new(ChatRole.User, + [ + new TextContent("Hello!"), + new NonSerializableAIContent(), + new TextContent("How are you?"), + ]), + ]; + + var response = await chatClient.GetResponseAsync(messages); + Assert.NotNull(response); + + var activity = Assert.Single(activities); + Assert.NotNull(activity); + + var inputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.input.messages").Value; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "Hello!" + }, + { + "type": "Microsoft.Extensions.AI.OpenTelemetryChatClientTests+NonSerializableAIContent", + "content": {} + }, + { + "type": "text", + "content": "How are you?" + } + ] + } + ] + """), ReplaceWhitespace(inputMessages)); } + + private sealed class NonSerializableAIContent : AIContent; + + private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs new file mode 100644 index 00000000000..82b00df03ff --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs @@ -0,0 +1,188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ReducingChatClientTests +{ + [Fact] + public void ReducingChatClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new ReducingChatClient(null!, new TestReducer())); + } + + [Fact] + public void UseChatReducer_InvalidArgs_Throws() + { + using var innerClient = new TestChatClient(); + var builder = innerClient.AsBuilder(); + Assert.Throws("builder", () => ReducingChatClientBuilderExtensions.UseChatReducer(null!, new TestReducer())); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetResponseAsync_CallsReducerBeforeInnerClient(bool streaming) + { + var originalMessages = new List + { + new(ChatRole.System, "You are a helpful assistant"), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!"), + new(ChatRole.User, "What's the weather?") + }; + + var reducedMessages = new List + { + new(ChatRole.System, "You are a helpful assistant"), + new(ChatRole.User, "What's the weather?") + }; + + var reducer = new TestReducer { ReducedMessages = reducedMessages }; + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "It's sunny!")); + var expectedUpdates = new[] { new ChatResponseUpdate(ChatRole.Assistant, "It's"), new ChatResponseUpdate(null, " sunny!") }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + // Verify that the inner client receives the reduced messages + Assert.Same(reducedMessages, messages); + return Task.FromResult(expectedResponse); + }, + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + // Verify that the inner client receives the reduced messages + Assert.Same(reducedMessages, messages); + return ToAsyncEnumerable(expectedUpdates); + } + }; + + using var client = new ReducingChatClient(innerClient, reducer); + + if (streaming) + { + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync(originalMessages)) + { + updates.Add(update); + } + + Assert.Equal(expectedUpdates.Length, updates.Count); + for (int i = 0; i < expectedUpdates.Length; i++) + { + Assert.Same(expectedUpdates[i], updates[i]); + } + } + else + { + var response = await client.GetResponseAsync(originalMessages); + Assert.Same(expectedResponse, response); + } + + Assert.Equal(1, reducer.ReduceAsyncCallCount); + Assert.Same(originalMessages, reducer.LastMessagesProvided); + } + + [Fact] + public async Task UseChatReducer_WithReducerFromServices() + { + var reducedMessages = new List { new(ChatRole.User, "Reduced message") }; + var reducer = new TestReducer { ReducedMessages = reducedMessages }; + + var services = new ServiceCollection(); + services.AddSingleton(reducer); + var serviceProvider = services.BuildServiceProvider(); + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Same(reducedMessages, messages); + return Task.FromResult(new ChatResponse()); + } + }; + + using var client = innerClient + .AsBuilder() + .UseChatReducer() // Should get reducer from services + .Build(serviceProvider); + + await client.GetResponseAsync(new List { new(ChatRole.User, "Original message") }); + Assert.Equal(1, reducer.ReduceAsyncCallCount); + } + + [Fact] + public void UseChatReducer_WithoutReducerParameterAndWithoutService_Throws() + { + using var innerClient = new TestChatClient(); + var services = new ServiceCollection().BuildServiceProvider(); + + var exception = Assert.Throws(() => + innerClient + .AsBuilder() + .UseChatReducer() // No reducer provided and not in services + .Build(services)); + + Assert.Contains("IChatReducer", exception.Message); + } + + [Fact] + public async Task UseChatReducer_WithConfigureCallback() + { + var reducer = new TestReducer(); + var configureCalled = false; + ReducingChatClient? configuredClient = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + Task.FromResult(new ChatResponse()) + }; + + using var client = innerClient + .AsBuilder() + .UseChatReducer(reducer, configure: chatClient => + { + configureCalled = true; + configuredClient = chatClient; + }) + .Build(); + + await client.GetResponseAsync([]); + + Assert.True(configureCalled); + Assert.NotNull(configuredClient); + Assert.IsType(configuredClient); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable items) + { + foreach (var item in items) + { + await Task.Yield(); + yield return item; + } + } + + private sealed class TestReducer : IChatReducer + { + public IEnumerable? ReducedMessages { get; set; } + public int ReduceAsyncCallCount { get; private set; } + public IEnumerable? LastMessagesProvided { get; private set; } + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + ReduceAsyncCallCount++; + LastMessagesProvided = messages; + return Task.FromResult(ReducedMessages ?? messages); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs new file mode 100644 index 00000000000..b2b74c711d7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class MessageCountingChatReducerTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidTargetCount(int targetCount) + { + Assert.Throws(nameof(targetCount), () => new MessageCountingChatReducer(targetCount)); + } + + [Fact] + public void Constructor_AcceptsValidTargetCount() + { + var reducer = new MessageCountingChatReducer(5); + Assert.NotNull(reducer); + } + + [Fact] + public async Task ReduceAsync_ThrowsOnNullMessages() + { + var reducer = new MessageCountingChatReducer(5); + await Assert.ThrowsAsync(() => reducer.ReduceAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task ReduceAsync_HandlesEmptyMessages() + { + var reducer = new MessageCountingChatReducer(5); + var result = await reducer.ReduceAsync([], CancellationToken.None); + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_PreservesFirstSystemMessage() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + new ChatMessage(ChatRole.User, "How are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("You are a helpful assistant.", m.Text); + }, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("How are you?", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("I'm doing well, thanks!", m.Text); + }); + } + + [Fact] + public async Task ReduceAsync_OnlyFirstSystemMessageIsPreserved() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.System, "First system message"), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, "Second system message"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, "How are you?"), + new ChatMessage(ChatRole.Assistant, "I'm fine!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("First system message", m.Text); + }, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("How are you?", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("I'm fine!", m.Text); + }); + + // Second system message should not be preserved separately + Assert.Equal(1, resultList.Count(m => m.Role == ChatRole.System)); + } + + [Fact] + public async Task ReduceAsync_IgnoresFunctionCallsAndResults() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the weather?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + new ChatMessage(ChatRole.Assistant, "The weather in Seattle is sunny and 72°F."), + new ChatMessage(ChatRole.User, "Thanks!"), + new ChatMessage(ChatRole.Assistant, "You're welcome!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("Thanks!", m.Text); + Assert.DoesNotContain(m.Contents, c => c is FunctionCallContent); + Assert.DoesNotContain(m.Contents, c => c is FunctionResultContent); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("You're welcome!", m.Text); + Assert.DoesNotContain(m.Contents, c => c is FunctionCallContent); + Assert.DoesNotContain(m.Contents, c => c is FunctionResultContent); + }); + } + + [Theory] + [InlineData(5, 3, 3)] // Less messages than target + [InlineData(5, 5, 5)] // Exactly at target + [InlineData(5, 8, 5)] // More messages than target + [InlineData(1, 10, 1)] // Only keep 1 message + public async Task ReduceAsync_RespectsTargetCount(int targetCount, int messageCount, int expectedCount) + { + var reducer = new MessageCountingChatReducer(targetCount); + + var messages = new List(); + for (int i = 0; i < messageCount; i++) + { + messages.Add(new ChatMessage(i % 2 == 0 ? ChatRole.User : ChatRole.Assistant, $"Message {i}")); + } + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Equal(expectedCount, resultList.Count); + + // Verify we kept the most recent messages + if (messageCount > targetCount) + { + var startIndex = messageCount - targetCount; + var expectedMessages = new Action[targetCount]; + for (int i = 0; i < targetCount; i++) + { + var expectedIndex = startIndex + i; + var expectedRole = expectedIndex % 2 == 0 ? ChatRole.User : ChatRole.Assistant; + expectedMessages[i] = m => + { + Assert.Equal(expectedRole, m.Role); + Assert.Equal($"Message {expectedIndex}", m.Text); + }; + } + + Assert.Collection(resultList, expectedMessages); + } + } + + [Fact] + public async Task ReduceAsync_HandlesOnlySystemMessage() + { + var reducer = new MessageCountingChatReducer(5); + + List messages = + [ + new ChatMessage(ChatRole.System, "System prompt"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("System prompt", m.Text); + }); + } + + [Fact] + public async Task ReduceAsync_HandlesOnlyFunctionMessages() + { + var reducer = new MessageCountingChatReducer(5); + + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "func", null)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "result")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", "func", null)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call2", "result")]), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_HandlesTargetCountOfOne() + { + var reducer = new MessageCountingChatReducer(1); + + List messages = + [ + new ChatMessage(ChatRole.System, "System"), + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Second"), + new ChatMessage(ChatRole.User, "Third"), + new ChatMessage(ChatRole.Assistant, "Fourth"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("System", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("Fourth", m.Text); + }); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs new file mode 100644 index 00000000000..8fa801c4811 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs @@ -0,0 +1,403 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable S103 // Lines should not be too long + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class SummarizingChatReducerTests +{ + [Fact] + public void Constructor_ThrowsOnNullChatClient() + { + Assert.Throws("chatClient", () => new SummarizingChatReducer(null!, targetCount: 5, threshold: 2)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidTargetCount(int targetCount) + { + using var chatClient = new TestChatClient(); + Assert.Throws(nameof(targetCount), () => new SummarizingChatReducer(chatClient, targetCount, threshold: 2)); + } + + [Theory] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidThresholdCount(int thresholdCount) + { + using var chatClient = new TestChatClient(); + Assert.Throws("threshold", () => new SummarizingChatReducer(chatClient, targetCount: 5, thresholdCount)); + } + + [Fact] + public async Task ReduceAsync_ThrowsOnNullMessages() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 5, threshold: 2); + await Assert.ThrowsAsync(() => reducer.ReduceAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task ReduceAsync_HandlesEmptyMessages() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 5, threshold: 2); + + var result = await reducer.ReduceAsync([], CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_PreservesSystemMessage() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + new ChatMessage(ChatRole.User, "How are you?"), + ]; + + chatClient.GetResponseAsyncCallback = (_, _, _) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of conversation"))); + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + var resultList = result.ToList(); + Assert.Equal(3, resultList.Count); // System + Summary + 1 unsummarized + Assert.Equal(ChatRole.System, resultList[0].Role); + Assert.Equal("You are a helpful assistant.", resultList[0].Text); + } + + [Fact] + public async Task ReduceAsync_PreservesCompleteToolCallSequence() + { + using var chatClient = new TestChatClient(); + + // Target 2 messages, but this would split a function call sequence + var reducer = new SummarizingChatReducer(chatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the time?"), + new ChatMessage(ChatRole.Assistant, "Let me check"), + new ChatMessage(ChatRole.User, "What's the weather?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather"), new TestUserInputRequestContent("uir1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]), + new ChatMessage(ChatRole.User, [new TestUserInputResponseContent("uir1")]), + new ChatMessage(ChatRole.Assistant, "It's sunny"), + ]; + + chatClient.GetResponseAsyncCallback = (msgs, _, _) => + { + Assert.DoesNotContain(msgs, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestUserInputRequestContent or TestUserInputResponseContent)); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Asked about time"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + // Should have: summary + function call + function result + user input response + last reply + Assert.Equal(5, resultList.Count); + + // Verify the complete sequence is preserved + Assert.Collection(resultList, + m => Assert.Contains("Asked about time", m.Text), + m => + { + Assert.Contains(m.Contents, c => c is FunctionCallContent); + Assert.Contains(m.Contents, c => c is TestUserInputRequestContent); + }, + m => Assert.Contains(m.Contents, c => c is FunctionResultContent), + m => Assert.Contains(m.Contents, c => c is TestUserInputResponseContent), + m => Assert.Contains("sunny", m.Text)); + } + + [Fact] + public async Task ReduceAsync_PreservesUserMessageWhenWithinThreshold() + { + using var chatClient = new TestChatClient(); + + // Target 3 messages with threshold of 2 + // This allows us to keep anywhere from 3 to 5 messages + var reducer = new SummarizingChatReducer(chatClient, targetCount: 3, threshold: 2); + + List messages = + [ + new ChatMessage(ChatRole.User, "First question"), + new ChatMessage(ChatRole.Assistant, "First answer"), + new ChatMessage(ChatRole.User, "Second question"), + new ChatMessage(ChatRole.Assistant, "Second answer"), + new ChatMessage(ChatRole.User, "Third question"), + new ChatMessage(ChatRole.Assistant, "Third answer"), + ]; + + chatClient.GetResponseAsyncCallback = (msgs, _, _) => + { + var msgList = msgs.ToList(); + + // Should summarize messages 0-1 (First question and answer) + // The reducer should find the User message at index 2 within the threshold + Assert.Equal(3, msgList.Count); // 2 messages to summarize + system prompt + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of first exchange"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + // Should have: summary + 4 kept messages (from "Second question" onward) + Assert.Equal(5, resultList.Count); + + // Verify the summary is first + Assert.Contains("Summary", resultList[0].Text); + + // Verify we kept the User message at index 2 and everything after + Assert.Collection(resultList.Skip(1), + m => Assert.Contains("Second question", m.Text), + m => Assert.Contains("Second answer", m.Text), + m => Assert.Contains("Third question", m.Text), + m => Assert.Contains("Third answer", m.Text)); + } + + [Fact] + public async Task ReduceAsync_ExcludesToolCallsFromSummarizedPortion() + { + using var chatClient = new TestChatClient(); + + // Target 3 messages - this will cause function calls in older messages to be summarized (excluded) + // while function calls in recent messages are kept + var reducer = new SummarizingChatReducer(chatClient, targetCount: 3, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the weather in Seattle?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" }), new TestUserInputRequestContent("uir2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + new ChatMessage(ChatRole.User, [new TestUserInputResponseContent("uir2")]), + new ChatMessage(ChatRole.Assistant, "It's sunny and 72°F in Seattle."), + new ChatMessage(ChatRole.User, "What about New York?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", "get_weather", new Dictionary { ["location"] = "New York" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call2", "Rainy, 65°F")]), + new ChatMessage(ChatRole.Assistant, "It's rainy and 65°F in New York."), + ]; + + chatClient.GetResponseAsyncCallback = (msgs, _, _) => + { + var msgList = msgs.ToList(); + + Assert.Equal(4, msgList.Count); // 3 non-function messages + system prompt + Assert.DoesNotContain(msgList, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestUserInputRequestContent or TestUserInputResponseContent)); + Assert.Contains(msgList, m => m.Text.Contains("What's the weather in Seattle?")); + Assert.Contains(msgList, m => m.Text.Contains("sunny and 72°F in Seattle")); + Assert.Contains(msgList, m => m.Text.Contains("What about New York?")); + Assert.Contains(msgList, m => m.Role == ChatRole.System); + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather in Seattle and New York."))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + // Should have: summary + 3 kept messages (the last 3 messages with function calls) + Assert.Equal(4, resultList.Count); + + Assert.Contains("User asked about weather", resultList[0].Text); + Assert.Contains(resultList, m => m.Contents.Any(c => c is FunctionCallContent fc && fc.CallId == "call2")); + Assert.Contains(resultList, m => m.Contents.Any(c => c is FunctionResultContent fr && fr.CallId == "call2")); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionCallContent fc && fc.CallId == "call1")); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionResultContent fr && fr.CallId == "call1")); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestUserInputRequestContent)); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestUserInputResponseContent)); + Assert.DoesNotContain(resultList, m => m.Text.Contains("sunny and 72°F in Seattle")); + } + + [Theory] + [InlineData(5, 0, 5, false)] // Exactly at target, no summarization + [InlineData(5, 0, 4, false)] // Below target, no summarization + [InlineData(5, 0, 6, true)] // Above target by 1, triggers summarization + [InlineData(5, 2, 7, false)] // At threshold boundary, no summarization + [InlineData(5, 2, 8, true)] // Above threshold, triggers summarization + public async Task ReduceAsync_RespectsTargetAndThresholdCounts(int targetCount, int thresholdCount, int messageCount, bool shouldSummarize) + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount, thresholdCount); + + var messages = new List(); + for (int i = 0; i < messageCount; i++) + { + messages.Add(new ChatMessage(ChatRole.Assistant, $"Message {i}")); + } + + var summarizationCalled = false; + chatClient.GetResponseAsyncCallback = (_, _, _) => + { + summarizationCalled = true; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Equal(shouldSummarize, summarizationCalled); + + if (shouldSummarize) + { + var resultList = result.ToList(); + Assert.Equal(targetCount + 1, resultList.Count); // Summary + target messages + Assert.StartsWith("Summary", resultList[0].Text, StringComparison.Ordinal); + } + else + { + Assert.Equal(messageCount, result.Count()); + } + } + + [Fact] + public async Task ReduceAsync_CancellationTokenIsRespected() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "Message 1"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Message 2"), + ]; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + chatClient.GetResponseAsyncCallback = (_, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + }; + + await Assert.ThrowsAsync(() => + reducer.ReduceAsync(messages, cts.Token)); + } + + [Fact] + public async Task ReduceAsync_OnlyFirstSystemMessageIsPreserved() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "First system message"), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, "Second system message"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, "How are you?"), + ]; + + chatClient.GetResponseAsyncCallback = (_, _, _) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + var resultList = result.ToList(); + Assert.Equal(ChatRole.System, resultList[0].Role); + Assert.Equal("First system message", resultList[0].Text); + + // Second system message should not be preserved separately + Assert.Equal(1, resultList.Count(m => m.Role == ChatRole.System)); + } + + [Fact] + public async Task CanHaveSummarizedConversation() + { + using var chatClientForSummarization = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClientForSummarization, targetCount: 2, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hi there! Can you tell me about golden retrievers?"), + new ChatMessage(ChatRole.Assistant, "Of course! Golden retrievers are known for their friendly and tolerant attitudes. They're great family pets and are very intelligent and easy to train."), + new ChatMessage(ChatRole.User, "What kind of exercise do they need?"), + new ChatMessage(ChatRole.Assistant, "Golden retrievers are quite active and need regular exercise. Daily walks, playtime, and activities like fetching or swimming are great for them."), + new ChatMessage(ChatRole.User, "Are they good with kids?"), + ]; + + chatClientForSummarization.GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(4, messages.Count()); // 3 messages to summarize + 1 system prompt + Assert.Collection(messages, + m => Assert.StartsWith("Hi there!", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Of course!", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("What kind of exercise", m.Text, StringComparison.Ordinal), + m => Assert.Equal(ChatRole.System, m.Role)); + const string Summary = """ + The user asked for information about golden retrievers. + The assistant explained that they have characteristics making them great family pets. + The user then asked what kind of exercise they need. + """; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Summary))); + }; + + var reducedMessages = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Equal(3, reducedMessages.Count()); // 1 summary + 2 unsummarized messages + Assert.Collection(reducedMessages, + m => Assert.StartsWith("The user asked for information", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers are quite", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids", m.Text, StringComparison.Ordinal)); + + messages.Add(new ChatMessage(ChatRole.Assistant, "Golden retrievers get along well with kids! They're able to be playful and energetic while remaining gentle.")); + messages.Add(new ChatMessage(ChatRole.User, "Do they make good lap dogs?")); + + chatClientForSummarization.GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(4, messages.Count()); // 1 summary message, 2 unsummarized message, 1 system prompt + Assert.Collection(messages, + m => Assert.StartsWith("The user asked", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers are quite active", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids", m.Text, StringComparison.Ordinal), + m => Assert.Equal(ChatRole.System, m.Role)); + const string Summary = """ + The user and assistant are discussing characteristics of golden retrievers. + The user asked what kind of exercise they need, and the assitant explained that golden retrievers + need frequent exercise. The user then asked about whether they're good around kids. + """; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Summary))); + }; + + reducedMessages = await reducer.ReduceAsync(messages, CancellationToken.None); + Assert.Equal(3, reducedMessages.Count()); // 1 summary + 2 unsummarized messages + Assert.Collection(reducedMessages, + m => Assert.StartsWith("The user and assistant are discussing", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers get along", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Do they make good lap dogs", m.Text, StringComparison.Ordinal)); + } + + private sealed class TestUserInputRequestContent : UserInputRequestContent + { + public TestUserInputRequestContent(string id) + : base(id) + { + } + } + + private sealed class TestUserInputResponseContent : UserInputResponseContent + { + public TestUserInputResponseContent(string id) + : base(id) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs index 6153ec8ab45..b14d3de83a9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/DistributedCachingEmbeddingGeneratorTest.cs @@ -21,6 +21,24 @@ public class DistributedCachingEmbeddingGeneratorTest AdditionalProperties = new() { ["a"] = "b" }, }; + [Fact] + public void Properties_Roundtrip() + { + using var innerGenerator = new TestEmbeddingGenerator(); + using DistributedCachingEmbeddingGenerator> generator = new(innerGenerator, _storage); + + Assert.Same(AIJsonUtilities.DefaultOptions, generator.JsonSerializerOptions); + var jso = new JsonSerializerOptions(); + generator.JsonSerializerOptions = jso; + Assert.Same(jso, generator.JsonSerializerOptions); + + Assert.Null(generator.CacheKeyAdditionalValues); + var additionalValues = new[] { "value1", "value2" }; + generator.CacheKeyAdditionalValues = additionalValues; + Assert.NotSame(additionalValues, generator.CacheKeyAdditionalValues); + Assert.Equal(additionalValues, generator.CacheKeyAdditionalValues); + } + [Fact] public async Task CachesSuccessResultsAsync() { @@ -271,6 +289,49 @@ public async Task CacheKeyVariesByEmbeddingOptionsAsync() AssertEmbeddingsEqual(new("value 2".Select(c => (float)c).ToArray()), result4); } + [Fact] + public async Task CacheKeyVariesByAdditionalKeyValuesAsync() + { + // Arrange + var innerCallCount = 0; + var completionTcs = new TaskCompletionSource(); + using var innerGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (value, options, cancellationToken) => + { + innerCallCount++; + await Task.Yield(); + return new(new Embedding[] { new Embedding(new float[] { innerCallCount }) }); + } + }; + using var outer = new DistributedCachingEmbeddingGenerator>(innerGenerator, _storage) + { + JsonSerializerOptions = TestJsonSerializerContext.Default.Options, + }; + + var result1 = await outer.GenerateAsync("abc"); + var result2 = await outer.GenerateAsync("abc"); + AssertEmbeddingsEqual(result1, result2); + + var result3 = await outer.GenerateAsync("abc"); + AssertEmbeddingsEqual(result1, result3); + + // Change key + outer.CacheKeyAdditionalValues = ["extraKey"]; + + var result4 = await outer.GenerateAsync("abc"); + Assert.NotEqual(result1.Vector.ToArray(), result4.Vector.ToArray()); + + var result5 = await outer.GenerateAsync("abc"); + AssertEmbeddingsEqual(result4, result5); + + // Remove key + outer.CacheKeyAdditionalValues = []; + + var result6 = await outer.GenerateAsync("abc"); + AssertEmbeddingsEqual(result1, result6); + } + [Fact] public async Task SubclassCanOverrideCacheKeyToVaryByOptionsAsync() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs index 25e01afb1df..b533cbc00c4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Runtime.CompilerServices; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; @@ -78,20 +77,20 @@ public async Task ExpectedInformationLogged_Async(string? perRequestModelId, boo Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); - Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal("localhost", activity.GetTagItem("server.address")); Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); Assert.Equal($"embeddings {expectedModelName}", activity.DisplayName); - Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); Assert.Equal(expectedModelName, activity.GetTagItem("gen_ai.request.model")); - Assert.Equal(1234, activity.GetTagItem("gen_ai.request.embedding.dimensions")); - Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("gen_ai.testservice.request.service_tier")); - Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("gen_ai.testservice.request.something_else")); + Assert.Equal(1234, activity.GetTagItem("gen_ai.embeddings.dimension.count")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); - Assert.Equal(10, activity.GetTagItem("gen_ai.response.input_tokens")); - Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("gen_ai.testservice.response.system_fingerprint")); - Assert.Equal(enableSensitiveData ? "value3" : null, activity.GetTagItem("gen_ai.testservice.response.and_something_else")); + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint")); + Assert.Equal(enableSensitiveData ? "value3" : null, activity.GetTagItem("AndSomethingElse")); Assert.True(activity.Duration.TotalMilliseconds > 0); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/EnumeratedOnceEnumerable.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/EnumeratedOnceEnumerable.cs new file mode 100644 index 00000000000..84e0ba9ab61 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/EnumeratedOnceEnumerable.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.AI; + +internal sealed class EnumeratedOnceEnumerable(IEnumerable items) : IEnumerable +{ + private int _iterated; + + public IEnumerator GetEnumerator() + { + if (Interlocked.Exchange(ref _iterated, 1) != 0) + { + throw new InvalidOperationException("This enumerable can only be enumerated once."); + } + + foreach (var item in items) + { + yield return item; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 6d448efb710..deea4cbcf13 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -13,6 +15,7 @@ using Xunit; #pragma warning disable IDE0004 // Remove Unnecessary Cast +#pragma warning disable S103 // Lines should not be too long #pragma warning disable S107 // Methods should not have too many parameters #pragma warning disable S2760 // Sequential tests should not check the same condition #pragma warning disable S3358 // Ternary operators should not be nested @@ -57,6 +60,51 @@ public async Task Parameters_DefaultValuesAreUsedButOverridable_Async() AssertExtensions.EqualFunctionCallResults("hello hello", await func.InvokeAsync(new() { ["a"] = "hello" })); } + [Fact] + public async Task Parameters_DefaultValueAttributeIsRespected_Async() + { + // Test with null default value + AIFunction funcNull = AIFunctionFactory.Create(([DefaultValue(null)] string? text) => text ?? "was null"); + + // Schema should not list 'text' as required and should have default value + string schema = funcNull.JsonSchema.ToString(); + Assert.Contains("\"text\"", schema); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":null", schema); + + // Should be invocable without providing the parameter + AssertExtensions.EqualFunctionCallResults("was null", await funcNull.InvokeAsync()); + + // Should be overridable + AssertExtensions.EqualFunctionCallResults("hello", await funcNull.InvokeAsync(new() { ["text"] = "hello" })); + + // Test with non-null default value + AIFunction funcValue = AIFunctionFactory.Create(([DefaultValue("default")] string text) => text); + schema = funcValue.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":\"default\"", schema); + + AssertExtensions.EqualFunctionCallResults("default", await funcValue.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults("custom", await funcValue.InvokeAsync(new() { ["text"] = "custom" })); + + // Test with int default value + AIFunction funcInt = AIFunctionFactory.Create(([DefaultValue(42)] int x) => x * 2); + schema = funcInt.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":42", schema); + + AssertExtensions.EqualFunctionCallResults(84, await funcInt.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults(10, await funcInt.InvokeAsync(new() { ["x"] = 5 })); + + // Test that DefaultValue attribute takes precedence over C# default value + AIFunction funcBoth = AIFunctionFactory.Create(([DefaultValue(100)] int y = 50) => y); + schema = funcBoth.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":100", schema); // DefaultValue should take precedence + + AssertExtensions.EqualFunctionCallResults(100, await funcBoth.InvokeAsync()); // Should use DefaultValue, not C# default + } + [Fact] public async Task Parameters_MissingRequiredParametersFail_Async() { @@ -75,6 +123,69 @@ public async Task Parameters_MissingRequiredParametersFail_Async() } } + [Fact] + public async Task Parameters_ToleratesJsonEncodedParameters() + { + AIFunction func = AIFunctionFactory.Create((int x, int y, int z, int w, int u) => x + y + z + w + u); + + var result = await func.InvokeAsync(new() + { + ["x"] = "1", + ["y"] = JsonNode.Parse("2"), + ["z"] = JsonDocument.Parse("3"), + ["w"] = JsonDocument.Parse("4").RootElement, + ["u"] = 5M, // boxed decimal cannot be cast to int, requires conversion + }); + + AssertExtensions.EqualFunctionCallResults(15, result); + } + + [Theory] + [InlineData(" null")] + [InlineData(" false ")] + [InlineData("true ")] + [InlineData("42")] + [InlineData("0.0")] + [InlineData("-1e15")] + [InlineData(" \"I am a string!\" ")] + [InlineData(" {}")] + [InlineData("[]")] + [InlineData("// single-line comment\r\nnull")] + [InlineData("/* multi-line\r\ncomment */\r\nnull")] + public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) + { + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { ReadCommentHandling = JsonCommentHandling.Skip }; + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param, serializerOptions: options); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam, new() { CommentHandling = JsonCommentHandling.Skip }).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = jsonStringParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + + [Theory] + [InlineData("")] + [InlineData(" \r\n")] + [InlineData("I am a string!")] + [InlineData("/* Code snippet */ int main(void) { return 0; }")] + [InlineData("let rec Y F x = F (Y F) x")] + [InlineData("+3")] + public async Task Parameters_ToleratesInvalidJsonStringParameters(string invalidJsonParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(JsonSerializer.Serialize(invalidJsonParam, JsonContext.Default.String)).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = invalidJsonParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + [Fact] public async Task Parameters_MappedByType_Async() { @@ -100,22 +211,27 @@ public async Task Returns_AsyncReturnTypesSupported_Async() AIFunction func; func = AIFunctionFactory.Create(Task (string a) => Task.FromResult(a + " " + a)); + Assert.Equal("""{"type":"string"}""", func.ReturnJsonSchema.ToString()); AssertExtensions.EqualFunctionCallResults("test test", await func.InvokeAsync(new() { ["a"] = "test" })); func = AIFunctionFactory.Create(ValueTask (string a, string b) => new ValueTask(b + " " + a)); + Assert.Equal("""{"type":"string"}""", func.ReturnJsonSchema.ToString()); AssertExtensions.EqualFunctionCallResults("hello world", await func.InvokeAsync(new() { ["b"] = "hello", ["a"] = "world" })); long result = 0; func = AIFunctionFactory.Create(async Task (int a, long b) => { result = a + b; await Task.Yield(); }); + Assert.Null(func.ReturnJsonSchema); AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync(new() { ["a"] = 1, ["b"] = 2L })); Assert.Equal(3, result); result = 0; func = AIFunctionFactory.Create(async ValueTask (int a, long b) => { result = a + b; await Task.Yield(); }); + Assert.Null(func.ReturnJsonSchema); AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync(new() { ["a"] = 1, ["b"] = 2L })); Assert.Equal(3, result); func = AIFunctionFactory.Create((int count) => SimpleIAsyncEnumerable(count), serializerOptions: JsonContext.Default.Options); + Assert.Equal("""{"type":"array","items":{"type":"integer"}}""", func.ReturnJsonSchema.ToString()); AssertExtensions.EqualFunctionCallResults(new int[] { 0, 1, 2, 3, 4 }, await func.InvokeAsync(new() { ["count"] = 5 }), JsonContext.Default.Options); static async IAsyncEnumerable SimpleIAsyncEnumerable(int count) @@ -165,6 +281,39 @@ public void Metadata_DerivedFromLambda() p => Assert.Equal("This is B", p.GetCustomAttribute()?.Description)); } + [Fact] + public void Metadata_DisplayNameAttribute() + { + // Test DisplayNameAttribute on a delegate method + Func funcWithDisplayName = [DisplayName("get_user_id")] () => "test"; + AIFunction func = AIFunctionFactory.Create(funcWithDisplayName); + Assert.Equal("get_user_id", func.Name); + Assert.Empty(func.Description); + + // Test DisplayNameAttribute with DescriptionAttribute + Func funcWithBoth = [DisplayName("my_function")][Description("A test function")] () => "test"; + func = AIFunctionFactory.Create(funcWithBoth); + Assert.Equal("my_function", func.Name); + Assert.Equal("A test function", func.Description); + + // Test that explicit name parameter takes precedence over DisplayNameAttribute + func = AIFunctionFactory.Create(funcWithDisplayName, name: "explicit_name"); + Assert.Equal("explicit_name", func.Name); + + // Test DisplayNameAttribute with options + func = AIFunctionFactory.Create(funcWithDisplayName, new AIFunctionFactoryOptions()); + Assert.Equal("get_user_id", func.Name); + + // Test that options.Name takes precedence over DisplayNameAttribute + func = AIFunctionFactory.Create(funcWithDisplayName, new AIFunctionFactoryOptions { Name = "options_name" }); + Assert.Equal("options_name", func.Name); + + // Test function without DisplayNameAttribute falls back to method name + Func funcWithoutDisplayName = () => "test"; + func = AIFunctionFactory.Create(funcWithoutDisplayName); + Assert.Contains("Metadata_DisplayNameAttribute", func.Name); // Will contain the lambda method name + } + [Fact] public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction() { @@ -205,6 +354,7 @@ public void AIFunctionFactoryOptions_DefaultValues() Assert.Null(options.SerializerOptions); Assert.Null(options.JsonSchemaCreateOptions); Assert.Null(options.ConfigureParameterBinding); + Assert.False(options.ExcludeResultSchema); } [Fact] @@ -220,6 +370,8 @@ public async Task AIFunctionFactoryOptions_SupportsSkippingParameters() Assert.DoesNotContain("firstParameter", func.JsonSchema.ToString()); Assert.Contains("secondParameter", func.JsonSchema.ToString()); + Assert.Equal("""{"type":"string"}""", func.ReturnJsonSchema.ToString()); + var result = (JsonElement?)await func.InvokeAsync(new() { ["firstParameter"] = "test", @@ -229,6 +381,21 @@ public async Task AIFunctionFactoryOptions_SupportsSkippingParameters() Assert.Contains("test42", result.ToString()); } + [Fact] + public void AIFunctionFactoryOptions_SupportsSkippingReturnSchema() + { + AIFunction func = AIFunctionFactory.Create( + (string firstParameter, int secondParameter) => firstParameter + secondParameter, + new() + { + ExcludeResultSchema = true, + }); + + Assert.Contains("firstParameter", func.JsonSchema.ToString()); + Assert.Contains("secondParameter", func.JsonSchema.ToString()); + Assert.Null(func.ReturnJsonSchema); + } + [Fact] public async Task AIFunctionArguments_SatisfiesParameters() { @@ -265,6 +432,8 @@ public async Task AIFunctionArguments_SatisfiesParameters() Assert.DoesNotContain("services", func.JsonSchema.ToString()); Assert.DoesNotContain("arguments", func.JsonSchema.ToString()); + Assert.Equal("""{"type":"integer"}""", func.ReturnJsonSchema.ToString()); + await Assert.ThrowsAsync("arguments.Services", () => func.InvokeAsync(arguments).AsTask()); arguments.Services = sp; @@ -430,6 +599,8 @@ public async Task FromKeyedServices_ResolvesFromServiceProvider() Assert.Contains("myInteger", f.JsonSchema.ToString()); Assert.DoesNotContain("service", f.JsonSchema.ToString()); + Assert.Equal("""{"type":"integer"}""", f.ReturnJsonSchema.ToString()); + Exception e = await Assert.ThrowsAsync("arguments.Services", () => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask()); var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp }); @@ -451,6 +622,8 @@ public async Task FromKeyedServices_NullKeysBindToNonKeyedServices() Assert.Contains("myInteger", f.JsonSchema.ToString()); Assert.DoesNotContain("service", f.JsonSchema.ToString()); + Assert.Equal("""{"type":"integer"}""", f.ReturnJsonSchema.ToString()); + Exception e = await Assert.ThrowsAsync("arguments.Services", () => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask()); var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp }); @@ -743,12 +916,70 @@ public async Task MarshalResult_TypeIsDeclaredTypeEvenWhenDerivedTypeReturned() Assert.Equal(cts.Token, cancellationToken); return "marshalResultInvoked"; }, + SerializerOptions = JsonContext.Default.Options, }); object? result = await f.InvokeAsync(new() { ["i"] = 42 }, cts.Token); Assert.Equal("marshalResultInvoked", result); } + [Fact] + public async Task AIContentReturnType_NotSerializedByDefault() + { + await ValidateAsync( + [ + AIFunctionFactory.Create(() => (AIContent)new TextContent("text")), + AIFunctionFactory.Create(async () => (AIContent)new TextContent("text")), + AIFunctionFactory.Create(async ValueTask () => (AIContent)new TextContent("text")), + AIFunctionFactory.Create(() => new TextContent("text")), + AIFunctionFactory.Create(async () => new TextContent("text")), + AIFunctionFactory.Create(async ValueTask () => new TextContent("text")), + ]); + + await ValidateAsync( + [ + AIFunctionFactory.Create(() => new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")), + AIFunctionFactory.Create(async () => new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")), + AIFunctionFactory.Create(async ValueTask () => new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")), + ]); + + await ValidateAsync>( + [ + AIFunctionFactory.Create(() => (IEnumerable)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (IEnumerable)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask> () => (IEnumerable)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + await ValidateAsync( + [ + AIFunctionFactory.Create(() => (AIContent[])[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (AIContent[])[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask () => (AIContent[])[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + await ValidateAsync>( + [ + AIFunctionFactory.Create(() => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask> () => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + await ValidateAsync>( + [ + AIFunctionFactory.Create(() => (IList)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (IList)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask> () => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + static async Task ValidateAsync(IEnumerable functions) + { + foreach (var f in functions) + { + Assert.IsAssignableFrom(await f.InvokeAsync()); + } + } + } + [Fact] public async Task AIFunctionFactory_DefaultDefaultParameter() { @@ -760,6 +991,102 @@ public async Task AIFunctionFactory_DefaultDefaultParameter() Assert.Contains("00000000-0000-0000-0000-000000000000,0", result?.ToString()); } + [Fact] + public async Task AIFunctionFactory_NullableParameters() + { + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: JsonContext.Default.Options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + + [Fact] + public async Task AIFunctionFactory_NullableParameters_AllowReadingFromString() + { + JsonSerializerOptions options = new(JsonContext.Default.Options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + + [Fact] + public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() + { + AIFunction f = AIFunctionFactory.Create(Add, serializerOptions: JsonContext.Default.Options); + + Assert.Equal("""{"description":"The summed result","type":"integer"}""", f.ReturnJsonSchema.ToString()); + + [return: Description("The summed result")] + static int Add(int a, int b) => a + b; + } + + [Fact] + public void CreateDeclaration_Roundtrips() + { + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(int), serializerOptions: AIJsonUtilities.DefaultOptions); + + AIFunctionDeclaration f = AIFunctionFactory.CreateDeclaration("something", "amazing", schema); + Assert.Equal("something", f.Name); + Assert.Equal("amazing", f.Description); + Assert.Equal("""{"type":"integer"}""", f.JsonSchema.ToString()); + Assert.Null(f.ReturnJsonSchema); + + f = AIFunctionFactory.CreateDeclaration("other", null, default, schema); + Assert.Equal("other", f.Name); + Assert.Empty(f.Description); + Assert.Equal(default, f.JsonSchema); + Assert.Equal("""{"type":"integer"}""", f.ReturnJsonSchema.ToString()); + + Assert.Throws("name", () => AIFunctionFactory.CreateDeclaration(null!, "description", default)); + } + private sealed class MyService(int value) { public int Value => value; @@ -814,6 +1141,8 @@ private sealed class MyFunctionTypeWithOneArg(MyArgumentType arg) private sealed class MyArgumentType; + private static int TestStaticMethod(int a, int b) => a + b; + private class A; private class B : A; private sealed class C : B; @@ -848,10 +1177,150 @@ private static AIFunctionFactoryOptions CreateKeyedServicesSupportOptions() => }, }; + [Fact] + public void LocalFunction_NameCleanup() + { + static void DoSomething() + { + // Empty local function for testing name cleanup + } + + var tool = AIFunctionFactory.Create(DoSomething); + + // The name should start with: ContainingMethodName_LocalFunctionName (followed by ordinal) + Assert.StartsWith("LocalFunction_NameCleanup_DoSomething_", tool.Name); + } + + [Fact] + public void LocalFunction_MultipleInSameMethod() + { + static void FirstLocal() + { + // Empty local function for testing name cleanup + } + + static void SecondLocal() + { + // Empty local function for testing name cleanup + } + + var tool1 = AIFunctionFactory.Create(FirstLocal); + var tool2 = AIFunctionFactory.Create(SecondLocal); + + // Each should have unique names based on the local function name (including ordinal) + Assert.StartsWith("LocalFunction_MultipleInSameMethod_FirstLocal_", tool1.Name); + Assert.StartsWith("LocalFunction_MultipleInSameMethod_SecondLocal_", tool2.Name); + Assert.NotEqual(tool1.Name, tool2.Name); + } + + [Fact] + public void Lambda_NameCleanup() + { + Action lambda = () => + { + // Empty lambda for testing name cleanup + }; + + var tool = AIFunctionFactory.Create(lambda); + + // The name should be the containing method name with ordinal for uniqueness + Assert.StartsWith("Lambda_NameCleanup", tool.Name); + } + + [Fact] + public void Lambda_MultipleInSameMethod() + { + Action lambda1 = () => + { + // Empty lambda for testing name cleanup + }; + + Action lambda2 = () => + { + // Empty lambda for testing name cleanup + }; + + var tool1 = AIFunctionFactory.Create(lambda1); + var tool2 = AIFunctionFactory.Create(lambda2); + + // Each lambda should have a unique name based on its ordinal + // to allow the LLM to distinguish between them + Assert.StartsWith("Lambda_MultipleInSameMethod", tool1.Name); + Assert.StartsWith("Lambda_MultipleInSameMethod", tool2.Name); + Assert.NotEqual(tool1.Name, tool2.Name); + } + + [Fact] + public void LocalFunction_WithParameters() + { + static int Add(int firstNumber, int secondNumber) => firstNumber + secondNumber; + + var tool = AIFunctionFactory.Create(Add); + + Assert.StartsWith("LocalFunction_WithParameters_Add_", tool.Name); + Assert.Contains("firstNumber", tool.JsonSchema.ToString()); + Assert.Contains("secondNumber", tool.JsonSchema.ToString()); + } + + [Fact] + public async Task LocalFunction_AsyncFunction() + { + static async Task FetchDataAsync() + { + await Task.Yield(); + return "data"; + } + + var tool = AIFunctionFactory.Create(FetchDataAsync); + + // Should strip "Async" suffix and include ordinal + Assert.StartsWith("LocalFunction_AsyncFunction_FetchData_", tool.Name); + + var result = await tool.InvokeAsync(); + AssertExtensions.EqualFunctionCallResults("data", result); + } + + [Fact] + public void LocalFunction_ExplicitNameOverride() + { + static void DoSomething() + { + // Empty local function for testing name cleanup + } + + var tool = AIFunctionFactory.Create(DoSomething, name: "CustomName"); + + Assert.Equal("CustomName", tool.Name); + } + + [Fact] + public void LocalFunction_InsideTestMethod() + { + // Even local functions defined in test methods get cleaned up + var tool = AIFunctionFactory.Create(Add, serializerOptions: JsonContext.Default.Options); + + Assert.StartsWith("LocalFunction_InsideTestMethod_Add_", tool.Name); + + [return: Description("The summed result")] + static int Add(int a, int b) => a + b; + } + + [Fact] + public void RegularStaticMethod_NameUnchanged() + { + // Test that actual static methods (not local functions) have names unchanged + var tool = AIFunctionFactory.Create(TestStaticMethod, null, serializerOptions: JsonContext.Default.Options); + + Assert.Equal("TestStaticMethod", tool.Name); + } + [JsonSerializable(typeof(IAsyncEnumerable))] [JsonSerializable(typeof(int[]))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(StructWithDefaultCtor))] + [JsonSerializable(typeof(B))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(DateTime?))] private partial class JsonContext : JsonSerializerContext; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs new file mode 100644 index 00000000000..ba37cad1b54 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsImageGeneratorTests +{ + [Fact] + public void ConfigureOptionsImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new ConfigureOptionsImageGenerator(null!, _ => { })); + Assert.Throws("configure", () => new ConfigureOptionsImageGenerator(new TestImageGenerator(), null!)); + } + + [Fact] + public void ConfigureOptions_InvalidArgs_Throws() + { + using var innerGenerator = new TestImageGenerator(); + var builder = innerGenerator.AsBuilder(); + Assert.Throws("configure", () => builder.ConfigureOptions(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextGenerator(bool nullProvidedOptions) + { + ImageGenerationOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" }; + ImageGenerationOptions? returnedOptions = null; + ImageGenerationResponse expectedResponse = new([]); + using CancellationTokenSource cts = new(); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (prompt, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + }, + + }; + + using var generator = innerGenerator + .AsBuilder() + .ConfigureOptions(options => + { + Assert.NotSame(providedOptions, options); + if (nullProvidedOptions) + { + Assert.Null(options.ModelId); + } + else + { + Assert.Equal(providedOptions!.ModelId, options.ModelId); + } + + returnedOptions = options; + }) + .Build(); + + var response1 = await generator.GenerateImagesAsync("test prompt", providedOptions, cts.Token); + Assert.Same(expectedResponse, response1); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorBuilderTests.cs new file mode 100644 index 00000000000..c0cfdc3ea06 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorBuilderTests.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorBuilderTests +{ + [Fact] + public void PassesServiceProviderToFactories() + { + var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); + using TestImageGenerator expectedInnerGenerator = new(); + using TestImageGenerator expectedOuterGenerator = new(); + + var builder = new ImageGeneratorBuilder(services => + { + Assert.Same(expectedServiceProvider, services); + return expectedInnerGenerator; + }); + + builder.Use((innerGenerator, serviceProvider) => + { + Assert.Same(expectedServiceProvider, serviceProvider); + Assert.Same(expectedInnerGenerator, innerGenerator); + return expectedOuterGenerator; + }); + + Assert.Same(expectedOuterGenerator, builder.Build(expectedServiceProvider)); + } + + [Fact] + public void BuildsPipelineInOrderAdded() + { + // Arrange + using TestImageGenerator expectedInnerGenerator = new(); + var builder = new ImageGeneratorBuilder(expectedInnerGenerator); + + builder.Use(next => new InnerGeneratorCapturingImageGenerator("First", next)); + builder.Use(next => new InnerGeneratorCapturingImageGenerator("Second", next)); + builder.Use(next => new InnerGeneratorCapturingImageGenerator("Third", next)); + + // Act + var first = (InnerGeneratorCapturingImageGenerator)builder.Build(); + + // Assert + Assert.Equal("First", first.Name); + var second = (InnerGeneratorCapturingImageGenerator)first.InnerGenerator; + Assert.Equal("Second", second.Name); + var third = (InnerGeneratorCapturingImageGenerator)second.InnerGenerator; + Assert.Equal("Third", third.Name); + Assert.Same(expectedInnerGenerator, third.InnerGenerator); + } + + [Fact] + public void DoesNotAcceptNullInnerService() + { + Assert.Throws("innerGenerator", () => new ImageGeneratorBuilder((IImageGenerator)null!)); + Assert.Throws("innerGenerator", () => ((IImageGenerator)null!).AsBuilder()); + } + + [Fact] + public void DoesNotAcceptNullFactories() + { + Assert.Throws("innerGeneratorFactory", () => new ImageGeneratorBuilder((Func)null!)); + } + + [Fact] + public void DoesNotAllowFactoriesToReturnNull() + { + using var innerGenerator = new TestImageGenerator(); + ImageGeneratorBuilder builder = new(innerGenerator); + builder.Use(_ => null!); + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("entry at index 0", ex.Message); + } + + [Fact] + public void UsesEmptyServiceProviderWhenNoServicesProvided() + { + using var innerGenerator = new TestImageGenerator(); + ImageGeneratorBuilder builder = new(innerGenerator); + builder.Use((innerGenerator, serviceProvider) => + { + Assert.Null(serviceProvider.GetService(typeof(object))); + + var keyedServiceProvider = Assert.IsAssignableFrom(serviceProvider); + Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key")); + Assert.Throws(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key")); + + return innerGenerator; + }); + builder.Build(); + } + + private sealed class InnerGeneratorCapturingImageGenerator(string name, IImageGenerator innerGenerator) : DelegatingImageGenerator(innerGenerator) + { +#pragma warning disable S3604 // False positive: Member initializer values should not be redundant + public string Name { get; } = name; +#pragma warning restore S3604 + public new IImageGenerator InnerGenerator => base.InnerGenerator; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs new file mode 100644 index 00000000000..a17cd5a5c41 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorDependencyInjectionPatterns +{ + private IServiceCollection ServiceCollection { get; } = new ServiceCollection(); + + [Fact] + public void CanRegisterSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddImageGenerator(services => new TestImageGenerator { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestImageGenerator(); + ServiceCollection.AddImageGenerator(singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddKeyedImageGenerator("mykey", services => new TestImageGenerator { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestImageGenerator(); + ServiceCollection.AddKeyedImageGenerator("mykey", singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddImageGenerator_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + ImageGeneratorBuilder builder = lifetime.HasValue + ? sc.AddImageGenerator(services => new TestImageGenerator(), lifetime.Value) + : sc.AddImageGenerator(services => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddKeyedImageGenerator_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + ImageGeneratorBuilder builder = lifetime.HasValue + ? sc.AddKeyedImageGenerator("key", services => new TestImageGenerator(), lifetime.Value) + : sc.AddKeyedImageGenerator("key", services => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.True(sd.IsKeyedService); + Assert.Equal("key", sd.ServiceKey); + Assert.Null(sd.KeyedImplementationInstance); + Assert.NotNull(sd.KeyedImplementationFactory); + Assert.IsType(sd.KeyedImplementationFactory(null!, null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Fact] + public void AddKeyedImageGenerator_WorksWithNullServiceKey() + { + ServiceCollection sc = new(); + sc.AddKeyedImageGenerator(null, _ => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ServiceKey); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(ServiceLifetime.Singleton, sd.Lifetime); + } + + public class SingletonMiddleware(IImageGenerator inner, IServiceProvider services) : DelegatingImageGenerator(inner) + { + public new IImageGenerator InnerGenerator => base.InnerGenerator; + public IServiceProvider Services => services; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs new file mode 100644 index 00000000000..6f3f0a43092 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingImageGeneratorTests +{ + [Fact] + public void LoggingImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new LoggingImageGenerator(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingImageGenerator(new TestImageGenerator(), null!)); + } + + [Fact] + public void UseLogging_AvoidsInjectingNopGenerator() + { + using var innerGenerator = new TestImageGenerator(); + + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(LoggingImageGenerator))); + Assert.Same(innerGenerator, innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(IImageGenerator))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(factory).Build().GetService(typeof(LoggingImageGenerator))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging().Build(services).GetService(typeof(LoggingImageGenerator))); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(null).Build(services).GetService(typeof(LoggingImageGenerator))); + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services).GetService(typeof(LoggingImageGenerator))); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GenerateImagesAsync_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + return Task.FromResult(new ImageGenerationResponse()); + }, + }; + + using IImageGenerator generator = innerGenerator + .AsBuilder() + .UseLogging() + .Build(services); + + await generator.GenerateAsync( + new ImageGenerationRequest("A beautiful sunset"), + new ImageGenerationOptions { ModelId = "dall-e-3" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.True( + entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked:") && + entry.Message.Contains("A beautiful sunset") && + entry.Message.Contains("dall-e-3")), + entry => Assert.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed:", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked.") && !entry.Message.Contains("A beautiful sunset")), + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed.") && !entry.Message.Contains("dall-e-3"))); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GenerateImagesAsync_WithOriginalImages_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + using IImageGenerator generator = innerGenerator + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + + AIContent[] originalImages = [new DataContent((byte[])[1, 2, 3, 4], "image/png")]; + await generator.GenerateAsync( + new ImageGenerationRequest("Make it more colorful", originalImages), + new ImageGenerationOptions { ModelId = "dall-e-3" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.True( + entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked:") && + entry.Message.Contains("Make it more colorful") && + entry.Message.Contains("dall-e-3")), + entry => Assert.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked.") && !entry.Message.Contains("Make it more colorful")), + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed.") && !entry.Message.Contains("dall-e-3"))); + } + else + { + Assert.Empty(logs); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs new file mode 100644 index 00000000000..30fc4ae4849 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetryImageGeneratorTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new OpenTelemetryImageGenerator(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExpectedInformationLogged_Async(bool enableSensitiveData) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = async (request, options, cancellationToken) => + { + await Task.Yield(); + + return new() + { + Contents = + [ + new UriContent("http://example/output.png", "image/png"), + new DataContent(new byte[] { 1, 2, 3, 4 }, "image/png") { Name = "moreOutput.png" }, + ], + + Usage = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }, + }; + }, + + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(ImageGeneratorMetadata) ? new ImageGeneratorMetadata("testservice", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, + }; + + using var g = innerGenerator + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = enableSensitiveData; + }) + .Build(); + + ImageGenerationRequest request = new() + { + Prompt = "This is the input prompt.", + OriginalImages = [new UriContent("http://example/input.png", "image/png")], + }; + + ImageGenerationOptions options = new() + { + Count = 2, + ImageSize = new(1024, 768), + MediaType = "image/jpeg", + ModelId = "mycoolimagemodel", + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + }; + + await g.GenerateAsync(request, options); + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("generate_content mycoolimagemodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); + + Assert.Equal("mycoolimagemodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(2, activity.GetTagItem("gen_ai.request.choice.count")); + Assert.Equal(1024, activity.GetTagItem("gen_ai.request.image.width")); + Assert.Equal(768, activity.GetTagItem("gen_ai.request.image.height")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); + + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (enableSensitiveData) + { + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "This is the input prompt." + }, + { + "type": "uri", + "uri": "http://example/input.png", + "mime_type": "image/png", + "modality": "image" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.input.messages"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "uri", + "uri": "http://example/output.png", + "mime_type": "image/png", + "modality": "image" + }, + { + "type": "blob", + "content": "AQIDBA==", + "mime_type": "image/png", + "modality": "image" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.input.messages")); + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + } + + static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs new file mode 100644 index 00000000000..498b4738962 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public static class SingletonImageGeneratorExtensions +{ + public static ImageGeneratorBuilder UseSingletonMiddleware(this ImageGeneratorBuilder builder) + => builder.Use((inner, services) + => new ImageGeneratorDependencyInjectionPatterns.SingletonMiddleware(inner, services)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index e4f17abb179..d06b423a504 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -5,7 +5,7 @@ - $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 + $(NoWarn);CA1063;CA1861;S104;SA1130;VSTHRD003 $(NoWarn);MEAI001 true @@ -22,6 +22,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs new file mode 100644 index 00000000000..c243bf2bf12 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetrySpeechToTextClientTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new OpenTelemetrySpeechToTextClient(null!)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExpectedInformationLogged_Async(bool streaming, bool enableSensitiveData) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerClient = new TestSpeechToTextClient + { + GetTextAsyncCallback = async (request, options, cancellationToken) => + { + await Task.Yield(); + return new("This is the recognized text.") + { + Usage = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }, + }; + }, + + GetStreamingTextAsyncCallback = TestClientStreamAsync, + + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(SpeechToTextClientMetadata) ? new SpeechToTextClientMetadata("testservice", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, + }; + + static async IAsyncEnumerable TestClientStreamAsync( + Stream request, SpeechToTextOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return new("This is"); + yield return new(" the recognized"); + yield return new() + { + Contents = + [ + new TextContent(" text."), + new UsageContent(new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }), + ] + }; + } + + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = enableSensitiveData; + }) + .Build(); + + SpeechToTextOptions options = new() + { + ModelId = "mycoolspeechmodel", + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + }; + + var response = streaming ? + await client.GetStreamingTextAsync(Stream.Null, options).ToSpeechToTextResponseAsync() : + await client.GetTextAsync(Stream.Null, options); + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("generate_content mycoolspeechmodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); + + Assert.Equal("mycoolspeechmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); + + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (enableSensitiveData) + { + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "This is the recognized text." + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + } + + static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs index 07596a1bb6f..5595e1c82ce 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs @@ -154,6 +154,22 @@ public void AddKeyedSpeechToTextClient_RegistersExpectedLifetime(ServiceLifetime Assert.Equal(expectedLifetime, sd.Lifetime); } + [Fact] + public void AddKeyedSpeechToTextClient_WorksWithNullServiceKey() + { + ServiceCollection sc = new(); + sc.AddKeyedSpeechToTextClient(null, _ => new TestSpeechToTextClient()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(ISpeechToTextClient), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ServiceKey); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(ServiceLifetime.Singleton, sd.Lifetime); + } + public class SingletonMiddleware(ISpeechToTextClient inner, IServiceProvider services) : DelegatingSpeechToTextClient(inner) { public new ISpeechToTextClient InnerClient => base.InnerClient; diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs index d66842b018b..ace2a6148e6 100644 --- a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs @@ -39,12 +39,28 @@ await RunAsync( }, sectionName); + [Theory] + [InlineData("ambientmetadata:application")] + [InlineData(null)] + public async Task UseApplicationMetadata_HostApplicationBuilder_CreatesPopulatesAndRegistersOptions(string? sectionName) => + await RunAsync_HostBuilder( + (options, hostEnvironment) => + { + options.BuildVersion.Should().Be(_metadata.BuildVersion); + options.DeploymentRing.Should().Be(_metadata.DeploymentRing); + options.ApplicationName.Should().Be(_metadata.ApplicationName); + options.EnvironmentName.Should().Be(hostEnvironment.EnvironmentName); + + return Task.CompletedTask; + }, + sectionName); + private static async Task RunAsync(Func func, string? sectionName) { using var host = await FakeHost.CreateBuilder() // need to set applicationName manually, because - // netfx console test runner cannot get assebly name + // netfx console test runner cannot get assembly name // to be able to set it automatically // see https://source.dot.net/#Microsoft.Extensions.Hosting/HostBuilder.cs,240 .ConfigureHostConfiguration("applicationname", _metadata.ApplicationName) @@ -60,4 +76,31 @@ await func(host.Services.GetRequiredService>().Val host.Services.GetRequiredService()); await host.StopAsync(); } + + private static async Task RunAsync_HostBuilder(Func func, string? sectionName) + { + var builder = Host.CreateEmptyApplicationBuilder(new() + { + ApplicationName = _metadata.ApplicationName + }); + + // need to set applicationName manually, because + // netfx console test runner cannot get assembly name + // to be able to set it automatically + // see https://source.dot.net/#Microsoft.Extensions.Hosting/HostBuilder.cs,240 + builder + .UseApplicationMetadata(sectionName ?? "ambientmetadata:application") + .Services.AddApplicationMetadata(metadata => + { + metadata.BuildVersion = _metadata.BuildVersion; + metadata.DeploymentRing = _metadata.DeploymentRing; + }); + + using var host = builder.Build(); + await host.StartAsync(); + + await func(host.Services.GetRequiredService>().Value, + host.Services.GetRequiredService()); + await host.StopAsync(); + } } diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs index 5091bd87e21..7fbc66a1e73 100644 --- a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs @@ -40,10 +40,20 @@ public void ApplicationMetadataExtensions_GivenAnyNullArgument_Throws() Assert.Throws(() => serviceCollection.AddApplicationMetadata((Action)null!)); Assert.Throws(() => serviceCollection.AddApplicationMetadata((IConfigurationSection)null!)); Assert.Throws(() => ((IHostBuilder)null!).UseApplicationMetadata(_fixture.Create())); + Assert.Throws(() => ((IHostApplicationBuilder)null!).UseApplicationMetadata(_fixture.Create())); Assert.Throws(() => new ConfigurationBuilder().AddApplicationMetadata(null!)); Assert.Throws(() => ((IConfigurationBuilder)null!).AddApplicationMetadata(null!)); } + [Fact] + public void ApplicationMetadataExtensions_GivenEmptyAction_DoesNotThrow() + { + var serviceCollection = new ServiceCollection(); + var config = new ConfigurationBuilder().Build(); + + Assert.Null(Record.Exception(() => serviceCollection.AddApplicationMetadata(_ => { }))); + } + [Theory] [InlineData(null)] [InlineData("")] @@ -66,6 +76,17 @@ public void UseApplicationMetadata_InvalidSectionName_Throws(string? sectionName act.Should().Throw(); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void UseApplicationMetadata_HostApplicationBuilder_InvalidSectionName_Throws(string? sectionName) + { + var act = () => Host.CreateEmptyApplicationBuilder(new()).UseApplicationMetadata(sectionName!); + act.Should().Throw(); + } + [Fact] public void AddApplicationMetadata_BuildsConfig() { diff --git a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs index 1ad60e9ad4f..0fcdd415fd1 100644 --- a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs +++ b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs @@ -7,6 +7,7 @@ using Xunit; namespace Microsoft.Extensions.AsyncState.Test; + public class AsyncContextTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs index 06a1c30e5b2..ef0a5023cbe 100644 --- a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs +++ b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace Microsoft.Extensions.AsyncState.Test; + public class AsyncStateTokenTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs index 909389acb95..5abd3e3d7cd 100644 --- a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs +++ b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs @@ -6,6 +6,7 @@ using Xunit; namespace Microsoft.Extensions.AsyncState.Test; + public class FeaturesPooledPolicyTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json deleted file mode 100644 index 374114fb1db..00000000000 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "no_entry_options": { - "MaximumKeyLength": 937 - }, - "with_entry_options": { - "MaximumKeyLength": 937, - "DefaultEntryOptions": { - "LocalCacheExpiration": "00:02:00", - "Flags": "DisableCompression,DisableLocalCacheRead" - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs index c33bcb50e98..562ba8ae98f 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs @@ -10,6 +10,7 @@ using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class ExpirationTests(ITestOutputHelper log) { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs index 7b8396eb50d..730013dbe4f 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class FunctionalTests : IClassFixture { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs index 5c9cc2a41c5..f5be5b5277d 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs @@ -11,6 +11,7 @@ using Xunit.Abstractions; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class L2Tests(ITestOutputHelper log) : IClassFixture { private static string CreateString(bool work = false) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs index 310f7d5cdce..6efc4b14d45 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs @@ -9,6 +9,7 @@ using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class LocalInvalidationTests(ITestOutputHelper log) : IClassFixture { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index fb8863cf776..3cd6a56dca5 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -23,10 +23,4 @@ - - - PreserveNewest - - - diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs index 1f125336dae..a4a9c470551 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs @@ -12,6 +12,7 @@ using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class PayloadTests(ITestOutputHelper log) : IClassFixture { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs index d66b325e802..384ace947c7 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs @@ -6,16 +6,17 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.SqlServer; +using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; #if NET9_0_OR_GREATER using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Json; #endif -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously #pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). namespace Microsoft.Extensions.Caching.Hybrid.Tests; @@ -51,12 +52,228 @@ public void CanCreateServiceWithManualOptions() Assert.Null(defaults.LocalCacheExpiration); // wasn't specified } + [Fact] + public void CanCreateServiceWithKeyedDistributedCache() + { + var services = new ServiceCollection(); + services.TryAddKeyedSingleton(typeof(CustomMemoryDistributedCache1)); + services.AddHybridCache(options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybrid = Assert.IsType(provider.GetRequiredService()); + var hybridOptions = hybrid.Options; + + var backend = Assert.IsType(hybrid.BackendCache); + Assert.Same(typeof(CustomMemoryDistributedCache1), hybridOptions.DistributedCacheServiceKey); + Assert.Same(backend, provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + } + + [Fact] + public void ThrowsWhenDistributedCacheKeyNotRegistered() + { + var services = new ServiceCollection(); + services.AddHybridCache(options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws(provider.GetRequiredService); + } + + [Fact] + public void ThrowsWhenRegisteredDistributedCacheIsNotKeyed() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddHybridCache(options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws(provider.GetRequiredService); + } + + [Fact] + public void CanCreateKeyedHybridCacheServiceWithNullKey() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache(null); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Resolves using null key registration + Assert.IsType(provider.GetRequiredKeyedService(null)); + + // Resolves as the non-keyed registration + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void CanCreateKeyedServicesWithStringKeys() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache("one"); + services.AddKeyedHybridCache("two"); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredKeyedService("one")); + Assert.IsType(provider.GetRequiredKeyedService("two")); + Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void CanCreateKeyedServicesWithStringKeysAndSetupActions() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache("one", options => options.MaximumKeyLength = 1); + services.AddKeyedHybridCache("two", options => options.MaximumKeyLength = 2); + services.AddKeyedHybridCache(null, options => options.MaximumKeyLength = 3); + using ServiceProvider provider = services.BuildServiceProvider(); + + var one = Assert.IsType(provider.GetRequiredKeyedService("one")); + Assert.Equal(1, one.Options.MaximumKeyLength); + + var two = Assert.IsType(provider.GetRequiredKeyedService("two")); + Assert.Equal(2, two.Options.MaximumKeyLength); + + var threeKeyed = Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.Equal(3, threeKeyed.Options.MaximumKeyLength); + + var threeUnkeyed = Assert.IsType(provider.GetRequiredService()); + Assert.Equal(3, threeUnkeyed.Options.MaximumKeyLength); + } + + [Fact] + public void CanCreateKeyedServicesWithTypeKeys() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache(typeof(string)); + services.AddKeyedHybridCache(typeof(int)); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredKeyedService(typeof(string))); + Assert.IsType(provider.GetRequiredKeyedService(typeof(int))); + Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void CanCreateKeyedServicesWithTypeKeysAndSetupActions() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache(typeof(string), options => options.MaximumKeyLength = 1); + services.AddKeyedHybridCache(typeof(int), options => options.MaximumKeyLength = 2); + services.AddKeyedHybridCache(null, options => options.MaximumKeyLength = 3); + + using ServiceProvider provider = services.BuildServiceProvider(); + var one = Assert.IsType(provider.GetRequiredKeyedService(typeof(string))); + Assert.Equal(1, one.Options.MaximumKeyLength); + + var two = Assert.IsType(provider.GetRequiredKeyedService(typeof(int))); + Assert.Equal(2, two.Options.MaximumKeyLength); + + var threeKeyed = Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.Equal(3, threeKeyed.Options.MaximumKeyLength); + + var threeUnkeyed = Assert.IsType(provider.GetRequiredService()); + Assert.Equal(3, threeUnkeyed.Options.MaximumKeyLength); + } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches() + { + var services = new ServiceCollection(); + services.TryAddKeyedSingleton(typeof(CustomMemoryDistributedCache1)); + services.TryAddKeyedSingleton(typeof(CustomMemoryDistributedCache2)); + + services.AddKeyedHybridCache("one", options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + services.AddKeyedHybridCache("two", options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache2)); + using ServiceProvider provider = services.BuildServiceProvider(); + + var cacheOne = Assert.IsType(provider.GetRequiredKeyedService("one")); + var cacheOneOptions = cacheOne.Options; + var cacheOneBackend = Assert.IsType(cacheOne.BackendCache); + Assert.Same(typeof(CustomMemoryDistributedCache1), cacheOneOptions.DistributedCacheServiceKey); + Assert.Same(cacheOneBackend, provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + + var cacheTwo = Assert.IsType(provider.GetRequiredKeyedService("two")); + var cacheTwoOptions = cacheTwo.Options; + var cacheTwoBackend = Assert.IsType(cacheTwo.BackendCache); + Assert.Same(typeof(CustomMemoryDistributedCache2), cacheTwoOptions.DistributedCacheServiceKey); + Assert.Same(cacheTwoBackend, provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2))); + } + + [Fact] + public async Task KeyedHybridCaches_ShareLocalMemoryCache() + { + var services = new ServiceCollection(); + services.AddMemoryCache(options => options.SizeLimit = 2); + services.AddSingleton(); + services.AddKeyedHybridCache("hybrid1"); + services.AddKeyedHybridCache("hybrid2"); + services.AddKeyedHybridCache("hybrid3"); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybrid1 = provider.GetRequiredKeyedService("hybrid1"); + var hybrid2 = provider.GetRequiredKeyedService("hybrid2"); + var hybrid3 = provider.GetRequiredKeyedService("hybrid3"); + + await hybrid1.SetAsync("entry1", 1); + await hybrid2.SetAsync("entry2", 2); + await hybrid3.SetAsync("entry3", 3); + + var localCache = provider.GetRequiredService(); + Assert.True(localCache.TryGetValue("entry1", out object? _)); + Assert.True(localCache.TryGetValue("entry2", out object? _)); + + // The third item fails to be cached locally because of the shared local cache size limit + Assert.False(localCache.TryGetValue("entry3", out object? _)); + + // But we can still get it from the hybrid cache (which gets it from the distributed cache) + var actual3 = await hybrid3.GetOrCreateAsync("entry3", ct => + { + Assert.Fail("Should not be called as the item should be found in the distributed cache"); + return new ValueTask(-1); + }); + + Assert.Equal(3, actual3); + } + + [Fact] + public void CanCreateRedisAndSqlServerBackedHybridCaches() + { + var services = new ServiceCollection(); + services.AddKeyedSingleton("Redis"); + + services.AddKeyedSingleton("SqlServer", + (sp, key) => new SqlServerCache(new SqlServerCacheOptions + { + ConnectionString = "test", + SchemaName = "test", + TableName = "test" + })); + + services.AddKeyedHybridCache("HybridWithRedis", options => options.DistributedCacheServiceKey = "Redis"); + services.AddKeyedHybridCache("HybridWithSqlServer", options => options.DistributedCacheServiceKey = "SqlServer"); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridWithRedis = Assert.IsType(provider.GetRequiredKeyedService("HybridWithRedis")); + var hybridWithRedisBackend = Assert.IsType(hybridWithRedis.BackendCache); + Assert.Same(hybridWithRedisBackend, provider.GetRequiredKeyedService("Redis")); + + var hybridWithSqlServer = Assert.IsType(provider.GetRequiredKeyedService("HybridWithSqlServer")); + var hybridWithSqlServerBackend = Assert.IsType(hybridWithSqlServer.BackendCache); + Assert.Same(hybridWithSqlServerBackend, provider.GetRequiredKeyedService("SqlServer")); + } + #if NET9_0_OR_GREATER // for Bind API [Fact] public void CanParseOptions_NoEntryOptions() { - var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; - var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("no_entry_options:MaximumKeyLength", "937") + ]); + var config = configBuilder.Build(); var options = new HybridCacheOptions(); ConfigurationBinder.Bind(config, "no_entry_options", options); @@ -68,8 +285,14 @@ public void CanParseOptions_NoEntryOptions() [Fact] public void CanParseOptions_WithEntryOptions() // in particular, check we can parse the timespan and [Flags] enums { - var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; - var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("with_entry_options:MaximumKeyLength", "937"), + new("with_entry_options:DefaultEntryOptions:Flags", "DisableCompression, DisableLocalCacheRead"), + new("with_entry_options:DefaultEntryOptions:LocalCacheExpiration", "00:02:00") + ]); + var config = configBuilder.Build(); var options = new HybridCacheOptions(); ConfigurationBinder.Bind(config, "with_entry_options", options); @@ -81,6 +304,122 @@ public void CanParseOptions_WithEntryOptions() // in particular, check we can pa Assert.Equal(TimeSpan.FromSeconds(120), defaults.LocalCacheExpiration); Assert.Null(defaults.Expiration); // wasn't specified } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches_UsingNamedOptions() + { + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("HybridOne:DistributedCacheServiceKey", "DistributedOne"), + new("HybridTwo:DistributedCacheServiceKey", "DistributedTwo") + ]); + + var config = configBuilder.Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("DistributedOne"); + services.AddKeyedSingleton("DistributedTwo"); + services.AddOptions("HybridOne").Configure(options => ConfigurationBinder.Bind(config, "HybridOne", options)); + services.AddOptions("HybridTwo").Configure(options => ConfigurationBinder.Bind(config, "HybridTwo", options)); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache1), "HybridOne"); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache2), "HybridTwo"); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridOne = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + var hybridOneOptions = hybridOne.Options; + var hybridOneBackend = Assert.IsType(hybridOne.BackendCache); + Assert.Equal("DistributedOne", hybridOneOptions.DistributedCacheServiceKey); + + var hybridTwo = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2))); + var hybridTwoOptions = hybridTwo.Options; + var hybridTwoBackend = Assert.IsType(hybridTwo.BackendCache); + Assert.Equal("DistributedTwo", hybridTwoOptions.DistributedCacheServiceKey); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches_UsingSetupActions() + { + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("HybridOne:DistributedCacheServiceKey", "DistributedOne"), + new("HybridTwo:DistributedCacheServiceKey", "DistributedTwo") + ]); + + var config = configBuilder.Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("DistributedOne"); + services.AddKeyedSingleton("DistributedTwo"); + services.AddKeyedHybridCache("HybridOne", options => ConfigurationBinder.Bind(config, "HybridOne", options)); + services.AddKeyedHybridCache("HybridTwo", options => ConfigurationBinder.Bind(config, "HybridTwo", options)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridOne = Assert.IsType(provider.GetRequiredKeyedService("HybridOne")); + var hybridOneOptions = hybridOne.Options; + var hybridOneBackend = Assert.IsType(hybridOne.BackendCache); + Assert.Equal("DistributedOne", hybridOneOptions.DistributedCacheServiceKey); + + var hybridTwo = Assert.IsType(provider.GetRequiredKeyedService("HybridTwo")); + var hybridTwoOptions = hybridTwo.Options; + var hybridTwoBackend = Assert.IsType(hybridTwo.BackendCache); + Assert.Equal("DistributedTwo", hybridTwoOptions.DistributedCacheServiceKey); + + provider.GetRequiredKeyedService("HybridOne"); + provider.GetRequiredKeyedService("HybridOne"); + provider.GetRequiredKeyedService("HybridOne"); + + provider.GetRequiredKeyedService("HybridTwo"); + provider.GetRequiredKeyedService("HybridTwo"); + provider.GetRequiredKeyedService("HybridTwo"); + } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches_UsingNamedOptionsAndSetupActions() + { + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("HybridOne:DistributedCacheServiceKey", "DistributedOne"), + new("HybridTwo:DistributedCacheServiceKey", "DistributedTwo") + ]); + + var config = configBuilder.Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("DistributedOne"); + services.AddKeyedSingleton("DistributedTwo"); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache1), "HybridOne", options => ConfigurationBinder.Bind(config, "HybridOne", options)); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache2), "HybridTwo", options => ConfigurationBinder.Bind(config, "HybridTwo", options)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridOne = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + var hybridOneOptions = hybridOne.Options; + var hybridOneBackend = Assert.IsType(hybridOne.BackendCache); + Assert.Equal("DistributedOne", hybridOneOptions.DistributedCacheServiceKey); + + var hybridTwo = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2))); + var hybridTwoOptions = hybridTwo.Options; + var hybridTwoBackend = Assert.IsType(hybridTwo.BackendCache); + Assert.Equal("DistributedTwo", hybridTwoOptions.DistributedCacheServiceKey); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + } #endif [Fact] @@ -173,7 +512,7 @@ public void DefaultMemoryDistributedCacheIsIgnored(bool manual) public void SubclassMemoryDistributedCacheIsNotIgnored() { var services = new ServiceCollection(); - services.AddSingleton(); + services.AddSingleton(); services.AddHybridCache(); using ServiceProvider provider = services.BuildServiceProvider(); var cache = Assert.IsType(provider.GetRequiredService()); @@ -293,14 +632,27 @@ public CustomMemoryCache(IOptions options, ILoggerFactory lo } } - internal class CustomMemoryDistributedCache : MemoryDistributedCache + internal class CustomMemoryDistributedCache1 : MemoryDistributedCache + { + public CustomMemoryDistributedCache1(IOptions options) + : base(options) + { + } + + public CustomMemoryDistributedCache1(IOptions options, ILoggerFactory loggerFactory) + : base(options, loggerFactory) + { + } + } + + internal class CustomMemoryDistributedCache2 : MemoryDistributedCache { - public CustomMemoryDistributedCache(IOptions options) + public CustomMemoryDistributedCache2(IOptions options) : base(options) { } - public CustomMemoryDistributedCache(IOptions options, ILoggerFactory loggerFactory) + public CustomMemoryDistributedCache2(IOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs index 1c63ff5e5c2..818dac7b45c 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Caching.Hybrid.Internal; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class TagSetTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs index c2ab242a6b0..d0176ab4e49 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Caching.Hybrid.Internal; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class TypeTests { [Theory] diff --git a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs index 52211d96e62..a890b5396c9 100644 --- a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs +++ b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs @@ -24,12 +24,7 @@ public static void When_Passed_Null_Value_String_Builder_Extensions_Does_Not_App var sb = new StringBuilder(); var redactor = NullRedactor.Instance; - sb.AppendRedacted(NullRedactor.Instance, -#if NETCOREAPP3_1_OR_GREATER - null); -#else - (string?)null); -#endif + sb.AppendRedacted(NullRedactor.Instance, null); Assert.Equal(0, sb.Length); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/ChunkerOptionsTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/ChunkerOptionsTests.cs new file mode 100644 index 00000000000..dd37d9b7551 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/ChunkerOptionsTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests; + +public class ChunkerOptionsTests +{ + private static readonly Tokenizer _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); + + [Fact] + public void TokenizerIsRequired() + { + Assert.Throws("tokenizer", () => new IngestionChunkerOptions(null!)); + } + + [Fact] + public void DefaultValues_ShouldBeSetCorrectly() + { + IngestionChunkerOptions options = new(_tokenizer); + + Assert.Equal(2000, options.MaxTokensPerChunk); + Assert.Equal(500, options.OverlapTokens); + } + + [Fact] + public void DefaultOverlapTokensIsZeroForSmallMaxTokensPerChunk() + { + IngestionChunkerOptions options = new(_tokenizer) { MaxTokensPerChunk = 100 }; + + Assert.Equal(100, options.MaxTokensPerChunk); + Assert.Equal(0, options.OverlapTokens); + } + + [Fact] + public void Properties_ShouldThrow_OnZeroOrNegative() + { + IngestionChunkerOptions options = new(_tokenizer); + + Assert.Throws("value", () => options.MaxTokensPerChunk = 0); + Assert.Throws("value", () => options.MaxTokensPerChunk = -1); + + // 0 is allowed for OverlapTokens + Assert.Throws("value", () => options.OverlapTokens = -1); + } + + [Fact] + public void OverlapTokensCanBeZero() + { + IngestionChunkerOptions options = new(_tokenizer) + { + OverlapTokens = 0 + }; + + Assert.Equal(0, options.OverlapTokens); + } + + [Fact] + public void OverlapTokens_ShouldThrow_WhenGreaterOrEqualThanMaxTokens() + { + IngestionChunkerOptions options = new(_tokenizer) { MaxTokensPerChunk = 1000 }; + + Assert.Throws("value", () => options.OverlapTokens = 1000); + Assert.Throws("value", () => options.OverlapTokens = 1500); + } + + [Fact] + public void MaxTokensPerChunk_ShouldThrow_WhenLessOrEqualThanOverlapTokens() + { + IngestionChunkerOptions options = new(_tokenizer) { OverlapTokens = 10 }; + + Assert.Throws("value", () => options.MaxTokensPerChunk = 10); + Assert.Throws("value", () => options.MaxTokensPerChunk = 5); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/DocumentChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/DocumentChunkerTests.cs new file mode 100644 index 00000000000..fcb8e795c90 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/DocumentChunkerTests.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests +{ + public abstract class DocumentChunkerTests + { + protected abstract IngestionChunker CreateDocumentChunker(int maxTokensPerChunk = 2_000, int overlapTokens = 500); + + [Fact] + public async Task ProcessAsync_ThrowsArgumentNullException_WhenDocumentIsNull() + { + var chunker = CreateDocumentChunker(); + await Assert.ThrowsAsync("document", async () => await chunker.ProcessAsync(null!).ToListAsync()); + } + + [Fact] + public async Task EmptyDocument() + { + IngestionDocument emptyDoc = new("emptyDoc"); + IngestionChunker chunker = CreateDocumentChunker(); + + IReadOnlyList> chunks = await chunker.ProcessAsync(emptyDoc).ToListAsync(); + Assert.Empty(chunks); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/HeaderChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/HeaderChunkerTests.cs new file mode 100644 index 00000000000..80803d3d62d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/HeaderChunkerTests.cs @@ -0,0 +1,275 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests; + +public class HeaderChunkerTests +{ + [Fact] + public async Task CanChunkNonTrivialDocument() + { + IngestionDocument doc = new("nonTrivial"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header 1") { Level = 1 }, + new IngestionDocumentHeader("Header 1_1") { Level = 2 }, + new IngestionDocumentParagraph("Paragraph 1_1_1"), + new IngestionDocumentHeader("Header 1_1_1") { Level = 3 }, + new IngestionDocumentParagraph("Paragraph 1_1_1_1"), + new IngestionDocumentParagraph("Paragraph 1_1_1_2"), + new IngestionDocumentHeader("Header 1_1_2") { Level = 3 }, + new IngestionDocumentParagraph("Paragraph 1_1_2_1"), + new IngestionDocumentParagraph("Paragraph 1_1_2_2"), + new IngestionDocumentHeader("Header 1_2") { Level = 2 }, + new IngestionDocumentParagraph("Paragraph 1_2_1"), + new IngestionDocumentHeader("Header 1_2_1") { Level = 3 }, + new IngestionDocumentParagraph("Paragraph 1_2_1_1"), + } + }); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(5, chunks.Count); + + Assert.Equal("Header 1 Header 1_1", chunks[0].Context); + Assert.Equal($"Header 1 Header 1_1\nParagraph 1_1_1", chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_1 Header 1_1_1", chunks[1].Context); + Assert.Equal($"Header 1 Header 1_1 Header 1_1_1\nParagraph 1_1_1_1\nParagraph 1_1_1_2", chunks[1].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_1 Header 1_1_2", chunks[2].Context); + Assert.Equal($"Header 1 Header 1_1 Header 1_1_2\nParagraph 1_1_2_1\nParagraph 1_1_2_2", chunks[2].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_2", chunks[3].Context); + Assert.Equal($"Header 1 Header 1_2\nParagraph 1_2_1", chunks[3].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_2 Header 1_2_1", chunks[4].Context); + Assert.Equal($"Header 1 Header 1_2 Header 1_2_1\nParagraph 1_2_1_1", chunks[4].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task CanRespectTokenLimit() + { + IngestionDocument doc = new("longOne"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, + new IngestionDocumentHeader("Header B") { Level = 2 }, + new IngestionDocumentHeader("Header C") { Level = 3 }, + new IngestionDocumentParagraph("This is a very long text. It's expressed with plenty of tokens") + } + }); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 13 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.Equal("Header A Header B Header C", chunks[0].Context); + Assert.Equal($"Header A Header B Header C\nThis is a very long text.", chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header A Header B Header C", chunks[1].Context); + Assert.Equal($"Header A Header B Header C\n It's expressed with plenty of tokens", chunks[1].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task ThrowsWhenLimitIsTooLowToFitAnythingMoreThanContext() + { + IngestionDocument doc = new("longOne"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, // 2 tokens + new IngestionDocumentHeader("Header B") { Level = 2 }, // 2 tokens + new IngestionDocumentHeader("Header C") { Level = 3 }, // 2 tokens + new IngestionDocumentParagraph("This is a very long text. It's expressed with plenty of tokens") + } + }); + + HeaderChunker lessThanContext = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 5 }); + await Assert.ThrowsAsync(async () => await lessThanContext.ProcessAsync(doc).ToListAsync()); + + HeaderChunker sameAsContext = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 6 }); + await Assert.ThrowsAsync(async () => await sameAsContext.ProcessAsync(doc).ToListAsync()); + } + + [Fact] + public async Task CanSplitLongerParagraphsOnNewLine() + { + IngestionDocument doc = new("withNewLines"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, + new IngestionDocumentHeader("Header B") { Level = 2 }, + new IngestionDocumentHeader("Header C") { Level = 3 }, + new IngestionDocumentParagraph("This is a very long text. It's expressed with plenty of tokens. And it contains a new line.\nWith some text after the new line."), + new IngestionDocumentParagraph("And following paragraph.") + } + }); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 30 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.Equal("Header A Header B Header C", chunks[0].Context); + Assert.Equal($"Header A Header B Header C\nThis is a very long text. It's expressed with plenty of tokens. And it contains a new line.\n", + chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header A Header B Header C", chunks[1].Context); + Assert.Equal($"Header A Header B Header C\nWith some text after the new line.\nAnd following paragraph.", chunks[1].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task ThrowsWhenHeaderSeparatorAndSingleRowExceedTokenLimit() + { + IngestionDocument document = CreateDocumentWithLargeTable(); + + // It takes 38 tokens to represent Headers, Separator and the first Row. + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 37 }); + + await Assert.ThrowsAsync(async () => await chunker.ProcessAsync(document).ToListAsync()); + } + + [Fact] + public async Task CanSplitLargeTableIntoMultipleChunks_MultipleRowsPerChunk() + { + IngestionDocument document = CreateDocumentWithLargeTable(); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 100 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(document).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.All(chunks, chunk => Assert.Equal("Header A", chunk.Context)); + Assert.Equal(""" + Header A + This is some text that describes why we need the following table. + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 0 | 1 | 2 | 3 | 4 | + | 5 | 6 | 7 | 8 | 9 | + | 10 | 11 | 12 | 13 | 14 | + """, chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 15 | 16 | 17 | 18 | 19 | + | 20 | 21 | 22 | 23 | 24 | + And some follow up. + """, chunks[1].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task CanSplitLargeTableIntoMultipleChunks_OneRowPerChunk() + { + IngestionDocument document = CreateDocumentWithLargeTable(); + + Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); + HeaderChunker chunker = new(new(tokenizer) { MaxTokensPerChunk = 50 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(document).ToListAsync(); + + Assert.Equal(6, chunks.Count); + Assert.All(chunks, chunk => Assert.Equal("Header A", chunk.Context)); + Assert.All(chunks, chunk => Assert.InRange(tokenizer.CountTokens(chunk.Content), 1, 50)); + + Assert.Equal(""" + Header A + This is some text that describes why we need the following table. + """, chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 0 | 1 | 2 | 3 | 4 | + """, chunks[1].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 5 | 6 | 7 | 8 | 9 | + """, chunks[2].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 10 | 11 | 12 | 13 | 14 | + """, chunks[3].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 15 | 16 | 17 | 18 | 19 | + """, chunks[4].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 20 | 21 | 22 | 23 | 24 | + And some follow up. + """, chunks[5].Content, ignoreLineEndingDifferences: true); + } + + private static IngestionDocument CreateDocumentWithLargeTable() + { + IngestionDocumentTable table = new(""" + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 0 | 1 | 2 | 3 | 4 | + | 5 | 6 | 7 | 8 | 9 | + | 10 | 11 | 12 | 13 | 14 | + | 15 | 16 | 17 | 18 | 19 | + | 20 | 21 | 22 | 23 | 24 | + """, CreateTableCells() +); + + IngestionDocument doc = new("withNewLines"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, + new IngestionDocumentParagraph("This is some text that describes why we need the following table."), + table, + new IngestionDocumentParagraph("And some follow up.") + } + }); + + return doc; + + static IngestionDocumentElement?[,] CreateTableCells() + { + var cells = new IngestionDocumentElement[6, 5]; // 6 rows (1 header + 5 data rows), 5 columns + + // Header row + cells[0, 0] = new IngestionDocumentParagraph("one"); + cells[0, 1] = new IngestionDocumentParagraph("two"); + cells[0, 2] = new IngestionDocumentParagraph("three"); + cells[0, 3] = new IngestionDocumentParagraph("four"); + cells[0, 4] = new IngestionDocumentParagraph("five"); + + // Data rows (0-29) + int number = 0; + for (int row = 1; row <= 5; row++) + { + for (int col = 0; col < 5; col++) + { + cells[row, col] = new IngestionDocumentParagraph(number.ToString()); + number++; + } + } + + return cells; + } + } + + // We need plenty of more tests here, especially for edge cases: + // - sentence splitting + // - markdown splitting (e.g. lists, code blocks etc.) +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs new file mode 100644 index 00000000000..354cebf1565 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests +{ + public class SemanticSimilarityChunkerTests : DocumentChunkerTests + { + protected override IngestionChunker CreateDocumentChunker(int maxTokensPerChunk = 2_000, int overlapTokens = 500) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + TestEmbeddingGenerator embeddingClient = new(); +#pragma warning restore CA2000 // Dispose objects before losing scope + return CreateSemanticSimilarityChunker(embeddingClient, maxTokensPerChunk, overlapTokens); + } + + private static IngestionChunker CreateSemanticSimilarityChunker(TestEmbeddingGenerator embeddingClient, int maxTokensPerChunk = 2_000, int overlapTokens = 500) + { + Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4o"); + return new SemanticSimilarityChunker(embeddingClient, + new(tokenizer) { MaxTokensPerChunk = maxTokensPerChunk, OverlapTokens = overlapTokens }); + } + + [Fact] + public async Task SingleParagraph() + { + string text = ".NET is a free, cross-platform, open-source developer platform for building many " + + "kinds of applications. It can run programs written in multiple languages, with C# being the most popular. " + + "It relies on a high-performance runtime that is used in production by many high-scale apps."; + IngestionDocument doc = new IngestionDocument("doc"); + doc.Sections.Add(new IngestionDocumentSection + { + Elements = + { + new IngestionDocumentParagraph(text) + } + }); + using TestEmbeddingGenerator customGenerator = new() + { + GenerateAsyncCallback = static async (values, options, ct) => + { + var embeddings = values.Select(v => + new Embedding(new float[] { 1.0f, 2.0f, 3.0f, 4.0f })) + .ToArray(); + + return [.. embeddings]; + } + }; + IngestionChunker chunker = CreateSemanticSimilarityChunker(customGenerator); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + Assert.Single(chunks); + Assert.Equal(text, chunks[0].Content); + } + + [Fact] + public async Task TwoTopicsParagraphs() + { + IngestionDocument doc = new IngestionDocument("doc"); + string text1 = ".NET is a free, cross-platform, open-source developer platform for building many" + + "kinds of applications. It can run programs written in multiple languages, with C# being the most popular."; + string text2 = "It relies on a high-performance runtime that is used in production by many high-scale apps."; + string text3 = "Zeus is the chief deity of the Greek pantheon. He is a sky and thunder god in ancient Greek religion and mythology."; + doc.Sections.Add(new IngestionDocumentSection + { + Elements = + { + new IngestionDocumentParagraph(text1), + new IngestionDocumentParagraph(text2), + new IngestionDocumentParagraph(text3) + } + }); + + using var customGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (values, options, ct) => + { + var embeddings = values.Select((_, index) => + { + return index switch + { + 0 => new Embedding(new float[] { 1.0f, 1.0f, 1.0f, 1.0f }), + 1 => new Embedding(new float[] { 1.0f, 1.0f, 1.0f, 1.0f }), + 2 => new Embedding(new float[] { -1.0f, -1.0f, -1.0f, -1.0f }), + _ => throw new InvalidOperationException("Unexpected call count") + }; + }).ToArray(); + + return [.. embeddings]; + } + }; + + IngestionChunker chunker = CreateSemanticSimilarityChunker(customGenerator); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + Assert.Equal(2, chunks.Count); + Assert.Equal(text1 + Environment.NewLine + text2, chunks[0].Content); + Assert.Equal(text3, chunks[1].Content); + } + + [Fact] + public async Task TwoSeparateTopicsWithAllKindsOfElements() + { + string dotNetTableMarkdown = """ + | Language | Type | Status | + | --- | --- | --- | + | C# | Object-oriented | Primary | + | F# | Functional | Official | + | Visual Basic | Object-oriented | Official | + | PowerShell | Scripting | Supported | + | IronPython | Dynamic | Community | + | IronRuby | Dynamic | Community | + | Boo | Object-oriented | Community | + | Nemerle | Functional/OOP | Community | + """; + + string godsTableMarkdown = """ + | God | Domain | Symbol | Roman Name | + | --- | --- | --- | --- | + | Zeus | Sky & Thunder | Lightning Bolt | Jupiter | + | Hera | Marriage & Family | Peacock | Juno | + | Poseidon | Sea & Earthquakes | Trident | Neptune | + | Athena | Wisdom & War | Owl | Minerva | + | Apollo | Sun & Music | Lyre | Apollo | + | Artemis | Hunt & Moon | Silver Bow | Diana | + | Aphrodite | Love & Beauty | Dove | Venus | + | Ares | War & Courage | Spear | Mars | + | Hephaestus | Fire & Forge | Hammer | Vulcan | + | Demeter | Harvest & Nature | Wheat | Ceres | + | Dionysus | Wine & Festivity | Grapes | Bacchus | + | Hermes | Messages & Trade | Caduceus | Mercury | + """; + + IngestionDocument doc = new("dotnet-languages"); + doc.Sections.Add(new IngestionDocumentSection + { + Elements = + { + new IngestionDocumentHeader("# .NET Supported Languages") { Level = 1 }, + new IngestionDocumentParagraph("The .NET platform supports multiple programming languages:"), + new IngestionDocumentTable(dotNetTableMarkdown, + ToParagraphCells(CreateLanguageTableCells())), + new IngestionDocumentParagraph("C# remains the most popular language for .NET development."), + new IngestionDocumentHeader("# Ancient Greek Olympian Gods") { Level = 1 }, + new IngestionDocumentParagraph("The twelve Olympian gods were the principal deities of the Greek pantheon:"), + new IngestionDocumentTable(godsTableMarkdown, + ToParagraphCells(CreateGreekGodsTableCells())), + new IngestionDocumentParagraph("These gods resided on Mount Olympus and ruled over different aspects of mortal and divine life.") + } + }); + + using var customGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (values, options, ct) => + { + var embeddings = values.Select((_, index) => + { + return index switch + { + <= 3 => new Embedding(new float[] { 1.0f, 1.0f, 1.0f, 1.0f }), + >= 4 and <= 7 => new Embedding(new float[] { -1.0f, -1.0f, -1.0f, -1.0f }), + _ => throw new InvalidOperationException($"Unexpected index: {index}") + }; + }).ToArray(); + + return [.. embeddings]; + } + }; + + IngestionChunker chunker = CreateSemanticSimilarityChunker(customGenerator, 200, 0); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(3, chunks.Count); + Assert.All(chunks, chunk => Assert.Same(doc, chunk.Document)); + Assert.Equal($@"# .NET Supported Languages +The .NET platform supports multiple programming languages: +{dotNetTableMarkdown} +C# remains the most popular language for .NET development.", + chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal($@"# Ancient Greek Olympian Gods +The twelve Olympian gods were the principal deities of the Greek pantheon: +| God | Domain | Symbol | Roman Name | +| --- | --- | --- | --- | +| Zeus | Sky & Thunder | Lightning Bolt | Jupiter | +| Hera | Marriage & Family | Peacock | Juno | +| Poseidon | Sea & Earthquakes | Trident | Neptune | +| Athena | Wisdom & War | Owl | Minerva | +| Apollo | Sun & Music | Lyre | Apollo | +| Artemis | Hunt & Moon | Silver Bow | Diana | +| Aphrodite | Love & Beauty | Dove | Venus | +| Ares | War & Courage | Spear | Mars | +| Hephaestus | Fire & Forge | Hammer | Vulcan | +| Demeter | Harvest & Nature | Wheat | Ceres | +| Dionysus | Wine & Festivity | Grapes | Bacchus |", + chunks[1].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + | God | Domain | Symbol | Roman Name | + | --- | --- | --- | --- | + | Hermes | Messages & Trade | Caduceus | Mercury | + These gods resided on Mount Olympus and ruled over different aspects of mortal and divine life. + """, chunks[2].Content, ignoreLineEndingDifferences: true); + + static string[,] CreateGreekGodsTableCells() => new string[,] + { + { "God", "Domain", "Symbol", "Roman Name" }, + { "Zeus", "Sky & Thunder", "Lightning Bolt", "Jupiter" }, + { "Hera", "Marriage & Family", "Peacock", "Juno" }, + { "Poseidon", "Sea & Earthquakes", "Trident", "Neptune" }, + { "Athena", "Wisdom & War", "Owl", "Minerva" }, + { "Apollo", "Sun & Music", "Lyre", "Apollo" }, + { "Artemis", "Hunt & Moon", "Silver Bow", "Diana" }, + { "Aphrodite", "Love & Beauty", "Dove", "Venus" }, + { "Ares", "War & Courage", "Spear", "Mars" }, + { "Hephaestus", "Fire & Forge", "Hammer", "Vulcan" }, + { "Demeter", "Harvest & Nature", "Wheat", "Ceres" }, + { "Dionysus", "Wine & Festivity", "Grapes", "Bacchus" }, + { "Hermes", "Messages & Trade", "Caduceus", "Mercury" } + }; + + static string[,] CreateLanguageTableCells() => new string[,] + { + { "Language", "Type", "Status" }, + { "C#", "Object-oriented", "Primary" }, + { "F#", "Functional", "Official" }, + { "Visual Basic", "Object-oriented", "Official" }, + { "PowerShell", "Scripting", "Supported" }, + { "IronPython", "Dynamic", "Community" }, + { "IronRuby", "Dynamic", "Community" }, + { "Boo", "Object-oriented", "Community" }, + { "Nemerle", "Functional/OOP", "Community" } + }; + } + + private static IngestionDocumentParagraph?[,] ToParagraphCells(string[,] cells) + { + int rows = cells.GetLength(0); + int cols = cells.GetLength(1); + var result = new IngestionDocumentParagraph?[rows, cols]; + for (int i = 0; i < rows; i++) + { + for (int j = 0; j < cols; j++) + { + result[i, j] = new IngestionDocumentParagraph(cells[i, j]); + } + } + + return result; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionDocumentTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionDocumentTests.cs new file mode 100644 index 00000000000..71c543a350e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionDocumentTests.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Tests; + +public class IngestionDocumentTests +{ + private readonly IngestionDocumentElement?[,] _rows = + { + { new IngestionDocumentParagraph("header") }, + { new IngestionDocumentParagraph("row1") }, + { new IngestionDocumentParagraph("row2") } + }; + + [Fact] + public void EnumeratorFlattensTheStructureAndPreservesOrder() + { + IngestionDocument doc = new("withSubSections"); + doc.Sections.Add(new IngestionDocumentSection("first section") + { + Elements = + { + new IngestionDocumentHeader("header"), + new IngestionDocumentParagraph("paragraph"), + new IngestionDocumentTable("table", _rows), + new IngestionDocumentSection("nested section") + { + Elements = + { + new IngestionDocumentHeader("nested header"), + new IngestionDocumentParagraph("nested paragraph") + } + } + } + }); + doc.Sections.Add(new IngestionDocumentSection("second section") + { + Elements = + { + new IngestionDocumentHeader("header 2"), + new IngestionDocumentParagraph("paragraph 2") + } + }); + + IngestionDocumentElement[] flatElements = doc.EnumerateContent().ToArray(); + + Assert.IsType(flatElements[0]); + Assert.Equal("header", flatElements[0].GetMarkdown()); + Assert.IsType(flatElements[1]); + Assert.Equal("paragraph", flatElements[1].GetMarkdown()); + Assert.IsType(flatElements[2]); + Assert.Equal("table", flatElements[2].GetMarkdown()); + Assert.IsType(flatElements[3]); + Assert.Equal("nested header", flatElements[3].GetMarkdown()); + Assert.IsType(flatElements[4]); + Assert.Equal("nested paragraph", flatElements[4].GetMarkdown()); + Assert.IsType(flatElements[5]); + Assert.Equal("header 2", flatElements[5].GetMarkdown()); + Assert.IsType(flatElements[6]); + Assert.Equal("paragraph 2", flatElements[6].GetMarkdown()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void EmptyParagraphDocumentCantBeCreated(string? input) + => Assert.Throws("markdown", () => new IngestionDocumentParagraph(input!)); + + [Theory] + [InlineData(-1)] + [InlineData(100)] + public void InvalidHeaderLevelThrows(int level) + { + IngestionDocumentHeader header = new("# header"); + + Assert.Throws("value", () => header.Level = level); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs new file mode 100644 index 00000000000..f2f0d85c458 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -0,0 +1,261 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; +using Microsoft.SemanticKernel.Connectors.InMemory; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Tests; + +#pragma warning disable S881 // Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression + +public sealed class IngestionPipelineTests : IDisposable +{ + private readonly FileInfo _withTable; + private readonly FileInfo _withImage; + private readonly IReadOnlyList _sampleFiles; + private readonly DirectoryInfo _sampleDirectory; + + public IngestionPipelineTests() + { + _sampleDirectory = Directory.CreateDirectory(Path.Combine("TestFiles")); + + _withTable = new(Path.Combine("TestFiles", "withTable.md")); + const string FirstFileContent = """ + # First Document + + This is the content of the first document. + + ## Subsection + + More content in section 1. + + ## Table + + What a nice table! + + | Header1 | Header2 | + |---------|---------| + | Cell1 | Cell2 | + | Cell3 | Cell4 | + """; + File.WriteAllText(_withTable.FullName, FirstFileContent); + + _withImage = new(Path.Combine("TestFiles", "withImage.md")); + string secondFileContent = $""" + # Second Document + + Content for the second document goes here. + + ## Another Subsection + + Additional content in section 2. + + It comes with an image! + + ![Sample Image](data:image/png;base64,{Convert.ToBase64String(new byte[1000])}) + """; + File.WriteAllText(_withImage.FullName, secondFileContent); + + _sampleFiles = [_withTable, _withImage]; + } + + public void Dispose() + { + _sampleDirectory.Delete(recursive: true); + } + + [Fact] + public async Task CanProcessDocuments() + { + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + + using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); + List ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); + + Assert.Equal(_sampleFiles.Count, ingestionResults.Count); + AssertAllIngestionsSucceeded(ingestionResults); + + Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + + var retrieved = await vectorStoreWriter.VectorStoreCollection + .GetAsync(record => _sampleFiles.Any(info => info.FullName == (string)record["documentid"]!), top: 1000) + .ToListAsync(); + + Assert.NotEmpty(retrieved); + for (int i = 0; i < retrieved.Count; i++) + { + Assert.NotEqual(Guid.Empty, (Guid)retrieved[i]["key"]!); + Assert.NotEmpty((string)retrieved[i]["content"]!); + Assert.Contains((string)retrieved[i]["documentid"]!, _sampleFiles.Select(info => info.FullName)); + } + + AssertActivities(activities, "ProcessFiles"); + } + + [Fact] + public async Task CanProcessDocumentsInDirectory() + { + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + + using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); + + DirectoryInfo directory = new("TestFiles"); + List ingestionResults = await pipeline.ProcessAsync(directory, "*.md").ToListAsync(); + Assert.Equal(directory.EnumerateFiles("*.md").Count(), ingestionResults.Count); + AssertAllIngestionsSucceeded(ingestionResults); + + Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + + var retrieved = await vectorStoreWriter.VectorStoreCollection + .GetAsync(record => ((string)record["documentid"]!).StartsWith(directory.FullName), top: 1000) + .ToListAsync(); + + Assert.NotEmpty(retrieved); + for (int i = 0; i < retrieved.Count; i++) + { + Assert.NotEqual(Guid.Empty, (Guid)retrieved[i]["key"]!); + Assert.NotEmpty((string)retrieved[i]["content"]!); + Assert.StartsWith(directory.FullName, (string)retrieved[i]["documentid"]!); + } + + AssertActivities(activities, "ProcessDirectory"); + } + + [Fact] + public async Task ChunksCanBeMoreThanJustText() + { + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using IngestionPipeline pipeline = new(CreateReader(), new ImageChunker(), vectorStoreWriter); + + Assert.False(embeddingGenerator.WasCalled); + var ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); + AssertAllIngestionsSucceeded(ingestionResults); + + var retrieved = await vectorStoreWriter.VectorStoreCollection + .GetAsync(record => ((string)record["documentid"]!).EndsWith(_withImage.Name), top: 100) + .ToListAsync(); + + Assert.True(embeddingGenerator.WasCalled); + Assert.NotEmpty(retrieved); + for (int i = 0; i < retrieved.Count; i++) + { + Assert.NotEqual(Guid.Empty, (Guid)retrieved[i]["key"]!); + Assert.EndsWith(_withImage.Name, (string)retrieved[i]["documentid"]!); + } + + AssertActivities(activities, "ProcessFiles"); + } + + internal class ImageChunker : IngestionChunker + { + public override IAsyncEnumerable> ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default) + => document.EnumerateContent() + .OfType() + .Select(image => new IngestionChunk( + content: new(image.Content.GetValueOrDefault(), image.MediaType!), + document: document)) + .ToAsyncEnumerable(); + } + + [Fact] + public async Task SingleFailureDoesNotTearDownEntirePipeline() + { + int failed = 0; + MarkdownReader workingReader = new(); + TestReader failingForFirstReader = new( + (source, identifier, mediaType, cancellationToken) => failed++ == 0 + ? Task.FromException(new ExpectedException()) + : workingReader.ReadAsync(source, identifier, mediaType, cancellationToken)); + + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + + using IngestionPipeline pipeline = new(failingForFirstReader, CreateChunker(), vectorStoreWriter); + + await Verify(pipeline.ProcessAsync(_sampleFiles)); + await Verify(pipeline.ProcessAsync(_sampleDirectory)); + + async Task Verify(IAsyncEnumerable results) + { + List ingestionResults = await results.ToListAsync(); + + Assert.Equal(_sampleFiles.Count, ingestionResults.Count); + Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); + IngestionResult ingestionResult = Assert.Single(ingestionResults.Where(result => !result.Succeeded)); + Assert.IsType(ingestionResult.Exception); + AssertErrorActivities(activities, expectedFailedActivitiesCount: 1); + + activities.Clear(); + failed = 0; + } + } + + private static IngestionDocumentReader CreateReader() => new MarkdownReader(); + + private static IngestionChunker CreateChunker() => new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + + private static TracerProvider CreateTraceProvider(List activities) + => Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.Microsoft.Extensions.DataIngestion") + .ConfigureResource(r => r.AddService("inmemory-test")) + .AddInMemoryExporter(activities) + .Build(); + + private static void AssertAllIngestionsSucceeded(List ingestionResults) + { + Assert.NotEmpty(ingestionResults); + Assert.All(ingestionResults, result => Assert.True(result.Succeeded)); + Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); + Assert.All(ingestionResults, result => Assert.NotNull(result.Document)); + Assert.All(ingestionResults, result => Assert.Null(result.Exception)); + } + + private static void AssertActivities(List activities, string rootActivityName) + { + Assert.NotEmpty(activities); + Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Extensions.DataIngestion", a.Source.Name)); + Assert.Single(activities, a => a.OperationName == rootActivityName); + Assert.Contains(activities, a => a.OperationName == "ProcessFile"); + } + + private static void AssertErrorActivities(List activities, int expectedFailedActivitiesCount) + { + Assert.NotEmpty(activities); + Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Extensions.DataIngestion", a.Source.Name)); + + var failed = activities.Where(act => act.Status == ActivityStatusCode.Error).ToList(); + Assert.Equal(expectedFailedActivitiesCount, failed.Count); + Assert.All(failed, a => Assert.Equal(ExpectedException.ExceptionMessage, a.StatusDescription)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj new file mode 100644 index 00000000000..cc64014cad6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj @@ -0,0 +1,37 @@ + + + + + $(NoWarn);S3967 + + $(NoWarn);CA1063 + + + x64 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs new file mode 100644 index 00000000000..6dad7e4af0a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class AlternativeTextEnricherTests +{ + private readonly ReadOnlyMemory _imageContent = new byte[256]; + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new ImageAlternativeTextEnricher(null!)); + } + + [Fact] + public async Task ThrowsOnNullDocument() + { + using TestChatClient chatClient = new(); + + ImageAlternativeTextEnricher sut = new(new(chatClient)); + + await Assert.ThrowsAsync("document", async () => await sut.ProcessAsync(null!)); + } + + [Fact] + public async Task CanGenerateImageAltText() + { + const string PreExistingAltText = "Pre-existing alt text"; + + string[] descriptions = { "First alt text", "Second alt text" }; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + Assert.Equal(2, materializedMessages[1].Contents.Count); + + Assert.All(materializedMessages[1].Contents, content => + { + DataContent dataContent = Assert.IsType(content); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal(_imageContent.ToArray(), dataContent.Data.ToArray()); + }); + + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(new Envelope { data = descriptions })) + })); + } + }; + ImageAlternativeTextEnricher sut = new(new(chatClient)); + + IngestionDocumentImage documentImage = new($"![](nonExisting.png)") + { + AlternativeText = null, + Content = _imageContent, + MediaType = "image/png" + }; + + IngestionDocumentImage tableCell = new($"![](another.png)") + { + AlternativeText = null, + Content = _imageContent, + MediaType = "image/png" + }; + + IngestionDocumentImage imageWithAltText = new($"![](noChangesNeeded.png)") + { + AlternativeText = PreExistingAltText, + Content = _imageContent, + MediaType = "image/png" + }; + + IngestionDocumentImage imageWithNoContent = new($"![](noImage.png)") + { + AlternativeText = null, + Content = default, + MediaType = "image/png" + }; + + IngestionDocument document = new("withImage") + { + Sections = + { + new IngestionDocumentSection + { + Elements = + { + documentImage, + new IngestionDocumentTable("nvm", new[,] { { tableCell } }) + } + } + } + }; + + await sut.ProcessAsync(document); + + Assert.Equal(descriptions[0], documentImage.AlternativeText); + Assert.Equal(descriptions[1], tableCell.AlternativeText); + Assert.Same(PreExistingAltText, imageWithAltText.AlternativeText); + Assert.Null(imageWithNoContent.AlternativeText); + } + + [Theory] + [InlineData(1, 3)] + [InlineData(3, 7)] + [InlineData(15, 3)] + public async Task SendsOneRequestPerBatchSize(int batchSize, int batchCount) + { + int callsCount = 0; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + callsCount++; + + var materializedMessages = messages.ToArray(); + + // One system message + one User message with all the contents + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + Assert.Equal(batchSize, materializedMessages[1].Contents.Count); + + Assert.All(materializedMessages[1].Contents, content => + { + DataContent dataContent = Assert.IsType(content); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal(_imageContent.ToArray(), dataContent.Data.ToArray()); + }); + + Envelope data = new() { data = Enumerable.Range(0, batchSize).Select(i => i.ToString()).ToArray() }; + return Task.FromResult(new ChatResponse(new[] { new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(data)) })); + } + }; + + ImageAlternativeTextEnricher sut = new(new(chatClient) { BatchSize = batchSize }); + + IngestionDocument document = CreateDocument(batchSize, batchCount, _imageContent); + + await sut.ProcessAsync(document); + Assert.Equal(batchCount, callsCount); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + EnricherOptions options = new(chatClient) { LoggerFactory = loggerFactory }; + ImageAlternativeTextEnricher sut = new(options); + + const int BatchCount = 2; + IngestionDocument document = CreateDocument(options.BatchSize, BatchCount, _imageContent); + IngestionDocument got = await sut.ProcessAsync(document); + + Assert.Equal(BatchCount, collector.Count); + Assert.All(collector.GetSnapshot(), record => + { + Assert.Equal(LogLevel.Error, record.Level); + Assert.IsType(record.Exception); + }); + } + + private static IngestionDocument CreateDocument(int batchSize, int batchCount, ReadOnlyMemory imageContent) + { + IngestionDocumentSection rootSection = new(); + for (int i = 0; i < batchSize * batchCount; i++) + { + IngestionDocumentImage image = new($"![](image{i}.png)") + { + Content = imageContent, + MediaType = "image/png", + AlternativeText = null + }; + + rootSection.Elements.Add(image); + } + + return new("batchTest") + { + Sections = { rootSection } + }; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs new file mode 100644 index 00000000000..15d0a5f6152 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class ClassificationEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new ClassificationEnricher(null!, predefinedClasses: ["some"])); + } + + [Fact] + public void ThrowsOnEmptyPredefinedClasses() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: [])); + } + + [Fact] + public void ThrowsOnDuplicatePredefinedClasses() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["same", "same"])); + } + + [Fact] + public void ThrowsOnPredefinedClassesContainingFallback() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["same", "Unknown"])); + } + + [Fact] + public void ThrowsOnFallbackInPredefinedClasses() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["some"], fallbackClass: "some")); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + ClassificationEnricher sut = new(new(chatClient), predefinedClasses: ["some"]); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Fact] + public async Task CanClassify() + { + int counter = 0; + string[] classes = ["AI", "Animals", "UFO"]; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = classes }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + ClassificationEnricher sut = new(new(chatClient), ["AI", "Animals", "Sports"], fallbackClass: "UFO"); + + IReadOnlyList> got = await sut.ProcessAsync(CreateChunks().ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(3, got.Count); + Assert.Equal("AI", got[0].Metadata[ClassificationEnricher.MetadataKey]); + Assert.Equal("Animals", got[1].Metadata[ClassificationEnricher.MetadataKey]); + Assert.Equal("UFO", got[2].Metadata[ClassificationEnricher.MetadataKey]); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + ClassificationEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }, ["AI", "Other"]); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new(".NET developers need to integrate and interact with a growing variety of artificial intelligence (AI) services in their apps. " + + "The Microsoft.Extensions.AI libraries provide a unified approach for representing generative AI components, and enable seamless" + + " integration and interoperability with various AI services.", _document), + new ("Rabbits are small mammals in the family Leporidae of the order Lagomorpha (along with the hare and the pika)." + + "They are herbivorous animals and are known for their long ears, large hind legs, and short fluffy tails.", _document), + new("This text does not belong to any category.", _document), + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs new file mode 100644 index 00000000000..5a116e1ab04 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class KeywordEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new KeywordEnricher(null!, predefinedKeywords: null, confidenceThreshold: 0.5)); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(1.1)] + public void ThrowsOnInvalidThreshold(double threshold) + { + Assert.Throws("confidenceThreshold", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: null, confidenceThreshold: threshold)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ThrowsOnInvalidMaxKeywords(int keywordCount) + { + Assert.Throws("maxKeywords", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: null, maxKeywords: keywordCount)); + } + + [Fact] + public void ThrowsOnDuplicateKeywords() + { + Assert.Throws("predefinedKeywords", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: ["same", "same"], confidenceThreshold: 0.5)); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + KeywordEnricher sut = new(new(chatClient), predefinedKeywords: null, confidenceThreshold: 0.5); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Theory] + [InlineData] + [InlineData("AI", "MEAI", "Animals", "Rabbits")] + public async Task CanExtractKeywords(params string[] predefined) + { + int counter = 0; + string[][] keywords = [["AI", "MEAI"], ["Animals", "Rabbits"]]; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = keywords }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + + KeywordEnricher sut = new(new(chatClient), predefinedKeywords: predefined, confidenceThreshold: 0.5); + var chunks = CreateChunks().ToAsyncEnumerable(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks).ToListAsync(); + + Assert.Equal(["AI", "MEAI"], (string[])got[0].Metadata[KeywordEnricher.MetadataKey]); + Assert.Equal(["Animals", "Rabbits"], (string[])got[1].Metadata[KeywordEnricher.MetadataKey]); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + KeywordEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }, ["AI", "Other"]); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new("The Microsoft.Extensions.AI libraries provide a unified approach for representing generative AI components", _document), + new("Rabbits are great pets. They are friendly and make excellent companions.", _document) + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs new file mode 100644 index 00000000000..8d762f3199c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class SentimentEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new SentimentEnricher(null!)); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(1.1)] + public void ThrowsOnInvalidThreshold(double threshold) + { + Assert.Throws("confidenceThreshold", () => new SentimentEnricher(new(new TestChatClient()), confidenceThreshold: threshold)); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + SentimentEnricher sut = new(new(chatClient)); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Fact] + public async Task CanProvideSentiment() + { + int counter = 0; + string[] sentiments = { "Positive", "Negative", "Neutral", "Unknown" }; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = sentiments }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + SentimentEnricher sut = new(new(chatClient)); + var input = CreateChunks().ToAsyncEnumerable(); + + var chunks = await sut.ProcessAsync(input).ToListAsync(); + + Assert.Equal(4, chunks.Count); + + Assert.Equal("Positive", chunks[0].Metadata[SentimentEnricher.MetadataKey]); + Assert.Equal("Negative", chunks[1].Metadata[SentimentEnricher.MetadataKey]); + Assert.Equal("Neutral", chunks[2].Metadata[SentimentEnricher.MetadataKey]); + Assert.Equal("Unknown", chunks[3].Metadata[SentimentEnricher.MetadataKey]); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + SentimentEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new("I love programming! It's so much fun and rewarding.", _document), + new("I hate bugs. They are so frustrating and time-consuming.", _document), + new("The weather is okay, not too bad but not great either.", _document), + new("I hate you. I am sorry, I actually don't. I am not sure myself what my feelings are.", _document) + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs new file mode 100644 index 00000000000..8b0dcd904c4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class SummaryEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new SummaryEnricher(null!)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ThrowsOnInvalidMaxKeywords(int wordCount) + { + Assert.Throws("maxWordCount", () => new SummaryEnricher(new(new TestChatClient()), maxWordCount: wordCount)); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + SummaryEnricher sut = new(new(chatClient)); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Fact] + public async Task CanProvideSummary() + { + int counter = 0; + string[] summaries = { "First summary.", "Second summary." }; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = summaries }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + SummaryEnricher sut = new(new(chatClient)); + var input = CreateChunks().ToAsyncEnumerable(); + + var chunks = await sut.ProcessAsync(input).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.Equal(summaries[0], (string)chunks[0].Metadata[SummaryEnricher.MetadataKey]!); + Assert.Equal(summaries[1], (string)chunks[1].Metadata[SummaryEnricher.MetadataKey]!); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + SummaryEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new("I love programming! It's so much fun and rewarding.", _document), + new("I hate bugs. They are so frustrating and time-consuming.", _document) + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/DocumentReaderConformanceTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/DocumentReaderConformanceTests.cs new file mode 100644 index 00000000000..d4993ad2cea --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/DocumentReaderConformanceTests.cs @@ -0,0 +1,219 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DataIngestion.Tests.Utils; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public abstract class DocumentReaderConformanceTests +{ + private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + + protected abstract IngestionDocumentReader CreateDocumentReader(bool extractImages = false); + + [ConditionalFact] + public async Task ThrowsWhenIdentifierIsNotProvided() + { + var reader = CreateDocumentReader(); + + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: null!)); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: string.Empty)); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: null!, mediaType: "some")); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: string.Empty, mediaType: "some")); + } + + [ConditionalFact] + public async Task ThrowsIfCancellationRequestedStream() + { + var reader = CreateDocumentReader(); + using CancellationTokenSource cts = new(); + cts.Cancel(); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync(async () => await reader.ReadAsync(stream, "id", "mediaType", cts.Token)); + } + + [ConditionalFact] + public async Task ThrowsIfCancellationRequestedFile() + { + string filePath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName() + ".txt"); +#if NET + await File.WriteAllTextAsync(filePath, "This is a test file for cancellation token."); +#else + File.WriteAllText(filePath, "This is a test file for cancellation token."); +#endif + + var reader = CreateDocumentReader(); + using CancellationTokenSource cts = new(); + cts.Cancel(); + + try + { + await Assert.ThrowsAsync(async () => await reader.ReadAsync(new FileInfo(filePath), cts.Token)); + } + finally + { + File.Delete(filePath); + } + } + + public static TheoryData Links => + [ + "https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-NRBF/%5bMS-NRBF%5d-190313.pdf", // PDF file + "https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-NRBF/%5bMS-NRBF%5d-190313.docx", // DOCX file + "https://www.bondcap.com/report/pdf/Trends_Artificial_Intelligence.pdf", // PDF file (presentation) + ]; + + [ConditionalTheory] + [MemberData(nameof(Links))] + public virtual async Task SupportsStreams(string source) + { + using HttpResponseMessage response = await DownloadAsync(new(source)); + + IngestionDocument document = await CreateDocumentReader().ReadAsync( + await response.Content.ReadAsStreamAsync(), + source, mediaType: response.Content.Headers.ContentType?.MediaType!); + + SimpleAsserts(document, source, source); + } + + [ConditionalTheory] + [MemberData(nameof(Links))] + public virtual async Task SupportsFiles(string source) + { + FileInfo inputFile = await DownloadToFileAsync(new Uri(source)); + + try + { + IngestionDocument document = await CreateDocumentReader().ReadAsync(inputFile); + + SimpleAsserts(document, inputFile.FullName, inputFile.FullName); + } + finally + { + inputFile.Delete(); + } + } + + [ConditionalFact] + public virtual Task SupportsImages() => SupportsImagesCore( + new("https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MC-SQLR/%5bMC-SQLR%5d.pdf")); // SQL Server Resolution Protocol + + protected async Task SupportsImagesCore(Uri source) + { + FileInfo inputFile = await DownloadToFileAsync(source); + + try + { + var reader = CreateDocumentReader(extractImages: true); + var document = await reader.ReadAsync(inputFile); + + SimpleAsserts(document, inputFile.FullName, expectedId: inputFile.FullName); + var elements = document.EnumerateContent().ToArray(); + Assert.Contains(elements, element => element is IngestionDocumentImage img && img.Content.HasValue && !string.IsNullOrEmpty(img.MediaType)); + } + finally + { + inputFile.Delete(); + } + } + + [ConditionalFact] + public virtual async Task SupportsTables() + { + string[,] expected = + { + { "Milestone", "Target Date", "Department", "Indicator" }, + { "Environmental Audit", "Mar 2025", "Environmental", "Audit Complete" }, + { "Renewable Energy Launch", "Jul 2025", "Facilities", "Install Operational" }, + { "Staff Workshop", "Sep 2025", "HR", "Workshop Held" }, + { "Emissions Review", "Dec 2029", "All", "25% Emissions Cut" } + }; + using Stream wordDoc = DocxHelper.CreateDocumentWithTable(expected); + + var document = await CreateDocumentReader().ReadAsync(wordDoc, "doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + + IngestionDocumentTable documentTable = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(5, documentTable.Cells.GetLength(0)); + Assert.Equal(4, documentTable.Cells.GetLength(1)); + + Assert.Equal(expected, documentTable.Cells.Map(NormalizeCell)); + } + + protected static async Task DownloadAsync(Uri uri) + { + try + { + HttpResponseMessage response = await _httpClient.GetAsync(uri); + +#if !NET + // .NET Framework HttpClient does not automatically follow permanent redirects. + if (response.StatusCode == (System.Net.HttpStatusCode)308) + { + string? redirectUri = response.Headers.Location?.ToString(); + Assert.False(string.IsNullOrEmpty(redirectUri), "Redirect URI is null or empty."); + response.Dispose(); + response = await _httpClient.GetAsync(new Uri(redirectUri!)); + } +#endif + + Assert.True(response.IsSuccessStatusCode); + return response; + } + catch (Exception ex) + { + throw new SkipTestException($"Unable to download the test file: '{ex.Message}'"); + } + } + + protected static async Task DownloadToFileAsync(Uri uri) + { + using HttpResponseMessage response = await DownloadAsync(uri); + + string extension = response.Content.Headers.ContentType?.MediaType switch + { + "application/pdf" => ".pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ".docx", + _ when uri.OriginalString.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) => ".pdf", + _ when uri.OriginalString.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) => ".docx", + _ => string.Empty + }; + + FileInfo file = new(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + extension)); + using FileStream inputStream = new(file.FullName, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.Asynchronous); + await response.Content.CopyToAsync(inputStream); + + return file; + } + + protected virtual void SimpleAsserts(IngestionDocument document, string source, string expectedId) + { + Assert.NotNull(document); + Assert.Equal(expectedId, document.Identifier); + Assert.NotEmpty(document.Sections); + + var elements = document.EnumerateContent().ToArray(); + Assert.Contains(elements, element => element is IngestionDocumentHeader); + Assert.Contains(elements, element => element is IngestionDocumentParagraph); + Assert.Contains(elements, element => element is IngestionDocumentTable); + Assert.All(elements.Where(element => element is not IngestionDocumentImage), element => Assert.NotEmpty(element.GetMarkdown())); + } + + private static string? NormalizeCell(IngestionDocumentElement? ingestionDocumentElement) + { + Assert.NotNull(ingestionDocumentElement); + + // Some readers add extra spaces or asterisks for bold/italic text for headers. + return ingestionDocumentElement.GetMarkdown().Trim().Trim('*'); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownConditionAttribute.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownConditionAttribute.cs new file mode 100644 index 00000000000..b0169a54c1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownConditionAttribute.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using Microsoft.TestUtilities; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +/// +/// This class exists because currently the local copy of can't ignore tests that throw . +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class MarkItDownConditionAttribute : Attribute, ITestCondition +{ + internal static readonly Lazy IsInstalled = new(CanInvokeMarkItDown); + + public bool IsMet => IsInstalled.Value; + + public string SkipReason => "MarkItDown is not installed or not accessible."; + + private static bool CanInvokeMarkItDown() + { + ProcessStartInfo startInfo = new() + { + FileName = "markitdown", + Arguments = "--help", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }; + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Win32Exception) + { + return false; + } + + while (process.StandardOutput.Peek() >= 0) + { + _ = process.StandardOutput.ReadLine(); + } + + process.WaitForExit(); + + return true; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs new file mode 100644 index 00000000000..37142f8b20e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public class MarkItDownMcpReaderTests +{ + [Fact] + public void Constructor_ThrowsWhenMcpServerUriIsNull() + { + Assert.Throws("mcpServerUri", () => new MarkItDownMcpReader(null!)); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenIdentifierIsNull() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: null!)); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: string.Empty)); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: null!, mediaType: "some")); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: string.Empty, mediaType: "some")); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenSourceIsNull() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + + await Assert.ThrowsAsync("source", async () => await reader.ReadAsync(null!, "identifier")); + await Assert.ThrowsAsync("source", async () => await reader.ReadAsync((Stream)null!, "identifier", "mediaType")); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenFileDoesNotExist() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + var nonExistentFile = new FileInfo(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + + await Assert.ThrowsAsync(async () => await reader.ReadAsync(nonExistentFile, "identifier")); + } + + // NOTE: Integration tests with an actual MCP server would go here, but they would require + // a running MarkItDown MCP server to be available, which is not part of the test setup. + // For full integration testing, use a real MCP server in a separate test environment. +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownReaderTests.cs new file mode 100644 index 00000000000..e506ea15ca1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownReaderTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +[MarkItDownCondition] +public class MarkItDownReaderTests : DocumentReaderConformanceTests +{ + protected override IngestionDocumentReader CreateDocumentReader(bool extractImages = false) + => MarkItDownConditionAttribute.IsInstalled.Value + ? new MarkItDownReader(extractImages: extractImages) + : throw new SkipTestException("MarkItDown is not installed"); + + protected override void SimpleAsserts(IngestionDocument document, string source, string expectedId) + { + Assert.NotNull(document); + Assert.Equal(expectedId, document.Identifier); + Assert.NotEmpty(document.Sections); + + var elements = document.EnumerateContent().ToArray(); + + bool isPdf = source.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase); + if (!isPdf) + { + // MarkItDown does a bad job of recognizing Headers and Tables even for simple PDF files. + Assert.Contains(elements, element => element is IngestionDocumentHeader); + Assert.Contains(elements, element => element is IngestionDocumentTable); + } + + Assert.Contains(elements, element => element is IngestionDocumentParagraph); + Assert.All(elements, element => Assert.NotEmpty(element.GetMarkdown())); + } + + // The original purpose of the MarkItDown library was to support text-only LLMs. + // Source: https://github.com/microsoft/markitdown/issues/56#issuecomment-2546357264 + // It can extract images, but the support is limited to some formats like docx. + [ConditionalFact] + public override Task SupportsImages() => SupportsImagesCore( + new("https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MC-SQLR/%5bMC-SQLR%5d-240423.docx")); // SQL Server Resolution Protocol. +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs new file mode 100644 index 00000000000..dce6d996821 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public class MarkdownReaderTests : DocumentReaderConformanceTests +{ + protected override IngestionDocumentReader CreateDocumentReader(bool extractImages = false) => new MarkdownReader(); + + public static new TheoryData Links => + [ + "https://raw.githubusercontent.com/microsoft/markitdown/main/README.md" + ]; + + [ConditionalTheory] + [MemberData(nameof(Links))] + public override Task SupportsStreams(string source) => base.SupportsStreams(source); + + [ConditionalTheory] + [MemberData(nameof(Links))] + public override Task SupportsFiles(string source) => base.SupportsFiles(source); + + [ConditionalFact] + public override async Task SupportsTables() + { + string markdownContent = """ + # Key Milestones + + | **Milestone** | **Target Date** | **Department** | **Indicator** | + | --- | --- | --- | --- | + | Environmental Audit | Mar 2025 | Environmental | Audit Complete | + | Renewable Energy Launch | Jul 2025 | Facilities | Install Operational | + | Staff Workshop | Sep 2025 | HR | Workshop Held | + | Emissions Review | Dec 2029 | All | 25% Emissions Cut | + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + IngestionDocumentTable documentTable = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(5, documentTable.Cells.GetLength(0)); + Assert.Equal(4, documentTable.Cells.GetLength(1)); + + string[,] expected = + { + { "**Milestone**", "**Target Date**", "**Department**", "**Indicator**" }, + { "Environmental Audit", "Mar 2025", "Environmental", "Audit Complete" }, + { "Renewable Energy Launch", "Jul 2025", "Facilities", "Install Operational" }, + { "Staff Workshop", "Sep 2025", "HR", "Workshop Held" }, + { "Emissions Review", "Dec 2029", "All", "25% Emissions Cut" } + }; + + Assert.Equal(expected, documentTable.Cells.Map(element => element!.GetMarkdown().Trim())); + } + + [ConditionalFact] + public override async Task SupportsImages() + { + string contentType1 = "image/png"; + byte[] imageBytes1 = Enumerable.Range(0, 55).Select(i => (byte)i).ToArray(); + string contentType2 = "image/jpeg"; + byte[] imageBytes2 = Enumerable.Range(55, 111).Select(i => (byte)i).ToArray(); + string contentType3 = "image/newfancy"; + byte[] imageBytes3 = Enumerable.Range(166, 200).Select(i => (byte)i).ToArray(); + + string markdownContent = $""" + # All content types supported! + + PNG is fine! + + ![One](data:{contentType1};base64,{Convert.ToBase64String(imageBytes1)}) + + JPEG is also fine! + + ![Two](data:{contentType2};base64,{Convert.ToBase64String(imageBytes2)}) + + But what about a new fancy type? + + ![Three](data:{contentType3};base64,{Convert.ToBase64String(imageBytes3)}) + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + Assert.NotNull(document); + var images = document.EnumerateContent().OfType().ToArray(); + Assert.Equal(3, images.Length); + Assert.Equal(contentType1, images[0].MediaType); + Assert.Equal(imageBytes1, images[0].Content?.ToArray()); + Assert.Equal("One", images[0].AlternativeText); + Assert.Equal(contentType2, images[1].MediaType); + Assert.Equal(imageBytes2, images[1].Content?.ToArray()); + Assert.Equal("Two", images[1].AlternativeText); + Assert.Equal(contentType3, images[2].MediaType); + Assert.Equal(imageBytes3, images[2].Content?.ToArray()); + Assert.Equal("Three", images[2].AlternativeText); + } + + [ConditionalFact] + public async Task SupportsTablesWithImages() + { + byte[] imageBytes = Enumerable.Range(55, 111).Select(i => (byte)i).ToArray(); + string markdownContent = $""" + # Table with Images + + | **Years** | **Logo** | + | --- | --- | + | 2020-2025 | ![Latest logo](data:image/png;base64,{Convert.ToBase64String(imageBytes)}) | + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + var table = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(2, table.Cells.GetLength(0)); + Assert.Equal(2, table.Cells.GetLength(1)); + + // Each reader properly recognizes the text from the first column. + // When it comes to the images, MarkItDown extracts them as images, while + // other readers return nothing or ORCed text. + Assert.Equal("**Years**", table.Cells[0, 0]!.GetMarkdown().Trim()); + Assert.Equal("**Logo**", table.Cells[0, 1]!.GetMarkdown().Trim()); + Assert.Equal("2020-2025", table.Cells[1, 0]!.GetMarkdown().Trim()); + + IngestionDocumentImage img = Assert.IsType(table.Cells[1, 1]); + Assert.Equal("image/png", img.MediaType); + Assert.NotNull(img.Content); + Assert.False(img.Content.Value.IsEmpty); + Assert.Equal("Latest logo", img.AlternativeText); + } + + private async Task ReadAsync(string content) + { + using MemoryStream stream = new(System.Text.Encoding.UTF8.GetBytes(content)); + return await CreateDocumentReader().ReadAsync(stream, "id", "text/markdown"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ArrayUtils.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ArrayUtils.cs new file mode 100644 index 00000000000..133390eef47 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ArrayUtils.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class ArrayUtils +{ + internal static TTo[,] Map(this TFrom[,] from, Func mapFunc) + { + int rows = from.GetLength(0); + int cols = from.GetLength(1); + var to = new TTo[rows, cols]; + for (int i = 0; i < rows; i++) + { + for (int j = 0; j < cols; j++) + { + to[i, j] = mapFunc(from[i, j]); + } + } + + return to; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/DocxHelper.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/DocxHelper.cs new file mode 100644 index 00000000000..acfbab3e475 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/DocxHelper.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace Microsoft.Extensions.DataIngestion.Tests.Utils; + +#pragma warning disable IDE0007 // Use implicit type + +internal static class DocxHelper +{ + internal static Stream CreateDocumentWithTable(string[,] cells) + { + MemoryStream stream = new(); + + using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true)) + { + MainDocumentPart mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(); + Body body = mainPart.Document.AppendChild(new Body()); + + // Add a header + Paragraph headerPara = body.AppendChild(new Paragraph()); + Run headerRun = headerPara.AppendChild(new Run()); + headerRun.AppendChild(new Text("Key Milestones")); + headerRun.RunProperties = new(new Bold()); + + // Create table + Table table = new Table(); + + // Table properties + TableProperties tableProps = new( + new TableBorders( + new TopBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new BottomBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new LeftBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new RightBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new InsideHorizontalBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new InsideVerticalBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }) + ); + table.AppendChild(tableProps); + + // Create rows + for (int i = 0; i < cells.GetLength(0); i++) + { + TableRow row = new TableRow(); + + for (int j = 0; j < cells.GetLength(1); j++) + { + TableCell cell = new TableCell(); + + // Cell properties + TableCellProperties cellProps = new( + new TableCellMargin( + new TopMargin { Width = "100", Type = TableWidthUnitValues.Dxa }, + new BottomMargin { Width = "100", Type = TableWidthUnitValues.Dxa }, + new LeftMargin { Width = "100", Type = TableWidthUnitValues.Dxa }, + new RightMargin { Width = "100", Type = TableWidthUnitValues.Dxa }) + ); + cell.AppendChild(cellProps); + + // Cell content + Paragraph cellPara = new Paragraph(); + Run cellRun = new Run(); + cellRun.AppendChild(new Text(cells[i, j])); + + // Make header row bold + if (i == 0) + { + cellRun.RunProperties = new(new Bold()); + } + + cellPara.AppendChild(cellRun); + cell.AppendChild(cellPara); + row.AppendChild(cell); + } + + table.AppendChild(row); + } + + body.AppendChild(table); + } + + stream.Position = 0; + return stream; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs new file mode 100644 index 00000000000..d6fade6892f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.DataIngestion; + +internal class Envelope +{ +#pragma warning disable IDE1006 // Naming Styles + public T? data { get; set; } +#pragma warning restore IDE1006 // Naming Styles +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs new file mode 100644 index 00000000000..79d2e7538fd --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.DataIngestion; + +internal sealed class ExpectedException : Exception +{ + internal const string ExceptionMessage = "An expected exception occurred."; + + internal ExpectedException() + : base(ExceptionMessage) + { + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/IAsyncEnumerableExtensions.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/IAsyncEnumerableExtensions.cs new file mode 100644 index 00000000000..bb30b585233 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/IAsyncEnumerableExtensions.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +// Once .NET 10 is shipped, we are going to switch to System.Linq.AsyncEnumerable. +internal static class IAsyncEnumerableExtensions +{ + internal static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) + { + foreach (T item in source) + { + await Task.Yield(); + yield return item; + } + } + + internal static async ValueTask CountAsync(this IAsyncEnumerable source) + { + int count = 0; + await foreach (T _ in source) + { + count++; + } + + return count; + } + + internal static async ValueTask SingleAsync(this IAsyncEnumerable source) + { + bool found = false; + T result = default!; + await foreach (T item in source) + { + if (found) + { + throw new InvalidOperationException(); + } + + result = item; + found = true; + } + + return found + ? result + : throw new InvalidOperationException(); + } + + internal static async ValueTask> ToListAsync(this IAsyncEnumerable source) + { + List list = []; + await foreach (var item in source) + { + list.Add(item); + } + + return list; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs new file mode 100644 index 00000000000..611243684cd --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Extensions.DataIngestion; + +public sealed class TestEmbeddingGenerator : IEmbeddingGenerator> +{ + public const int DimensionCount = 4; + + public bool WasCalled { get; private set; } + + public void Dispose() + { + // No resources to dispose + } + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + WasCalled = true; + + return Task.FromResult(new GeneratedEmbeddings>( + [new(new float[] { 0, 1, 2, 3 })])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestReader.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestReader.cs new file mode 100644 index 00000000000..aa039de5e31 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestReader.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +internal sealed class TestReader : IngestionDocumentReader +{ + public TestReader(Func> readAsyncCallback) + { + ReadAsyncCallback = readAsyncCallback; + } + + public Func> ReadAsyncCallback { get; } + + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + => ReadAsyncCallback(source, identifier, mediaType, cancellationToken); +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs new file mode 100644 index 00000000000..b81b5a2aa79 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.InMemory; + +namespace Microsoft.Extensions.DataIngestion.Writers.Tests; + +public class InMemoryVectorStoreWriterTests : VectorStoreWriterTests +{ + protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + => new InMemoryVectorStore(new() { EmbeddingGenerator = testEmbeddingGenerator }); +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs new file mode 100644 index 00000000000..b596445b822 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.SqliteVec; + +namespace Microsoft.Extensions.DataIngestion.Writers.Tests; + +public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisposable +{ + private readonly string _tempFile = Path.GetTempFileName(); + + public void Dispose() => File.Delete(_tempFile); + + protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + => new SqliteVectorStore($"Data Source={_tempFile};Pooling=false", new() { EmbeddingGenerator = testEmbeddingGenerator }); +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs new file mode 100644 index 00000000000..bafdc93fabe --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.VectorData; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Writers.Tests; + +public abstract class VectorStoreWriterTests +{ + [Fact] + public async Task CanGenerateDynamicSchema() + { + string documentId = Guid.NewGuid().ToString(); + + using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); + using VectorStoreWriter writer = new( + vectorStore, + dimensionCount: TestEmbeddingGenerator.DimensionCount); + + IngestionDocument document = new(documentId); + List> chunks = + [ + new("some content", document) + { + Metadata = + { + { "key1", "value1" }, + { "key2", 123 }, + { "key3", true }, + { "key4", 123.45 }, + } + } + ]; + + Assert.False(testEmbeddingGenerator.WasCalled); + await writer.WriteAsync(chunks.ToAsyncEnumerable()); + + Dictionary record = await writer.VectorStoreCollection + .GetAsync(filter: record => (string)record["documentid"]! == documentId, top: 1) + .SingleAsync(); + + Assert.NotNull(record); + Assert.NotNull(record["key"]); + Assert.Equal(documentId, record["documentid"]); + Assert.Equal(chunks[0].Content, record["content"]); + Assert.True(testEmbeddingGenerator.WasCalled); + foreach (var kvp in chunks[0].Metadata) + { + Assert.True(record.ContainsKey(kvp.Key), $"Record does not contain key '{kvp.Key}'"); + Assert.Equal(kvp.Value, record[kvp.Key]); + } + } + + [Fact] + public async Task DoesSupportIncrementalIngestion() + { + string documentId = Guid.NewGuid().ToString(); + + using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); + using VectorStoreWriter writer = new( + vectorStore, + dimensionCount: TestEmbeddingGenerator.DimensionCount, + options: new() + { + IncrementalIngestion = true, + }); + + IngestionDocument document = new(documentId); + List> chunks = + [ + new("first chunk", document) + { + Metadata = + { + { "key1", "value1" } + } + }, + new("second chunk", document) + ]; + + await writer.WriteAsync(chunks.ToAsyncEnumerable()); + + int recordCount = await writer.VectorStoreCollection + .GetAsync(filter: record => (string)record["documentid"]! == documentId, top: 100) + .CountAsync(); + Assert.Equal(chunks.Count, recordCount); + + // Now we will do an incremental ingestion that updates the chunk(s). + List> updatedChunks = + [ + new("different content", document) + { + Metadata = + { + { "key1", "value2" }, + } + } + ]; + + await writer.WriteAsync(updatedChunks.ToAsyncEnumerable()); + + // We ask for 100 records, but we expect only 1 as the previous 2 should have been deleted. + Dictionary record = await writer.VectorStoreCollection + .GetAsync(filter: record => (string)record["documentid"]! == documentId, top: 100) + .SingleAsync(); + + Assert.NotNull(record); + Assert.NotNull(record["key"]); + Assert.Equal("different content", record["content"]); + Assert.Equal("value2", record["key1"]); + } + + protected abstract VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator); +} diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs index 61fcf232461..9e3227e92d2 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IAnotherFakeServiceCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs index 592a617d32f..a3d7b92cbb2 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFactoryServiceCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs index 772b0be54df..dea82dbdc7d 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFakeMultipleCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs index e7bc46ec661..e9573e4e34f 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFakeOpenGenericCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs index d7708a196d6..f8474f20599 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFakeServiceCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs index 16ec64fa003..2599be2dd9e 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs @@ -490,6 +490,7 @@ public async Task TestCpuAndMemoryChecks_WithMetrics( Mock processInfoMock = new(); var appMemoryUsage = memoryUsed; processInfoMock.Setup(p => p.GetMemoryUsage()).Returns(() => appMemoryUsage); + processInfoMock.Setup(p => p.GetCurrentProcessMemoryUsage()).Returns(() => appMemoryUsage); JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = default; limitInfo.JobMemoryLimit = new UIntPtr(totalMemory); @@ -500,6 +501,7 @@ public async Task TestCpuAndMemoryChecks_WithMetrics( accountingInfoAfter1Ms.TotalUserTime = (long)(utilization * 100); jobHandleMock.SetupSequence(j => j.GetBasicAccountingInfo()) .Returns(() => initialAccountingInfo) // this is called from the WindowsContainerSnapshotProvider's constructor + .Returns(() => initialAccountingInfo) // this is called from the WindowsContainerSnapshotProvider's GetCpuTime method .Returns(() => accountingInfoAfter1Ms); // this is called from the WindowsContainerSnapshotProvider's CpuPercentage method using var meter = new Meter("Microsoft.Extensions.Diagnostics.ResourceMonitoring"); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs index efaaea1a51a..1dd11c1f3bb 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs @@ -6,6 +6,9 @@ using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.IO; +#if !NET10_0 +using System.Linq; +#endif using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -209,6 +212,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var listener = new MeterListener(); var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; var cpuRequestFromGauge = 0.0d; @@ -217,10 +222,20 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -292,18 +307,38 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var listener = new MeterListener(); var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; var cpuRequestFromGauge = 0.0d; var memoryFromGauge = 0.0d; var memoryLimitFromGauge = 0.0d; + long memoryUsageFromGauge = 0; using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage) + { + memoryUsageFromGauge = value; + } + }); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -350,6 +385,7 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou Assert.Equal(1, roundedCpuUsedPercentage); Assert.Equal(50, utilization.MemoryUsedPercentage); + Assert.Equal(524288, memoryUsageFromGauge); Assert.Equal(0.5, cpuLimitFromGauge * 100); Assert.Equal(roundedCpuUsedPercentage, Math.Round(cpuRequestFromGauge * 100)); Assert.Equal(utilization.MemoryUsedPercentage, memoryLimitFromGauge * 100); @@ -362,86 +398,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou [ConditionalFact] [CombinatorialData] [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] - public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_v2() - { - var cpuRefresh = TimeSpan.FromMinutes(13); - var memoryRefresh = TimeSpan.FromMinutes(14); - var fileSystem = new HardcodedValueFileSystem(new Dictionary - { - { new FileInfo("/proc/self/cgroup"), "0::/fakeslice"}, - { new FileInfo("/proc/stat"), "cpu 10 10 10 10 10 10 10 10 10 10"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1020000\nnr_periods 50"}, - { new FileInfo("/sys/fs/cgroup/memory.max"), "1048576" }, - { new FileInfo("/proc/meminfo"), "MemTotal: 1024 kB"}, - { new FileInfo("/sys/fs/cgroup/cpuset.cpus.effective"), "0-19"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.max"), "40000 10000"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.weight"), "79"}, - }); - - using var listener = new MeterListener(); - var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); - var cpuFromGauge = 0.0d; - var cpuLimitFromGauge = 0.0d; - var cpuRequestFromGauge = 0.0d; - var memoryFromGauge = 0.0d; - var memoryLimitFromGauge = 0.0d; - using var e = new ManualResetEventSlim(); - - object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); - listener.Start(); - - using var host = FakeHost.CreateBuilder() - .ConfigureServices(x => - x.AddLogging() - .AddSingleton(clock) - .AddSingleton(new FakeUserHz(100)) - .AddSingleton(fileSystem) - .AddSingleton(new GenericPublisher(_ => e.Set())) - .AddResourceMonitoring(x => x.ConfigureMonitor(options => options.CalculateCpuUsageWithoutHostDelta = true)) - .Replace(ServiceDescriptor.Singleton())) - .Build(); - - meterScope = host.Services.GetRequiredService(); - var tracker = host.Services.GetService(); - Assert.NotNull(tracker); - - _ = host.RunAsync(); - - listener.RecordObservableInstruments(); - - var utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - - fileSystem.ReplaceFileContent(new FileInfo("/proc/stat"), "cpu 11 10 10 10 10 10 10 10 10 10"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1120000\nnr_periods 56"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.current"), "524298"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.stat"), "inactive_file 10"); - - clock.Advance(TimeSpan.FromSeconds(1)); - listener.RecordObservableInstruments(); - - e.Wait(); - - utilization = tracker.GetUtilization(TimeSpan.FromSeconds(1)); - - var roundedCpuUsedPercentage = Math.Round(utilization.CpuUsedPercentage, 1); - - Assert.Equal(0, Math.Round(cpuLimitFromGauge * 100)); - Assert.Equal(0, Math.Round(cpuRequestFromGauge * 100)); - - return Task.CompletedTask; - } - - [ConditionalFact] - [CombinatorialData] - [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] - public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_v2_Using_NrPeriods() + public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_Using_LinuxCalculationV2() { - var cpuRefresh = TimeSpan.FromMinutes(13); - var memoryRefresh = TimeSpan.FromMinutes(14); var fileSystem = new HardcodedValueFileSystem(new Dictionary { { new FileInfo("/proc/self/cgroup"), "0::/fakeslice"}, @@ -458,43 +416,48 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuRequestFromGauge = 0.0d; var memoryFromGauge = 0.0d; var memoryLimitFromGauge = 0.0d; - using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); listener.Start(); - using var host = FakeHost.CreateBuilder() + using IHost host = FakeHost.CreateBuilder() .ConfigureServices(x => x.AddLogging() .AddSingleton(clock) .AddSingleton(new FakeUserHz(100)) .AddSingleton(fileSystem) - .AddSingleton(new GenericPublisher(_ => e.Set())) .AddResourceMonitoring(x => x.ConfigureMonitor(options => { - options.CalculateCpuUsageWithoutHostDelta = true; - options.UseDeltaNrPeriodsForCpuCalculation = true; + options.UseLinuxCalculationV2 = true; })) .Replace(ServiceDescriptor.Singleton())) .Build(); meterScope = host.Services.GetRequiredService(); - var tracker = host.Services.GetService(); - Assert.NotNull(tracker); _ = host.RunAsync(); listener.RecordObservableInstruments(); - var utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - fileSystem.ReplaceFileContent(new FileInfo("/proc/stat"), "cpu 11 10 10 10 10 10 10 10 10 10"); fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1120000\nnr_periods 56"); fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.current"), "524298"); @@ -503,14 +466,10 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou clock.Advance(TimeSpan.FromSeconds(6)); listener.RecordObservableInstruments(); - e.Wait(); - - utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - - var roundedCpuUsedPercentage = Math.Round(utilization.CpuUsedPercentage, 1); - Assert.Equal(42, Math.Round(cpuLimitFromGauge * 100)); Assert.Equal(83, Math.Round(cpuRequestFromGauge * 100)); + Assert.Equal(167, Math.Round(cpuUserTime * 100)); + Assert.Equal(81, Math.Round(cpuKernelTime * 100)); return Task.CompletedTask; } @@ -525,19 +484,29 @@ private static void OnInstrumentPublished(Instrument instrument, MeterListener m #pragma warning disable S1067 // Expressions should not be too complex if (instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization || instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization || + instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime || instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization || instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization || - instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization) + instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization || + instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage) { meterListener.EnableMeasurementEvents(instrument); } #pragma warning restore S1067 // Expressions should not be too complex } - private static void OnMeasurementReceived( - Instrument instrument, double value, - ref double cpuFromGauge, ref double cpuLimitFromGauge, ref double cpuRequestFromGauge, - ref double memoryFromGauge, ref double memoryLimitFromGauge) +#pragma warning disable S107 // Methods should not have too many parameters + private static void OnMeasurementReceived(Instrument instrument, + double value, + ReadOnlySpan> tags, + ref double cpuUserTime, + ref double cpuKernelTime, + ref double cpuFromGauge, + ref double cpuLimitFromGauge, + ref double cpuRequestFromGauge, + ref double memoryFromGauge, + ref double memoryLimitFromGauge) +#pragma warning restore S107 // Methods should not have too many parameters { if (instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization) { @@ -547,6 +516,18 @@ private static void OnMeasurementReceived( { memoryFromGauge = value; } + else if (instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime) + { + var tagsArray = tags.ToArray(); + if (tagsArray.Contains(new KeyValuePair("cpu.mode", "user"))) + { + cpuUserTime = value; + } + else if (tagsArray.Contains(new KeyValuePair("cpu.mode", "system"))) + { + cpuKernelTime = value; + } + } else if (instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization) { cpuLimitFromGauge = value; diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/DiskStatsReaderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/DiskStatsReaderTests.cs new file mode 100644 index 00000000000..1f7738bb030 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/DiskStatsReaderTests.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Test; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk.Test; + +[OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] +public class DiskStatsReaderTests +{ + private static readonly string[] _skipDevicePrefixes = new[] { "ram", "loop", "dm-" }; + + [Fact] + public void Test_ReadAll_Valid_DiskStats() + { + string diskStatsFileContent = + " 7 0 loop0 269334 0 12751202 147117 11604772 0 97447664 1402945 0 12193892 2255752 0 0 0 0 1206808 705690\n" + + " 7 1 loop1 965348 0 28605866 474103 73636257 0 1211288288 14086242 0 60580032 24777643 0 0 0 0 18723136 10217297\n" + + " 7 2 loop2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n" + + " 259 1 nvme1n1 4180498 5551 247430002 746099 96474435 12677267 2160066791 23514624 0 68786140 29777259 0 0 0 0 22111407 5516535\n" + + " 259 2 nvme1n1p1 4180387 5551 247422458 746080 96474435 12677267 2160066791 23514624 0 68786108 24260705 0 0 0 0 0 0\n" + + " 259 0 nvme0n1 6090587 689465 1120208521 1810566 19069165 8947684 406356430 3897150 0 38134844 6246643 69106 0 271818368 23139 1659742 515787\n" + + " 259 3 nvme0n1p1 378 0 26406 96 0 0 0 0 0 760 96 0 0 0 0 0 0\n" + + " 259 4 nvme0n1p2 7301 26408 116617 3628 600 47 59970 98 0 1196 3767 48 0 33106424 40 0 0\n" + + " 259 5 nvme0n1p3 6079544 663057 1119819306 1806337 19068535 8947637 406296460 3897045 0 38130316 5726482 69058 0 238711944 23098 0 0\n" + + " 252 0 dm-0 1303410 0 10434296 166616 1812455 0 14879824 1213588 0 397256 1380204 0 0 0 0 0 0\n" + + " 252 1 dm-1 712122 0 38299466 140852 18159197 0 286348832 1552768 0 14182384 1716692 69058 0 238711944 23072 0 0\n" + + " 252 5 dm-5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n" + + " 252 7 dm-7 6828 0 360325 2100 14438 0 1149672 1508 0 7524 3608 0 0 0 0 0 0\n" + + " 8 0 sda 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n" + + " 252 8 dm-8 100601 0 2990980 23940 3097278 0 32037680 1410540 0 5488608 1434496 513 0 67108872 16 0 0\n"; + + var fileSystem = new HardcodedValueFileSystem(new Dictionary + { + { new FileInfo("/proc/diskstats"), diskStatsFileContent } + }); + + var reader = new DiskStatsReader(fileSystem); + var dictionary = reader.ReadAll(_skipDevicePrefixes).ToDictionary(x => x.DeviceName); + + var expectedDevices = new[] + { + "nvme1n1", "nvme1n1p1", "nvme0n1", "nvme0n1p1", "nvme0n1p2", "nvme0n1p3", "sda" + }; + Assert.Equal(expectedDevices.Length, dictionary.Count); + foreach (var device in expectedDevices) + { + Assert.True(dictionary.ContainsKey(device), $"Expected device {device} to be present."); + } + + var disk1 = dictionary["nvme0n1"]; + Assert.Equal(6_090_587u, disk1.ReadsCompleted); + Assert.Equal(689_465u, disk1.ReadsMerged); + Assert.Equal(1_120_208_521u, disk1.SectorsRead); + Assert.Equal(1_810_566u, disk1.TimeReadingMs); + Assert.Equal(19_069_165u, disk1.WritesCompleted); + Assert.Equal(8_947_684u, disk1.WritesMerged); + Assert.Equal(406_356_430u, disk1.SectorsWritten); + Assert.Equal(3_897_150u, disk1.TimeWritingMs); + Assert.Equal(0u, disk1.IoInProgress); + Assert.Equal(38_134_844u, disk1.TimeIoMs); + Assert.Equal(6_246_643u, disk1.WeightedTimeIoMs); + Assert.Equal(69_106u, disk1.DiscardsCompleted); + Assert.Equal(0u, disk1.DiscardsMerged); + Assert.Equal(271_818_368u, disk1.SectorsDiscarded); + Assert.Equal(23_139u, disk1.TimeDiscardingMs); + Assert.Equal(1_659_742u, disk1.FlushRequestsCompleted); + Assert.Equal(515_787u, disk1.TimeFlushingMs); + + var disk2 = dictionary["sda"]; + Assert.Equal(0u, disk2.ReadsCompleted); + Assert.Equal(0u, disk2.ReadsMerged); + Assert.Equal(0u, disk2.SectorsRead); + Assert.Equal(0u, disk2.TimeReadingMs); + Assert.Equal(0u, disk2.WritesCompleted); + Assert.Equal(0u, disk2.WritesMerged); + Assert.Equal(0u, disk2.SectorsWritten); + Assert.Equal(0u, disk2.TimeWritingMs); + Assert.Equal(0u, disk2.IoInProgress); + Assert.Equal(0u, disk2.TimeIoMs); + Assert.Equal(0u, disk2.WeightedTimeIoMs); + } + + [Fact] + public void Test_ReadAll_With_Invalid_Lines() + { + string diskStatsFileContent = + " 259 1 nvme1n1 4180498 5551 247430002 746099 96474435 12677267 2160066791 23514624 0 68786140 29777259 0 0 0 0 22111407 5516535\n" + + " 259 2 nvme1n1p1 4180387 5551 247422458\n" + + " 259 2 nvme1n1p1 4180387 5551 247422458 746080 96474435 12677267 2160066791 23514624 0 68786108 24260705 0 0 0 0 0 0\n" + + " 259 0 nvme0n1 6090587 689465 1120208521 1810566 19069165 8947684 406356430 3897150 0 38134844 6246643 69106 0 271818368 23139 1659742 515787\n" + + " 259 nvme0n1p1 378 0 26406 96 0 0 0 0 0 760 96 0 0 0 0 0 0\n"; + + var fileSystem = new HardcodedValueFileSystem(new Dictionary + { + { new FileInfo("/proc/diskstats"), diskStatsFileContent } + }); + + var reader = new DiskStatsReader(fileSystem); + var dictionary = reader.ReadAll(_skipDevicePrefixes).ToDictionary(x => x.DeviceName); + Assert.Equal(3, dictionary.Count); + + var disk1 = dictionary["nvme1n1"]; + Assert.Equal(4_180_498u, disk1.ReadsCompleted); + Assert.Equal(5_551u, disk1.ReadsMerged); + Assert.Equal(247_430_002u, disk1.SectorsRead); + Assert.Equal(746_099u, disk1.TimeReadingMs); + Assert.Equal(96_474_435u, disk1.WritesCompleted); + Assert.Equal(12_677_267u, disk1.WritesMerged); + Assert.Equal(2_160_066_791u, disk1.SectorsWritten); + Assert.Equal(23_514_624u, disk1.TimeWritingMs); + Assert.Equal(0u, disk1.IoInProgress); + Assert.Equal(68_786_140u, disk1.TimeIoMs); + Assert.Equal(29_777_259u, disk1.WeightedTimeIoMs); + Assert.Equal(0u, disk1.DiscardsCompleted); + Assert.Equal(0u, disk1.DiscardsMerged); + Assert.Equal(0u, disk1.SectorsDiscarded); + Assert.Equal(0u, disk1.TimeDiscardingMs); + Assert.Equal(22_111_407u, disk1.FlushRequestsCompleted); + Assert.Equal(5_516_535u, disk1.TimeFlushingMs); + + var disk2 = dictionary["nvme1n1p1"]; + Assert.NotNull(disk2); + + var disk3 = dictionary["nvme0n1"]; + Assert.NotNull(disk3); + } + + [Fact] + public void Test_ReadAll_Skips_Prefixes() + { + string diskStatsFileContent = + " 7 0 loop0 100 0 1000 10 1000 0 10000 100 0 1000 100 0 0 0 0 100 100\n" + + " 1 0 ram0 200 0 2000 20 2000 0 20000 200 0 2000 200 0 0 0 0 200 200\n" + + " 259 0 nvme0n1 300 0 3000 30 3000 0 30000 300 0 3000 300 0 0 0 0 300 300\n" + + " 252 0 dm-0 400 0 4000 40 4000 0 40000 400 0 4000 400 0 0 0 0 400 400\n" + + " 8 0 sda 500 0 5000 50 5000 0 50000 500 0 5000 500 0 0 0 0 500 500\n"; + + var fileSystem = new HardcodedValueFileSystem(new Dictionary + { + { new FileInfo("/proc/diskstats"), diskStatsFileContent } + }); + + var reader = new DiskStatsReader(fileSystem); + var dictionary = reader.ReadAll(_skipDevicePrefixes).ToDictionary(x => x.DeviceName); + + Assert.DoesNotContain("loop0", dictionary.Keys); + Assert.DoesNotContain("ram0", dictionary.Keys); + Assert.DoesNotContain("dm-0", dictionary.Keys); + Assert.Contains("nvme0n1", dictionary.Keys); + Assert.Contains("sda", dictionary.Keys); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/FakeDiskStatsReader.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/FakeDiskStatsReader.cs new file mode 100644 index 00000000000..4b8186c8fb7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/FakeDiskStatsReader.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk.Test; + +internal class FakeDiskStatsReader(Dictionary> stats) : IDiskStatsReader +{ + private int _index; + + public DiskStats[] ReadAll(string[] skipDevicePrefixes) + { + if (_index >= stats.Values.First().Count) + { + throw new InvalidOperationException("No more values available."); + } + + DiskStats[] result = stats.Values.Select(x => x[_index]).ToArray(); + _index++; + return result; + } +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/LinuxSystemDiskMetricsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/LinuxSystemDiskMetricsTests.cs new file mode 100644 index 00000000000..80ebc818894 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Disk/LinuxSystemDiskMetricsTests.cs @@ -0,0 +1,314 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Shared.Instruments; +using Microsoft.TestUtilities; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk.Test; + +[OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] +public class LinuxSystemDiskMetricsTests +{ + private static readonly string[] _skipDevicePrefixes = new[] { "ram", "loop", "dm-" }; + private readonly FakeLogger _fakeLogger = new(); + + [Fact] + public void Creates_Meter_With_Correct_Name() + { + using var meterFactory = new TestMeterFactory(); + var diskStatsReaderMock = new Mock(); + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; + + _ = new LinuxSystemDiskMetrics( + _fakeLogger, + meterFactory, + Options.Options.Create(options), + TimeProvider.System, + diskStatsReaderMock.Object); + + Meter meter = meterFactory.Meters.Single(); + Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); + } + + [Fact] + public void Test_MetricValues() + { + using var meterFactory = new TestMeterFactory(); + var fakeTimeProvider = new FakeTimeProvider(); + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; + + // Set up + var diskStatsReader = new FakeDiskStatsReader(new Dictionary> + { + { + "sda", [ + new DiskStats + { + DeviceName = "sda", + SectorsRead = 0, + SectorsWritten = 0, + ReadsCompleted = 0, + WritesCompleted = 0, + TimeIoMs = 0 + }, + new DiskStats + { + DeviceName = "sda", + SectorsRead = 500, + SectorsWritten = 1000, + ReadsCompleted = 600, + WritesCompleted = 1200, + TimeIoMs = 1234 + }, + new DiskStats + { + DeviceName = "sda", + SectorsRead = 700, + SectorsWritten = 1100, + ReadsCompleted = 800, + WritesCompleted = 1300, + TimeIoMs = 2234 + }, + new DiskStats + { + DeviceName = "sda", + SectorsRead = 1000, + SectorsWritten = 1600, + ReadsCompleted = 1300, + WritesCompleted = 1350, + TimeIoMs = 4444 + } + ] + }, + { + "sdb", [ + new DiskStats + { + DeviceName = "sdb", + SectorsRead = 200, + SectorsWritten = 300, + ReadsCompleted = 400, + WritesCompleted = 500, + TimeIoMs = 6000 + }, + new DiskStats + { + DeviceName = "sdb", + SectorsRead = 350, + SectorsWritten = 450, + ReadsCompleted = 550, + WritesCompleted = 650, + TimeIoMs = 7500 + }, + new DiskStats + { + DeviceName = "sdb", + SectorsRead = 400, + SectorsWritten = 500, + ReadsCompleted = 600, + WritesCompleted = 700, + TimeIoMs = 7500 + }, + new DiskStats + { + DeviceName = "sdb", + SectorsRead = 550, + SectorsWritten = 650, + ReadsCompleted = 750, + WritesCompleted = 850, + TimeIoMs = 9500 + } + ] + }, + }); + + _ = new LinuxSystemDiskMetrics( + _fakeLogger, + meterFactory, + Options.Options.Create(options), + fakeTimeProvider, + diskStatsReader); + Meter meter = meterFactory.Meters.Single(); + + var readTag = new KeyValuePair("disk.io.direction", "read"); + var writeTag = new KeyValuePair("disk.io.direction", "write"); + var deviceTagSda = new KeyValuePair("system.device", "sda"); + var deviceTagSdb = new KeyValuePair("system.device", "sdb"); + + using var diskIoCollector = new MetricCollector(meter, ResourceUtilizationInstruments.SystemDiskIo); + using var operationCollector = new MetricCollector(meter, ResourceUtilizationInstruments.SystemDiskOperations); + using var ioTimeCollector = new MetricCollector(meter, ResourceUtilizationInstruments.SystemDiskIoTime); + + // 1st measurement + diskIoCollector.RecordObservableInstruments(); + operationCollector.RecordObservableInstruments(); + ioTimeCollector.RecordObservableInstruments(); + + // Assert the 1st measurement + var diskIoMeasurement = diskIoCollector.GetMeasurementSnapshot(); + Assert.Equal(4, diskIoMeasurement.Count); + Assert.Equal(256_000, diskIoMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSda)).Value); // (500 - 0) * 512 = 256000 + Assert.Equal(76_800, diskIoMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSdb)).Value); // (350 - 200) * 512 = 76800 + Assert.Equal(512_000, diskIoMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSda)).Value); // (1000 - 0) * 512 = 512000 + Assert.Equal(76_800, diskIoMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSdb)).Value); // (450 - 300) * 512 = 76800 + var operationMeasurement = operationCollector.GetMeasurementSnapshot(); + Assert.Equal(4, operationMeasurement.Count); + Assert.Equal(600, operationMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSda)).Value); // 600 - 0 = 600 + Assert.Equal(150, operationMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSdb)).Value); // 550 - 400 = 150 + Assert.Equal(1200, operationMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSda)).Value); // 1200 - 0 = 1200 + Assert.Equal(150, operationMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSdb)).Value); // 650 - 500 = 150 + var ioTimeMeasurement = ioTimeCollector.GetMeasurementSnapshot(); + Assert.Equal(2, ioTimeMeasurement.Count); + Assert.Equal(1.234, ioTimeMeasurement.Last(x => x.MatchesTags(deviceTagSda)).Value, 0.01); // (1234 - 0) / 1000 = 1.234 + Assert.Equal(1.5, ioTimeMeasurement.Last(x => x.MatchesTags(deviceTagSdb)).Value, 0.01); // (7500 - 6000) / 1000 = 6.0 + + // 2nd measurement + fakeTimeProvider.Advance(TimeSpan.FromMinutes(1)); + diskIoCollector.RecordObservableInstruments(); + operationCollector.RecordObservableInstruments(); + ioTimeCollector.RecordObservableInstruments(); + + // Assert the 2nd measurement + diskIoMeasurement = diskIoCollector.GetMeasurementSnapshot(); + Assert.Equal(358_400, diskIoMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSda)).Value); // (700 - 0) * 512 = 358400 + Assert.Equal(102_400, diskIoMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSdb)).Value); // (400 - 200) * 512 = 102400 + Assert.Equal(563_200, diskIoMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSda)).Value); // (1100 - 0) * 512 = 563200 + Assert.Equal(102_400, diskIoMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSdb)).Value); // (500 - 300) * 512 = 102400 + operationMeasurement = operationCollector.GetMeasurementSnapshot(); + Assert.Equal(800, operationMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSda)).Value); // 800 - 0 = 800 + Assert.Equal(200, operationMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSdb)).Value); // 600 - 400 = 200 + Assert.Equal(1300, operationMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSda)).Value); // 1300 - 0 = 1300 + Assert.Equal(200, operationMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSdb)).Value); // 700 - 500 = 200 + ioTimeMeasurement = ioTimeCollector.GetMeasurementSnapshot(); + Assert.Equal(2.234, ioTimeMeasurement.Last(x => x.MatchesTags(deviceTagSda)).Value, 0.01); // (2234 - 0) / 1000 = 2.234 + Assert.Equal(1.5, ioTimeMeasurement.Last(x => x.MatchesTags(deviceTagSdb)).Value, 0.01); // (7500 - 6000) / 1000 = 1.5 + + // 3rd measurement + fakeTimeProvider.Advance(TimeSpan.FromMinutes(1)); + diskIoCollector.RecordObservableInstruments(); + operationCollector.RecordObservableInstruments(); + ioTimeCollector.RecordObservableInstruments(); + + // Assert the 3rd measurement + diskIoMeasurement = diskIoCollector.GetMeasurementSnapshot(); + Assert.Equal(512_000, diskIoMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSda)).Value); // (1000 - 0) * 512 = 512000 + Assert.Equal(179_200, diskIoMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSdb)).Value); // (550 - 200) * 512 = 179200 + Assert.Equal(819_200, diskIoMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSda)).Value); // (1600 - 0) * 512 = 819200 + Assert.Equal(179_200, diskIoMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSdb)).Value); // (650 - 300) * 512 = 179200 + operationMeasurement = operationCollector.GetMeasurementSnapshot(); + Assert.Equal(1300, operationMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSda)).Value); // 1300 - 0 = 1300 + Assert.Equal(350, operationMeasurement.Last(x => x.MatchesTags(readTag, deviceTagSdb)).Value); // 750 - 400 = 350 + Assert.Equal(1350, operationMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSda)).Value); // 1350 - 0 = 1350 + Assert.Equal(350, operationMeasurement.Last(x => x.MatchesTags(writeTag, deviceTagSdb)).Value); // 850 - 500 = 350 + ioTimeMeasurement = ioTimeCollector.GetMeasurementSnapshot(); + Assert.Equal(4.444, ioTimeMeasurement.Last(x => x.MatchesTags(deviceTagSda)).Value, 0.01); // (4444 - 0) / 1000 = 4.444 + Assert.Equal(3.5, ioTimeMeasurement.Last(x => x.MatchesTags(deviceTagSdb)).Value, 0.01); // (9500 - 6000) / 1000 = 3.5 + } + + [Fact] + public void GetAllDiskStats_RetriesAfterFailureInterval() + { + using var meterFactory = new TestMeterFactory(); + var fakeTimeProvider = new FakeTimeProvider(); + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; + + var diskStats = new DiskStats + { + DeviceName = "sda", + SectorsRead = 100, + SectorsWritten = 200, + ReadsCompleted = 10, + WritesCompleted = 20, + TimeIoMs = 1000 + }; + + var diskStatsReaderMock = new Mock(); + diskStatsReaderMock.Setup(r => r.ReadAll(_skipDevicePrefixes)).Throws(); + + var metrics = new LinuxSystemDiskMetrics( + _fakeLogger, + meterFactory, + Options.Options.Create(options), + fakeTimeProvider, + diskStatsReaderMock.Object); + + using var ioCollector = new MetricCollector(meterFactory.Meters.Single(), ResourceUtilizationInstruments.SystemDiskIo); + + ioCollector.RecordObservableInstruments(); + Assert.Empty(ioCollector.GetMeasurementSnapshot()); + diskStatsReaderMock.Verify(r => r.ReadAll(_skipDevicePrefixes), Times.Once); + + ioCollector.RecordObservableInstruments(); + Assert.Empty(ioCollector.GetMeasurementSnapshot()); + diskStatsReaderMock.Verify(r => r.ReadAll(_skipDevicePrefixes), Times.Once); + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(1))); + + ioCollector.RecordObservableInstruments(); + Assert.Empty(ioCollector.GetMeasurementSnapshot()); + diskStatsReaderMock.Verify(r => r.ReadAll(_skipDevicePrefixes), Times.Exactly(2)); + + diskStatsReaderMock.Reset(); + diskStatsReaderMock.Setup(r => r.ReadAll(_skipDevicePrefixes)).Returns(new DiskStats[] { diskStats }); + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(1))); + + ioCollector.RecordObservableInstruments(); + var measurements = ioCollector.GetMeasurementSnapshot(); + Assert.NotEmpty(measurements); + Assert.Contains(measurements, m => m.Tags.Any(t => t.Value?.ToString() == "sda")); + diskStatsReaderMock.Verify(r => r.ReadAll(_skipDevicePrefixes), Times.Once); + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(1))); + + ioCollector.RecordObservableInstruments(); + measurements = ioCollector.GetMeasurementSnapshot(); + Assert.NotEmpty(measurements); + Assert.Contains(measurements, m => m.Tags.Any(t => t.Value?.ToString() == "sda")); + diskStatsReaderMock.Verify(r => r.ReadAll(_skipDevicePrefixes), Times.Exactly(2)); + } + + [Fact] + public void Metrics_Are_Not_Created_When_ReadAll_Throws_FileNotFoundException() + { + using var meterFactory = new TestMeterFactory(); + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; + var diskStatsReaderMock = new Mock(); + diskStatsReaderMock.Setup(r => r.ReadAll(_skipDevicePrefixes)).Throws(); + + var fakeTimeProvider = new FakeTimeProvider(); + + _ = new LinuxSystemDiskMetrics( + _fakeLogger, + meterFactory, + Options.Options.Create(options), + fakeTimeProvider, + diskStatsReaderMock.Object); + + Meter meter = meterFactory.Meters.Single(); + + using var diskIoCollector = new MetricCollector(meter, ResourceUtilizationInstruments.SystemDiskIo); + using var operationCollector = new MetricCollector(meter, ResourceUtilizationInstruments.SystemDiskOperations); + using var ioTimeCollector = new MetricCollector(meter, ResourceUtilizationInstruments.SystemDiskIoTime); + + diskIoCollector.RecordObservableInstruments(); + operationCollector.RecordObservableInstruments(); + ioTimeCollector.RecordObservableInstruments(); + + Assert.Empty(diskIoCollector.GetMeasurementSnapshot()); + Assert.Empty(operationCollector.GetMeasurementSnapshot()); + Assert.Empty(ioTimeCollector.GetMeasurementSnapshot()); + diskStatsReaderMock.Verify(r => r.ReadAll(_skipDevicePrefixes), Times.Once); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxCountersTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxCountersTests.cs index e0ff3880075..4f8dbf9547f 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxCountersTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxCountersTests.cs @@ -1,12 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Diagnostics.Metrics; using System.IO; using System.Threading; using FluentAssertions; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network; +using Microsoft.Extensions.Time.Testing; using Microsoft.Shared.Instruments; using Moq; using Xunit; @@ -85,7 +87,7 @@ public void LinuxNetworkCounters_Registers_Instruments() .Returns(meter); var tcpStateInfo = new LinuxTcpStateInfo(options, parser); - var lnm = new LinuxNetworkMetrics(meterFactoryMock.Object, tcpStateInfo); + var lnm = new LinuxNetworkMetrics(meterFactoryMock.Object, tcpStateInfo, new FakeTimeProvider(DateTimeOffset.UtcNow)); using var listener = new MeterListener { diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs index 6668ebe811c..40536a3245c 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxNetworkMetricsTests.cs @@ -1,10 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; using System.Diagnostics.Metrics; +using System.IO; using System.Linq; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; +using Microsoft.Extensions.Time.Testing; using Microsoft.Shared.Instruments; using Microsoft.TestUtilities; using Moq; @@ -15,14 +19,120 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Test; [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] public class LinuxNetworkMetricsTests { + private readonly Mock _tcpStateInfoProvider = new(); + private readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow; + private FakeTimeProvider _timeProvider; + + public LinuxNetworkMetricsTests() + { + _timeProvider = new FakeTimeProvider(_startTime); + + _tcpStateInfoProvider.Setup(p => p.GetIpV4TcpStateInfo()).Returns(new TcpStateInfo()); + _tcpStateInfoProvider.Setup(p => p.GetIpV6TcpStateInfo()).Returns(new TcpStateInfo()); + } + [Fact] - public void Creates_Meter_With_Correct_Name() + public void CreatesMeter_WithCorrectName() { using var meterFactory = new TestMeterFactory(); - var tcpStateInfoProviderMock = new Mock(); - _ = new LinuxNetworkMetrics(meterFactory, tcpStateInfoProviderMock.Object); + _ = new LinuxNetworkMetrics( + meterFactory, + _tcpStateInfoProvider.Object, + _timeProvider); Meter meter = meterFactory.Meters.Single(); Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); } + + [Fact] + public void GetTcpStateInfoWithRetry_SuccessfulCall_ReturnsState() + { + var expectedV4 = new TcpStateInfo { ClosedCount = 42 }; + var expectedV6 = new TcpStateInfo { EstabCount = 24 }; + _tcpStateInfoProvider.Setup(p => p.GetIpV4TcpStateInfo()).Returns(expectedV4); + _tcpStateInfoProvider.Setup(p => p.GetIpV6TcpStateInfo()).Returns(expectedV6); + + LinuxNetworkMetrics metrics = CreateMetrics(); + List> measurements = metrics.GetMeasurements().ToList(); + + Assert.Contains(measurements, m => HasTagWithValue(m, "network.type", "ipv4", 42)); + Assert.Contains(measurements, m => HasTagWithValue(m, "system.network.state", "close", 42)); + Assert.Contains(measurements, m => HasTagWithValue(m, "network.type", "ipv6", 24)); + Assert.Contains(measurements, m => HasTagWithValue(m, "system.network.state", "established", 24)); + } + + [Theory] + [InlineData(typeof(FileNotFoundException))] + [InlineData(typeof(DirectoryNotFoundException))] + [InlineData(typeof(UnauthorizedAccessException))] + public void GetTcpStateInfoWithRetry_Failure_SetsUnavailableAndReturnsDefault(Type exceptionType) + { + _tcpStateInfoProvider.Setup(p => p.GetIpV4TcpStateInfo()).Throws((Exception)Activator.CreateInstance(exceptionType)!); + + LinuxNetworkMetrics metrics = CreateMetrics(); + List> measurements = metrics.GetMeasurements().ToList(); + + Assert.All(measurements.Take(11), m => Assert.Equal(0, m.Value)); + } + + [Fact] + public void GetTcpStateInfoWithRetry_DuringRetryInterval_ReturnsDefault() + { + _tcpStateInfoProvider.SetupSequence(p => p.GetIpV4TcpStateInfo()) + .Throws(new FileNotFoundException()) + .Returns(new TcpStateInfo { ClosedCount = 123 }); + + LinuxNetworkMetrics metrics = CreateMetrics(); + List> first = metrics.GetMeasurements().ToList(); + + _timeProvider.Advance(TimeSpan.FromMinutes(2)); + List> second = metrics.GetMeasurements().ToList(); + + Assert.All(first.Take(11), m => Assert.Equal(0, m.Value)); + Assert.All(second.Take(11), m => Assert.Equal(0, m.Value)); + _tcpStateInfoProvider.Verify(p => p.GetIpV4TcpStateInfo(), Times.Once); + } + + [Fact] + public void GetTcpStateInfoWithRetry_AfterRetryInterval_ResetsUnavailableOnSuccess() + { + _tcpStateInfoProvider.SetupSequence(p => p.GetIpV4TcpStateInfo()) + .Throws(new FileNotFoundException()) + .Returns(new TcpStateInfo { ClosedCount = 99 }); + + LinuxNetworkMetrics metrics = CreateMetrics(); + List> first = metrics.GetMeasurements().ToList(); + + _timeProvider.Advance(TimeSpan.FromMinutes(6)); + List> second = metrics.GetMeasurements().ToList(); + + Assert.All(first.Take(11), m => Assert.Equal(0, m.Value)); + Assert.Equal(99, second[0].Value); + Assert.Contains(second, m => HasTagWithValue(m, "network.type", "ipv4", 99)); + Assert.Contains(second, m => HasTagWithValue(m, "system.network.state", "close", 99)); + + _tcpStateInfoProvider.Verify(p => p.GetIpV4TcpStateInfo(), Times.Exactly(2)); + } + + private static bool HasTagWithValue(Measurement measurement, string tagKey, string tagValue, long expectedValue) + { + foreach (KeyValuePair tag in measurement.Tags) + { + if (tag.Key == tagKey && string.Equals(tag.Value as string, tagValue, StringComparison.Ordinal)) + { + return measurement.Value == expectedValue; + } + } + + return false; + } + + private LinuxNetworkMetrics CreateMetrics() + { + using var meterFactory = new TestMeterFactory(); + return new LinuxNetworkMetrics( + meterFactory, + _tcpStateInfoProvider.Object, + _timeProvider); + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index e6e9a282eca..c60ec5fa834 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Time.Testing; using Microsoft.Shared.Instruments; using Microsoft.TestUtilities; using Moq; @@ -71,10 +72,18 @@ public void Provider_Registers_Instruments() } }); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + listener.Start(); listener.RecordObservableInstruments(); - Assert.Equal(5, samples.Count); + Assert.Equal(6, samples.Count); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization).value)); @@ -85,6 +94,9 @@ public void Provider_Registers_Instruments() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization); Assert.Equal(0.5, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization).value); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage); + Assert.Equal(524288, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage).value); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization).value)); @@ -212,7 +224,7 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() { var meterName = Guid.NewGuid().ToString(); var logger = new FakeLogger(); - var options = Options.Options.Create(new ResourceMonitoringOptions { CalculateCpuUsageWithoutHostDelta = true }); + var options = Options.Options.Create(new ResourceMonitoringOptions { UseLinuxCalculationV2 = true }); using var meter = new Meter(nameof(Provider_Registers_Instruments_CgroupV2_WithoutHostCpu)); var meterFactoryMock = new Mock(); meterFactoryMock.Setup(x => x.Create(It.IsAny())) @@ -258,7 +270,7 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() listener.Start(); listener.RecordObservableInstruments(); - Assert.Equal(4, samples.Count); + Assert.Equal(6, samples.Count); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization).value)); @@ -266,10 +278,142 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization).value)); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime); + Assert.All(samples.Where(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime), item => double.IsNaN(item.value)); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization); Assert.Equal(1, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization).value); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); Assert.Equal(1, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization).value); } + + [Fact] + public void Provider_GetMeasurementWithRetry_HandlesExceptionAndRecovers() + { + var meterName = Guid.NewGuid().ToString(); + var logger = new FakeLogger(); + var options = Options.Options.Create(new ResourceMonitoringOptions()); + using var meter = new Meter(nameof(Provider_GetMeasurementWithRetry_HandlesExceptionAndRecovers)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + + var callCount = 0; + var parserMock = new Mock(); + parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => + { + callCount++; + if (callCount <= 1) + { + throw new FileNotFoundException("Simulated failure to read file"); + } + + return 420UL; + }); + parserMock.Setup(p => p.GetAvailableMemoryInBytes()).Returns(1000UL); + parserMock.Setup(p => p.GetCgroupRequestCpu()).Returns(10f); + parserMock.Setup(p => p.GetCgroupLimitedCpus()).Returns(12f); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var provider = new LinuxUtilizationProvider(options, parserMock.Object, meterFactoryMock.Object, logger, fakeTime); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + var samples = new List<(Instrument instrument, double value)>(); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + + listener.Start(); + listener.RecordObservableInstruments(); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(1)); + listener.RecordObservableInstruments(); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(5)); + listener.RecordObservableInstruments(); + var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + Assert.Equal(0.42, metric.value); + + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(2)); + } + + [Fact] + public void Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutureReads() + { + var meterName = Guid.NewGuid().ToString(); + var logger = new FakeLogger(); + var options = Options.Options.Create(new ResourceMonitoringOptions()); + using var meter = new Meter(nameof(Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutureReads)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + + var callCount = 0; + var parserMock = new Mock(); + parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => + { + callCount++; + if (callCount <= 3) + { + throw new InvalidOperationException("Simulated unhandled exception"); + } + + return 1234UL; + }); + parserMock.Setup(p => p.GetAvailableMemoryInBytes()).Returns(2000UL); + parserMock.Setup(p => p.GetCgroupRequestCpu()).Returns(10f); + parserMock.Setup(p => p.GetCgroupLimitedCpus()).Returns(12f); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var provider = new LinuxUtilizationProvider(options, parserMock.Object, meterFactoryMock.Object, logger, fakeTime); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + var samples = new List<(Instrument instrument, double value)>(); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + + listener.Start(); + + Assert.Throws(() => listener.RecordObservableInstruments()); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(1)); + listener.RecordObservableInstruments(); + var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + Assert.Equal(1234f / 2000f, metric.value, 0.01f); + + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(4)); + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.1.DotNet10_0.verified.txt b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.1.DotNet10_0.verified.txt new file mode 100644 index 00000000000..4896aab52db --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.1.DotNet10_0.verified.txt @@ -0,0 +1,10 @@ +{ + Type: InvalidOperationException, + Message: Could not split contents. We expected every line to contain more than 4 elements, but it has only 2 elements., + StackTrace: +at Microsoft.Shared.Diagnostics.Throw.InvalidOperationException(String message) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.UpdateTcpStateInfo(ReadOnlySpan`1 buffer, TcpStateInfo tcpStateInfo) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpStateInfo(FileInfo file) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpIPv4StateInfo() +at Xunit.Record.Exception(Func`1 testCode) +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.2.DotNet10_0.verified.txt b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.2.DotNet10_0.verified.txt new file mode 100644 index 00000000000..4896aab52db --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.2.DotNet10_0.verified.txt @@ -0,0 +1,10 @@ +{ + Type: InvalidOperationException, + Message: Could not split contents. We expected every line to contain more than 4 elements, but it has only 2 elements., + StackTrace: +at Microsoft.Shared.Diagnostics.Throw.InvalidOperationException(String message) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.UpdateTcpStateInfo(ReadOnlySpan`1 buffer, TcpStateInfo tcpStateInfo) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpStateInfo(FileInfo file) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpIPv4StateInfo() +at Xunit.Record.Exception(Func`1 testCode) +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests.csproj b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests.csproj index b7fcf503d96..ff2cd26412f 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests.csproj @@ -6,8 +6,7 @@ - - + diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringExtensionsTests.cs index 995afb65c62..875fbb67158 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringExtensionsTests.cs @@ -16,9 +16,9 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test; -[OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] public sealed class ResourceMonitoringExtensionsTests { + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void Throw_Null_When_Registration_Ingredients_Null() { @@ -30,6 +30,7 @@ public void Throw_Null_When_Registration_Ingredients_Null() Assert.Throws(() => services.AddResourceMonitoring((b) => b.ConfigureMonitor((Action)null!))); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void AddsResourceMonitoringService_ToServicesCollection() { @@ -50,6 +51,7 @@ public void AddsResourceMonitoringService_ToServicesCollection() Assert.IsAssignableFrom(trackerService); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void AddsResourceMonitoringService_ToServicesCollection_NoArgs() { @@ -66,6 +68,7 @@ public void AddsResourceMonitoringService_ToServicesCollection_NoArgs() Assert.IsAssignableFrom(trackerService); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void AddsResourceMonitoringService_AsHostedService() { @@ -87,6 +90,7 @@ public void AddsResourceMonitoringService_AsHostedService() Assert.IsAssignableFrom(trackerService); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void ConfigureResourceUtilization_InitializeTrackerProperly() { @@ -113,6 +117,7 @@ public void ConfigureResourceUtilization_InitializeTrackerProperly() Assert.NotNull(publisher); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void ConfigureMonitor_GivenOptionsDelegate_InitializeTrackerWithOptionsProperly() { @@ -141,6 +146,7 @@ public void ConfigureMonitor_GivenOptionsDelegate_InitializeTrackerWithOptionsPr Assert.Equal(TimeSpan.FromSeconds(CalculationPeriodValue), options!.Value.PublishingWindow); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void ConfigureMonitor_GivenIConfigurationSection_InitializeTrackerWithOptionsProperly() { @@ -182,6 +188,7 @@ public void ConfigureMonitor_GivenIConfigurationSection_InitializeTrackerWithOpt Assert.Equal(TimeSpan.FromSeconds(CalculationPeriod), options!.Value.PublishingWindow); } + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] [ConditionalFact] public void Registering_Resource_Utilization_Adds_Only_One_Object_Of_Type_ResourceUtilizationService_To_DI_Container() { @@ -204,4 +211,46 @@ public void Registering_Resource_Utilization_Adds_Only_One_Object_Of_Type_Resour Assert.IsAssignableFrom(background); Assert.Same(tracker as ResourceMonitorService, background as ResourceMonitorService); } + + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.Windows, SkipReason = "For MacOs only.")] + [ConditionalFact] + public void AddResourceMonitoringInternal_WhenMacOs_ReturnsSameServiceCollection() + { + var services = new ServiceCollection(); + + // Act + IServiceCollection result = services.AddResourceMonitoring(); + + // Assert + Assert.Same(services, result); + Assert.DoesNotContain(services, s => s.ServiceType == typeof(ISnapshotProvider)); + } + + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] + [ConditionalFact] + public void AddResourceMonitoring_AddsISnapshotProvider() + { + var services = new ServiceCollection(); + + // Act + IServiceCollection result = services.AddResourceMonitoring(); + + // Assert + Assert.Same(services, result); + Assert.Contains(services, s => s.ServiceType == typeof(ISnapshotProvider)); + } + + [OSSkipCondition(OperatingSystems.MacOSX, SkipReason = "Not supported on MacOs.")] + [ConditionalFact] + public void AddResourceMonitoringInternal_CallsConfigureDelegate() + { + var services = new ServiceCollection(); + bool delegateCalled = false; + + // Act + services.AddResourceMonitoring(_ => delegateCalled = true); + + // Assert + Assert.True(delegateCalled); + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs index 1a40889fd72..ca3126e8b97 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs @@ -19,7 +19,7 @@ public void Basic() }; Assert.NotNull(options); - Assert.False(options.CalculateCpuUsageWithoutHostDelta); + Assert.False(options.UseLinuxCalculationV2); } [Fact] @@ -27,9 +27,9 @@ public void CalculateCpuUsageWithoutHostDelta_WhenSet_ReturnsExpectedValue() { var options = new ResourceMonitoringOptions { - CalculateCpuUsageWithoutHostDelta = true + UseLinuxCalculationV2 = true }; - Assert.True(options.CalculateCpuUsageWithoutHostDelta); + Assert.True(options.UseLinuxCalculationV2); } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs index a714861c204..9e1efa767ad 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs @@ -222,7 +222,7 @@ public async Task StartAsync_WithSimulatingThatTimeDidNotPass_NoUtilizationDataW Assert.Equal(0, numberOfSnapshots); } - [Fact] + [Fact(Skip = "Flaky test, see https://github.com/dotnet/extensions/issues/7009")] public async Task RunTrackerAsync_IfProviderThrows_LogsError() { var clock = new FakeTimeProvider(); @@ -255,11 +255,20 @@ public async Task RunTrackerAsync_IfProviderThrows_LogsError() // Now, allow the faulted provider to throw. provider.ShouldThrow = true; - clock.Advance(TimeSpan.FromMilliseconds(1)); - clock.Advance(TimeSpan.FromMilliseconds(1)); - - e.Wait(); + // Use polling pattern with FakeTimeProvider to ensure the async ExecuteAsync loop completes. + // The ExecuteAsync method waits on _timeProvider.Delay(), which requires repeated Advance() calls + // to trigger in FakeTimeProvider. We can't assume exactly when the delay is "armed" and ready to + // respond to time advancement, especially across different .NET runtime versions where task scheduling + // may differ. This polling approach (advance time + short wait) ensures the test works reliably + // regardless of when the Delay starts waiting, avoiding the indefinite hang that occurred in net10.0. + var maxAttempts = 100; // Prevent infinite loop + var attempts = 0; + while (!e.Wait(10) && attempts++ < maxAttempts) + { + clock.Advance(TimeSpan.FromMilliseconds(1)); + } + Assert.True(attempts < maxAttempts, "Timeout waiting for publisher to be called"); Assert.Contains(ProviderUnableToGatherData, logger.Collector.LatestRecord.Message, StringComparison.OrdinalIgnoreCase); } @@ -367,10 +376,19 @@ public async Task ResourceUtilizationTracker_LogsSnapshotInformation() // Start running the tracker. await tracker.StartAsync(CancellationToken.None); - clock.Advance(TimeSpan.FromMilliseconds(TimerPeriod)); + // Use polling pattern to ensure the ExecuteAsync loop runs and logs snapshot information. + // We need to wait until at least one log record appears before stopping the service. + var maxAttempts = 100; + var attempts = 0; + while (logger.Collector.Count == 0 && attempts++ < maxAttempts) + { + clock.Advance(TimeSpan.FromMilliseconds(TimerPeriod)); + await Task.Delay(10); // Give the async loop time to process + } await tracker.StopAsync(CancellationToken.None); + Assert.True(logger.Collector.Count > 0, "No logs were recorded"); await Verifier.Verify(logger.Collector.LatestRecord).UseDirectory("Verified"); } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Disk/WindowsDiskMetricsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Disk/WindowsDiskMetricsTests.cs index 25587a6ad59..a592aacce19 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Disk/WindowsDiskMetricsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Disk/WindowsDiskMetricsTests.cs @@ -30,7 +30,7 @@ public void Creates_Meter_With_Correct_Name() { using var meterFactory = new TestMeterFactory(); var performanceCounterFactoryMock = new Mock(); - var options = new ResourceMonitoringOptions { EnableDiskIoMetrics = true }; + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; _ = new WindowsDiskMetrics( _fakeLogger, @@ -49,7 +49,7 @@ public void DiskOperationMetricsTest() using var meterFactory = new TestMeterFactory(); var performanceCounterFactory = new Mock(); var fakeTimeProvider = new FakeTimeProvider(); - var options = new ResourceMonitoringOptions { EnableDiskIoMetrics = true }; + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; // Set up const string ReadCounterName = WindowsDiskPerfCounterNames.DiskReadsCounter; @@ -123,7 +123,7 @@ public void DiskIoBytesMetricsTest() using var meterFactory = new TestMeterFactory(); var performanceCounterFactory = new Mock(); var fakeTimeProvider = new FakeTimeProvider(); - var options = new ResourceMonitoringOptions { EnableDiskIoMetrics = true }; + var options = new ResourceMonitoringOptions { EnableSystemDiskIoMetrics = true }; // Set up const string ReadCounterName = WindowsDiskPerfCounterNames.DiskReadBytesCounter; diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Tcp6TableInfoTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Tcp6TableInfoTests.cs index 3ef240e1274..a5dfdb5c170 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Tcp6TableInfoTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/Tcp6TableInfoTests.cs @@ -234,11 +234,12 @@ public void Test_Tcp6TableInfo_Get_UnsuccessfulStatus_All_The_Time() SourceIpAddresses = new HashSet { "[::1]" }, SamplingInterval = DefaultTimeSpan }; - WindowsTcpStateInfo tcp6TableInfo = new WindowsTcpStateInfo(Options.Options.Create(options)); + + var tcp6TableInfo = new WindowsTcpStateInfo(Options.Options.Create(options)); tcp6TableInfo.SetGetTcp6TableDelegate(FakeGetTcp6TableWithUnsuccessfulStatusAllTheTime); Assert.Throws(() => { - var tcpStateInfo = tcp6TableInfo.GetpIpV6TcpStateInfo(); + TcpStateInfo tcpStateInfo = tcp6TableInfo.GetIpV6TcpStateInfo(); }); } @@ -254,7 +255,7 @@ public void Test_Tcp6TableInfo_Get_InsufficientBuffer_Then_Get_InvalidParameter( tcp6TableInfo.SetGetTcp6TableDelegate(FakeGetTcp6TableWithInsufficientBufferAndInvalidParameter); Assert.Throws(() => { - var tcpStateInfo = tcp6TableInfo.GetpIpV6TcpStateInfo(); + TcpStateInfo tcpStateInfo = tcp6TableInfo.GetIpV6TcpStateInfo(); }); } @@ -270,7 +271,7 @@ public void Test_Tcp6TableInfo_Get_Correct_Information() }; WindowsTcpStateInfo tcp6TableInfo = new WindowsTcpStateInfo(Options.Options.Create(options)); tcp6TableInfo.SetGetTcp6TableDelegate(FakeGetTcp6TableWithFakeInformation); - var tcpStateInfo = tcp6TableInfo.GetpIpV6TcpStateInfo(); + TcpStateInfo tcpStateInfo = tcp6TableInfo.GetIpV6TcpStateInfo(); Assert.NotNull(tcpStateInfo); Assert.Equal(1, tcpStateInfo.ClosedCount); Assert.Equal(1, tcpStateInfo.ListenCount); @@ -286,7 +287,7 @@ public void Test_Tcp6TableInfo_Get_Correct_Information() Assert.Equal(1, tcpStateInfo.DeleteTcbCount); // Second calling in a small interval. - tcpStateInfo = tcp6TableInfo.GetpIpV6TcpStateInfo(); + tcpStateInfo = tcp6TableInfo.GetIpV6TcpStateInfo(); Assert.NotNull(tcpStateInfo); Assert.Equal(1, tcpStateInfo.ClosedCount); Assert.Equal(1, tcpStateInfo.ListenCount); @@ -303,7 +304,7 @@ public void Test_Tcp6TableInfo_Get_Correct_Information() // Third calling in a long interval. Thread.Sleep(6000); - tcpStateInfo = tcp6TableInfo.GetpIpV6TcpStateInfo(); + tcpStateInfo = tcp6TableInfo.GetIpV6TcpStateInfo(); Assert.NotNull(tcpStateInfo); Assert.Equal(2, tcpStateInfo.ClosedCount); Assert.Equal(2, tcpStateInfo.ListenCount); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/TcpTableInfoTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/TcpTableInfoTests.cs index fb79d0cb839..8c88fc123dd 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/TcpTableInfoTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/TcpTableInfoTests.cs @@ -181,7 +181,7 @@ public void Test_TcpTableInfo_Get_UnsuccessfulStatus_All_The_Time() tcpTableInfo.SetGetTcpTableDelegate(FakeGetTcpTableWithUnsuccessfulStatusAllTheTime); Assert.Throws(() => { - var tcpStateInfo = tcpTableInfo.GetpIpV4TcpStateInfo(); + var tcpStateInfo = tcpTableInfo.GetIpV4TcpStateInfo(); }); } @@ -197,7 +197,7 @@ public void Test_TcpTableInfo_Get_InsufficientBuffer_Then_Get_InvalidParameter() tcpTableInfo.SetGetTcpTableDelegate(FakeGetTcpTableWithInsufficientBufferAndInvalidParameter); Assert.Throws(() => { - var tcpStateInfo = tcpTableInfo.GetpIpV4TcpStateInfo(); + var tcpStateInfo = tcpTableInfo.GetIpV4TcpStateInfo(); }); } @@ -213,7 +213,7 @@ public void Test_TcpTableInfo_Get_Correct_Information() }; WindowsTcpStateInfo tcpTableInfo = new WindowsTcpStateInfo(Options.Options.Create(options)); tcpTableInfo.SetGetTcpTableDelegate(FakeGetTcpTableWithFakeInformation); - var tcpStateInfo = tcpTableInfo.GetpIpV4TcpStateInfo(); + var tcpStateInfo = tcpTableInfo.GetIpV4TcpStateInfo(); Assert.NotNull(tcpStateInfo); Assert.Equal(1, tcpStateInfo.ClosedCount); Assert.Equal(1, tcpStateInfo.ListenCount); @@ -229,7 +229,7 @@ public void Test_TcpTableInfo_Get_Correct_Information() Assert.Equal(1, tcpStateInfo.DeleteTcbCount); // Second calling in a small interval. - tcpStateInfo = tcpTableInfo.GetpIpV4TcpStateInfo(); + tcpStateInfo = tcpTableInfo.GetIpV4TcpStateInfo(); Assert.NotNull(tcpStateInfo); Assert.Equal(1, tcpStateInfo.ClosedCount); Assert.Equal(1, tcpStateInfo.ListenCount); @@ -246,7 +246,7 @@ public void Test_TcpTableInfo_Get_Correct_Information() // Third calling in a long interval. Thread.Sleep(6000); - tcpStateInfo = tcpTableInfo.GetpIpV4TcpStateInfo(); + tcpStateInfo = tcpTableInfo.GetIpV4TcpStateInfo(); Assert.NotNull(tcpStateInfo); Assert.Equal(2, tcpStateInfo.ClosedCount); Assert.Equal(2, tcpStateInfo.ListenCount); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs index 08d3ecf6020..ead512e015b 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs @@ -190,6 +190,66 @@ public void GetSnapshot_With_JobMemoryLimit_Set_To_Zero_ProducesCorrectSnapshot( Assert.True(data.MemoryUsageInBytes > 0); } + [Fact] + public void SnapshotProvider_EmitsCpuTimeMetric() + { + // Simulating 10% CPU usage (2 CPUs, 2000 ticks initially, 4000 ticks after 1 ms): + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION updatedAccountingInfo = default; + updatedAccountingInfo.TotalKernelTime = 2500; + updatedAccountingInfo.TotalUserTime = 1500; + + _jobHandleMock.SetupSequence(j => j.GetBasicAccountingInfo()) + .Returns(_accountingInfo) + .Returns(_accountingInfo) + .Returns(updatedAccountingInfo) + .Returns(updatedAccountingInfo) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _sysInfo.NumberOfProcessors = 2; + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_EmitsCpuMetrics)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var metricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerCpuTime, fakeClock); + + var options = new ResourceMonitoringOptions { CpuConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2) }; + + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + metricCollector.RecordObservableInstruments(); + var snapshot = metricCollector.GetMeasurementSnapshot(); + Assert.Equal(2, snapshot.Count); + Assert.Contains(_accountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(_accountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + snapshot = metricCollector.GetMeasurementSnapshot(); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + + // Step #2 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + snapshot = metricCollector.GetMeasurementSnapshot(); + + // CPU time should be the same as before, as we're not simulating any CPU usage: + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + } + [Theory] [InlineData(ResourceUtilizationInstruments.ProcessCpuUtilization, true)] [InlineData(ResourceUtilizationInstruments.ProcessCpuUtilization, false)] @@ -319,6 +379,146 @@ public void SnapshotProvider_EmitsMemoryMetrics(string instrumentName, bool useZ Assert.Equal(0.3 * multiplier, metricCollector.LastMeasurement.Value); // Consuming 30% of the memory afterwards. } + [Fact] + public void SnapshotProvider_TestMemoryMetricsTogether() + { + _appMemoryUsage = 200UL; + ulong containerMemoryUsage = 400UL; + ulong updatedAppMemoryUsage = 600UL; + ulong updatedContainerMemoryUsage = 1200UL; + + _processInfoMock.SetupSequence(p => p.GetCurrentProcessMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(updatedAppMemoryUsage) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _processInfoMock.SetupSequence(p => p.GetMemoryUsage()) + .Returns(() => containerMemoryUsage) + .Returns(updatedContainerMemoryUsage) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_TestMemoryMetricsTogether)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var processMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ProcessMemoryUtilization, fakeClock); + using var containerLimitMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, fakeClock); + using var containerUsageMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryUsage, fakeClock); + + var options = new ResourceMonitoringOptions + { + MemoryConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2) + }; + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + processMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + + Assert.NotNull(processMetricCollector.LastMeasurement?.Value); + Assert.NotNull(containerLimitMetricCollector.LastMeasurement?.Value); + Assert.NotNull(containerUsageMetricCollector.LastMeasurement?.Value); + + Assert.Equal(10, processMetricCollector.LastMeasurement.Value); // Process is consuming 10% of memory limit initially. + Assert.Equal(20, containerLimitMetricCollector.LastMeasurement.Value); // The whole container is consuming 20% of the memory limit initially. + Assert.Equal((long)containerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); // 400 bytes of memory usage initially. + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(options.MemoryConsumptionRefreshInterval - TimeSpan.FromMilliseconds(1)); + + processMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + + // Still consuming 10% and 20% as values weren't updated yet - not enough time passed. + Assert.Equal(10, processMetricCollector.LastMeasurement.Value); + Assert.Equal(20, containerLimitMetricCollector.LastMeasurement.Value); + Assert.Equal((long)containerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); + + // Step #2 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + + processMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + + // App is consuming 30%, and container is consuming 60% of the limit: + Assert.Equal(30, processMetricCollector.LastMeasurement.Value); + Assert.Equal(60, containerLimitMetricCollector.LastMeasurement.Value); + Assert.Equal((long)updatedContainerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); + } + + [Fact] + public void SnapshotProvider_EmitsMemoryUsageMetric() + { + _appMemoryUsage = 200UL; + const ulong UpdatedAppMemoryUsage = 600UL; + const ulong UpdatedAppMemoryUsage2 = 300UL; + + _processInfoMock.SetupSequence(p => p.GetCurrentProcessMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(UpdatedAppMemoryUsage) + .Returns(UpdatedAppMemoryUsage2) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _processInfoMock.SetupSequence(p => p.GetMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(UpdatedAppMemoryUsage) + .Returns(UpdatedAppMemoryUsage2) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_EmitsMemoryMetrics)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var metricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryUsage, fakeClock); + + var options = new ResourceMonitoringOptions + { + MemoryConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2), + }; + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + metricCollector.RecordObservableInstruments(); + Assert.NotNull(metricCollector.LastMeasurement?.Value); + Assert.Equal(200, metricCollector.LastMeasurement.Value); // Consuming 200 bytes initially. + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(options.MemoryConsumptionRefreshInterval - TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(200, metricCollector.LastMeasurement.Value); // Still consuming 200 bytes as metric wasn't updated. + + // Step #2 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(2)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(600, metricCollector.LastMeasurement.Value); // Consuming 600 bytes. + + // Step #3 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(2)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(300, metricCollector.LastMeasurement.Value); // Consuming 300 bytes. + } + [Fact] public Task SnapshotProvider_EmitsLogRecord() { diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs index 9ec81a36077..18083f43569 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs @@ -6,8 +6,6 @@ using System.Globalization; using System.Linq; using System.Threading; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -283,4 +281,61 @@ public void Scopes() Assert.Equal(42, (int)logger.LatestRecord.Scopes[0]!); Assert.Equal("Hello World", (string)logger.LatestRecord.Scopes[1]!); } + + [Theory] + [InlineData(false, 2)] + [InlineData(true, 1)] + public void FilterByCustomFilter(bool useErrorLevelFilter, int expectedRecordCount) + { + const string NotIgnoredMessage1 = "Not ignored message 1"; + const string NotIgnoredMessage2 = "Not ignored message 2"; + const string IgnoredMessage = "Ignored message"; + + // Given + var options = new FakeLogCollectorOptions + { + CustomFilter = r => r.Message != IgnoredMessage, + FilteredLevels = useErrorLevelFilter ? [LogLevel.Error] : new HashSet(), + }; + + var collector = FakeLogCollector.Create(options); + var logger = new FakeLogger(collector); + + // When + logger.LogInformation(NotIgnoredMessage1); + logger.LogInformation(IgnoredMessage); + logger.LogError(IgnoredMessage); + logger.LogError(NotIgnoredMessage2); + logger.LogCritical(IgnoredMessage); + + var records = logger.Collector.GetSnapshot(); + + // Then + Assert.Equal(expectedRecordCount, records.Count); + Assert.Equal(expectedRecordCount, logger.Collector.Count); + + IList<(string message, LogLevel level, string prefix)> expectationsInOrder = useErrorLevelFilter + ? [(NotIgnoredMessage2, LogLevel.Error, "error] ")] + : [(NotIgnoredMessage1, LogLevel.Information, "info] "), (NotIgnoredMessage2, LogLevel.Error, "error] ")]; + + for (var i = 0; i < expectedRecordCount; i++) + { + var (expectedMessage, expectedLevel, expectedPrefix) = expectationsInOrder[i]; + var record = records[i]; + + Assert.Equal(expectedMessage, record.Message); + Assert.Equal(expectedLevel, record.Level); + Assert.Null(record.Exception); + Assert.Null(record.Category); + Assert.True(record.LevelEnabled); + Assert.Empty(record.Scopes); + Assert.Equal(0, record.Id.Id); + Assert.EndsWith($"{expectedPrefix}{expectedMessage}", record.ToString()); + + if (i == expectedRecordCount - 1) + { + Assert.Equivalent(record, logger.LatestRecord); + } + } + } } diff --git a/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs b/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs index 49efadd8f09..f96fb65a7ce 100644 --- a/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs +++ b/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs @@ -33,6 +33,8 @@ public async Task Host_ShutsDownAfterTimeout() }) .StartAsync(); + await Task.Delay(100); // Give some time for the host to shut down + Assert.Throws(() => host.Services.GetService()); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs index 71941df0e7e..79753bfb996 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs @@ -18,14 +18,14 @@ public class HttpClientLatencyLogEnricherTest public void HttpClientLatencyLogEnricher_NoOp_OnRequest() { var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); var checkpoints = new ArraySegment(new[] { new Checkpoint("a", default, default), new Checkpoint("b", default, default) }); var ld = new LatencyData(default, checkpoints, default, default, default); var lc = HttpMockProvider.GetLatencyContext(); lc.Setup(lc => lc.LatencyData).Returns(ld); var context = new HttpClientLatencyContext(); context.Set(lc.Object); - - var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object); + var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object, mediator); Mock mockEnrichmentPropertyBag = new Mock(); enricher.Enrich(mockEnrichmentPropertyBag.Object, null!, null, null); mockEnrichmentPropertyBag.Verify(m => m.Add(It.IsAny(), It.IsAny()), Times.Never); @@ -35,6 +35,7 @@ public void HttpClientLatencyLogEnricher_NoOp_OnRequest() public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithoutHeader() { var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); var checkpoints = new ArraySegment(new[] { new Checkpoint("a", default, default), new Checkpoint("b", default, default) }); var ld = new LatencyData(default, checkpoints, default, default, default); var lc = HttpMockProvider.GetLatencyContext(); @@ -43,8 +44,7 @@ public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithoutHeader() context.Set(lc.Object); using HttpResponseMessage httpResponseMessage = new(); - - var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object); + var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object, mediator); Mock mockEnrichmentPropertyBag = new Mock(); enricher.Enrich(mockEnrichmentPropertyBag.Object, null!, httpResponseMessage, null); @@ -55,6 +55,7 @@ public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithoutHeader() public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithHeader() { var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); var checkpoints = new ArraySegment(new[] { new Checkpoint("a", default, default), new Checkpoint("b", default, default) }); var ld = new LatencyData(default, checkpoints, default, default, default); var lc = HttpMockProvider.GetLatencyContext(); @@ -66,7 +67,7 @@ public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithHeader() string serverName = "serverNameVal"; httpResponseMessage.Headers.Add(TelemetryConstants.ServerApplicationNameHeader, serverName); - var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object); + var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object, mediator); Mock mockEnrichmentPropertyBag = new Mock(); enricher.Enrich(mockEnrichmentPropertyBag.Object, null!, httpResponseMessage, null); diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyMediatorTests.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyMediatorTests.cs new file mode 100644 index 00000000000..b82b815e430 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyMediatorTests.cs @@ -0,0 +1,155 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AmbientMetadata; +using Microsoft.Extensions.Diagnostics.Latency; +using Microsoft.Extensions.Http.Latency.Internal; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Microsoft.Extensions.Http.Latency.Test.Internal; + +public class HttpLatencyMediatorTests +{ + [Fact] + public void RecordStart_RecordsGCPauseMeasure() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var measureToken = new MeasureToken(HttpMeasures.GCPauseTime, 0); + lcti.Setup(i => i.GetMeasureToken(HttpMeasures.GCPauseTime)) + .Returns(measureToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + // Act + mediator.RecordStart(lc.Object); + + // Assert + // Verify RecordMeasure was called with the correct token + lc.Verify(c => c.RecordMeasure( + measureToken, + It.Is(v => v <= 0)), // Value should be negative (start value) + Times.Once); + } + + [Fact] + public async Task HttpLatencyTelemetryHandler_UsesMediator() + { + // Arrange + var lc = HttpMockProvider.GetLatencyContext(); + var lcp = HttpMockProvider.GetContextProvider(lc); + lcp.Setup(p => p.CreateContext()).Returns(lc.Object); + + var context = new HttpClientLatencyContext(); + + var sop = new Mock>(); + sop.Setup(a => a.Value).Returns(new ApplicationMetadata()); + var hop = new Mock>(); + hop.Setup(a => a.Value).Returns(new HttpClientLatencyTelemetryOptions()); + + var lcti = HttpMockProvider.GetTokenIssuer(); + + // Create a mediator + var mediator = new HttpLatencyMediator(lcti.Object); + + using var listener = HttpMockProvider.GetListener(context, lcti.Object); + using var req = new HttpRequestMessage(); + req.Method = HttpMethod.Post; + req.RequestUri = new Uri($"https://default-uri.com/foo"); + + var resp = new HttpResponseMessage(); + var mockHandler = new Mock(); + mockHandler.Protected().Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(resp); + + using var handler = new HttpLatencyTelemetryHandler( + listener, lcti.Object, lcp.Object, hop.Object, sop.Object, mediator); + handler.InnerHandler = mockHandler.Object; + + // Act + using var client = new HttpClient(handler); + await client.SendAsync(req, It.IsAny()); + + // Verify that the latency context was created and properly used + lcp.Verify(p => p.CreateContext(), Times.Once); + resp.Dispose(); + } + + [Fact] + public void RecordEnd_RecordsGCPauseMeasure() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var measureToken = new MeasureToken(HttpMeasures.GCPauseTime, 0); + lcti.Setup(i => i.GetMeasureToken(HttpMeasures.GCPauseTime)) + .Returns(measureToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + // Act + mediator.RecordEnd(lc.Object); + + lc.Verify(c => c.AddMeasure(measureToken, It.IsAny()), Times.Once); + } + + [Fact] + public void RecordEnd_WithResponse_SetsHttpVersionTag() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var httpVersionToken = new TagToken(HttpTags.HttpVersion, 0); + lcti.Setup(i => i.GetTagToken(HttpTags.HttpVersion)) + .Returns(httpVersionToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + using var response = new HttpResponseMessage(); + response.Version = new Version(2, 0); + + // Act + mediator.RecordEnd(lc.Object, response); + + // Assert + lc.Verify(c => c.SetTag( + httpVersionToken, + "2.0"), + Times.Once); + } + + [Fact] + public void RecordEnd_WithNullResponse_DoesNotSetHttpVersionTag() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var httpVersionToken = new TagToken("Http.Version", 0); + lcti.Setup(i => i.GetTagToken(HttpTags.HttpVersion)) + .Returns(httpVersionToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + // Act + mediator.RecordEnd(lc.Object); + + // Assert + lc.Verify(c => c.SetTag( + It.Is(t => t.Name == HttpTags.HttpVersion), + It.IsAny()), + Times.Never); + } +} +#endif \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs index 2f787284b26..e70f8135d72 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Linq; using System.Net.Http; using System.Threading; @@ -32,8 +31,9 @@ public void HttpLatencyTelemetryHandler_InvokesTokenIssuer() var lcti = HttpMockProvider.GetTokenIssuer(); var lcti2 = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti2.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); - using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object); + using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object, mediator); lcti2.Verify(a => a.GetCheckpointToken(It.Is(s => !HttpCheckpoints.Checkpoints.Contains(s))), Times.Never); lcti2.Verify(a => a.GetCheckpointToken(It.Is(s => HttpCheckpoints.Checkpoints.Contains(s)))); @@ -53,6 +53,7 @@ public async Task HttpLatencyTelemetryHandler_SetsLatencyContext() var lcti = HttpMockProvider.GetTokenIssuer(); var lcti2 = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti2.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); using var req = new HttpRequestMessage { @@ -71,12 +72,12 @@ public async Task HttpLatencyTelemetryHandler_SetsLatencyContext() Assert.True(req.Headers.Contains(TelemetryConstants.ClientApplicationNameHeader)); }).Returns(Task.FromResult(resp.Object)); - using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object) + using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object, mediator) { InnerHandler = mockHandler.Object }; - using var client = new System.Net.Http.HttpClient(handler); + using var client = new HttpClient(handler); await client.SendAsync(req, It.IsAny()); Assert.Null(context.Get()); } @@ -93,8 +94,9 @@ public void HttpLatencyTelemetryHandler_IfDetailsDisabled_DoesNotEnableListener( hop.Setup(a => a.Value).Returns(new HttpClientLatencyTelemetryOptions { EnableDetailedLatencyBreakdown = false }); var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); - using var handler = new HttpLatencyTelemetryHandler(listener, lcti.Object, lcp.Object, hop.Object, sop.Object); + using var handler = new HttpLatencyTelemetryHandler(listener, lcti.Object, lcp.Object, hop.Object, sop.Object, mediator); Assert.False(listener.Enabled); } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs index a9150df371b..68d758df26b 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs @@ -40,7 +40,7 @@ public static Mock GetLatencyContext() return lc; } - public class MockEventSource : EventSource + public class MockEventSource() : EventSource(throwOnEventWriteErrors: true) { public int OnEventInvoked; diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs index 3675bc3901a..3f66b7ea506 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.Tracing; using System.Linq; using Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Extensions.Http.Latency.Internal; @@ -16,10 +17,9 @@ public void HttpClientLatencyContext_Set_BasicFunction() { var lc = HttpMockProvider.GetLatencyContext(); var context = new HttpClientLatencyContext(); + Assert.Null(context.Get()); context.Set(lc.Object); Assert.Equal(context.Get(), lc.Object); - context.Unset(); - Assert.Null(context.Get()); } [Fact] @@ -85,8 +85,16 @@ public void HttpRequestLatencyListener_OnEventSourceCreated_NonHttpSources() listener.OnEventSourceCreated("test", es); Assert.Equal(0, es.OnEventInvoked); Assert.False(es.IsEnabled()); + + using var dummyListener = new DummyListener(); + dummyListener.EnableEvents(es, EventLevel.LogAlways); + + // EventSource seems to send the event to all listeners, even those that didn't enable the EventSource at all! + es.Write("Dummy"); } + private sealed class DummyListener : EventListener; + [Fact] public void HttpRequestLatencyListener_OnEventSourceCreated_HttpSources() { @@ -115,28 +123,6 @@ public void HttpRequestLatencyListener_OnEventSourceCreated_HttpSources() Assert.True(esNameRes.IsEnabled()); } - [Fact] - public void HttpRequestLatencyListener_OnEventSourceCreated_Twice() - { - var lcti = HttpMockProvider.GetTokenIssuer(); - var lc = HttpMockProvider.GetLatencyContext(); - var context = new HttpClientLatencyContext(); - context.Set(lc.Object); - - using var listener = HttpMockProvider.GetListener(context, lcti.Object); - Assert.NotNull(listener); - listener.Enable(); - - using var esSockets = new HttpMockProvider.SockeyMockEventSource(); - listener.OnEventSourceCreated("System.Net.Sockets", esSockets); - Assert.Equal(1, esSockets.OnEventInvoked); - Assert.True(esSockets.IsEnabled()); - - listener.OnEventSourceCreated("System.Net.Sockets", esSockets); - Assert.Equal(1, esSockets.OnEventInvoked); - Assert.True(esSockets.IsEnabled()); - } - [Fact] public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_NonHttp() { @@ -146,15 +132,18 @@ public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_NonH context.Set(lc.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); + listener.Enable(); var events = new[] { "ConnectionEstablished", "RequestLeftQueue", "ResolutionStop", "ConnectStart", "New" }; + using var es = new HttpMockProvider.MockEventSource(); + listener.OnEventSourceCreated("System.Net", es); for (int i = 0; i < events.Length; i++) { - listener.OnEventWritten("System.Net", events[i]); + es.Write(events[i]); } lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Never); @@ -211,4 +200,19 @@ public void HttpRequestLatencyListener_OnEventWritten_AddsCheckpoints_Http() lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Exactly(numHttpEvents + numSocketEvents + numDnsEvents)); } + + [Fact] + public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_UnknownEventSource() + { + var lcti = HttpMockProvider.GetTokenIssuer(); + var lc = HttpMockProvider.GetLatencyContext(); + var context = new HttpClientLatencyContext(); + context.Set(lc.Object); + + using var listener = HttpMockProvider.GetListener(context, lcti.Object); + + listener.OnEventWritten("System.Runtime", "EventCounters"); + + lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Never); + } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs index 3143aab9185..ec8f21c06bc 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs @@ -385,14 +385,14 @@ public async Task AddHttpClientLogging_StructuredPathLogging_RedactsSensitivePar if (parameterRedactionMode == HttpRouteParameterRedactionMode.None) { loggedPath.Should().Be(httpRequestMessage.RequestUri.AbsolutePath); - state.Should().HaveCount(5); + state.Should().HaveCount(6); } else { loggedPath.Should().Be(RequestRoute); state.Should().ContainSingle(kvp => kvp.Key == "userId").Which.Value.Should().Be(expectedUserId); state.Should().ContainSingle(kvp => kvp.Key == "unitId").Which.Value.Should().Be(expectedUnitId); - state.Should().HaveCount(7); + state.Should().HaveCount(8); } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs index 84ed98263c1..f57fb23b8d7 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs @@ -252,6 +252,7 @@ public async Task HttpLoggingHandler_AllOptions_LogsOutgoingRequest() logRecordState.Contains(testSharedResponseHeaderKey, expectedLogRecord.ResponseHeaders[1].Value); logRecordState.Contains(testSharedRequestHeaderKey, expectedLogRecord.RequestHeaders[1].Value); logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -344,6 +345,7 @@ public async Task HttpLoggingHandler_AllOptionsWithLogRequestStart_LogsOutgoingR logRecordRequest.NotContains(HttpClientLoggingTagNames.StatusCode); logRecordRequest.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); logRecordRequest.NotContains(testEnricher.KvpRequest.Key); + EnsureLogRecordContainsOriginalFormat(logRecords[0]); var logRecordFull = logRecords[1].GetStructuredState(); logRecordFull.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); @@ -357,6 +359,7 @@ public async Task HttpLoggingHandler_AllOptionsWithLogRequestStart_LogsOutgoingR logRecordFull.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); logRecordFull.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); logRecordFull.Contains(testEnricher.KvpResponse.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpResponse.Key)); + EnsureLogRecordContainsOriginalFormat(logRecords[1]); } [Fact] @@ -453,6 +456,7 @@ public async Task HttpLoggingHandler_AllOptionsSendAsyncFailed_LogsRequestInform logRecordState.NotContains(testEnricher.KvpResponse.Key); logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); Assert.DoesNotContain(logRecordState, kvp => kvp.Key.StartsWith(HttpClientLoggingTagNames.ResponseHeaderPrefix)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact(Skip = "Flaky test, see https://github.com/dotnet/extensions/issues/4530")] @@ -568,6 +572,7 @@ public async Task HttpLoggingHandler_ReadResponseThrows_LogsException() logRecordState.Contains(testEnricher.KvpResponse.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpResponse.Key)); logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); Assert.DoesNotContain(logRecordState, kvp => kvp.Key.StartsWith(HttpClientLoggingTagNames.ResponseHeaderPrefix)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -657,6 +662,7 @@ public async Task HttpLoggingHandler_AllOptionsTransferEncodingIsNotChunked_Logs logRecordState.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); logRecordState.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -918,18 +924,20 @@ public async Task HttpLoggingHandler_AllOptionsTransferEncodingChunked_LogsOutgo await client.SendAsync(httpRequestMessage, It.IsAny()); var logRecords = fakeLogger.Collector.GetSnapshot(); - var logRecord = Assert.Single(logRecords).GetStructuredState(); - - logRecord.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); - logRecord.Contains(HttpClientLoggingTagNames.Method, expectedLogRecord.Method.ToString()); - logRecord.Contains(HttpClientLoggingTagNames.Path, TelemetryConstants.Redacted); - logRecord.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); - logRecord.Contains(HttpClientLoggingTagNames.StatusCode, expectedLogRecord.StatusCode.Value.ToString(CultureInfo.InvariantCulture)); - logRecord.Contains(HttpClientLoggingTagNames.RequestBody, expectedLogRecord.RequestBody); - logRecord.Contains(HttpClientLoggingTagNames.ResponseBody, expectedLogRecord.ResponseBody); - logRecord.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); - logRecord.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); - logRecord.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + var logRecord = Assert.Single(logRecords); + var logRecordState = logRecord.GetStructuredState(); + + logRecordState.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); + logRecordState.Contains(HttpClientLoggingTagNames.Method, expectedLogRecord.Method.ToString()); + logRecordState.Contains(HttpClientLoggingTagNames.Path, TelemetryConstants.Redacted); + logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); + logRecordState.Contains(HttpClientLoggingTagNames.StatusCode, expectedLogRecord.StatusCode.Value.ToString(CultureInfo.InvariantCulture)); + logRecordState.Contains(HttpClientLoggingTagNames.RequestBody, expectedLogRecord.RequestBody); + logRecordState.Contains(HttpClientLoggingTagNames.ResponseBody, expectedLogRecord.ResponseBody); + logRecordState.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); + logRecordState.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); + logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Theory] @@ -1024,4 +1032,11 @@ private static void EnsureLogRecordDuration(string? actualValue) private static IOutgoingRequestContext RequestMetadataContext => new Mock().Object; + + private static void EnsureLogRecordContainsOriginalFormat(FakeLogRecord logRecord) + { + var pair = logRecord.StructuredState!.Last(); + Assert.Equal("{OriginalFormat}", pair.Key); + Assert.Equal("{http.request.method} {server.address}/{url.path}", pair.Value); + } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs index ba781c5d101..c2b5295d707 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Http.Logging.Internal; using Microsoft.Extensions.Http.Logging.Test.Internal; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Telemetry.Internal; using Moq; using Xunit; @@ -54,6 +55,8 @@ public async Task ReadAsync_AllData_ReturnsLogRecord() ResponseHeaders = [new("Header2", Redacted), new("Header3", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty, }; var options = new LoggingOptions @@ -80,7 +83,7 @@ public async Task ReadAsync_AllData_ReturnsLogRecord() using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo"), + RequestUri = new Uri("https://default-uri.com/foo"), Content = new StringContent(requestContent, Encoding.UTF8) }; @@ -120,6 +123,8 @@ public async Task ReadAsync_NoHost_ReturnsLogRecordWithoutHost() StatusCode = 200, RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var options = new LoggingOptions @@ -180,6 +185,8 @@ public async Task ReadAsync_AllDataWithRequestMetadataSet_ReturnsLogRecord() ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -206,7 +213,7 @@ public async Task ReadAsync_AllDataWithRequestMetadataSet_ReturnsLogRecord() using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -251,7 +258,8 @@ public async Task ReadAsync_FormatRequestPathDisabled_ReturnsLogRecordWithRoute( ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, - PathParametersCount = 1 + PathParametersCount = 1, + QueryString = string.Empty }; var opts = new LoggingOptions @@ -281,7 +289,7 @@ public async Task ReadAsync_FormatRequestPathDisabled_ReturnsLogRecordWithRoute( using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri($"http://{RequestedHost}/foo/bar/123"), + RequestUri = new Uri($"https://{RequestedHost}/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -325,6 +333,7 @@ public async Task ReadAsync_RouteParameterRedactionModeNone_ReturnsLogRecordWith Path = "/foo/bar/123", RequestHeaders = [new("Header1", Redacted)], RequestBody = requestContent, + QueryString = string.Empty }; var opts = new LoggingOptions @@ -353,7 +362,7 @@ public async Task ReadAsync_RouteParameterRedactionModeNone_ReturnsLogRecordWith using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -385,6 +394,8 @@ public async Task ReadAsync_RequestMetadataRequestNameSetAndRouteMissing_Returns ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -411,7 +422,7 @@ public async Task ReadAsync_RequestMetadataRequestNameSetAndRouteMissing_Returns using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -456,6 +467,8 @@ public async Task ReadAsync_NoMetadataUsesRedactedString_ReturnsLogRecord() ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -482,7 +495,7 @@ public async Task ReadAsync_NoMetadataUsesRedactedString_ReturnsLogRecord() using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -523,6 +536,8 @@ public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_Retur ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -549,7 +564,7 @@ public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_Retur using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -573,6 +588,301 @@ public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_Retur actualRecord.Should().BeEquivalentTo(expectedRecord); } + [Fact] + public async Task ReadAsync_SetsQueryParameters_WhenClassificationPresent() + { + var requestContent = _fixture.Create(); + var queryParamName = "userId"; + var queryParamValue = "12345"; + + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { queryParamName, FakeTaxonomy.PrivateData } + }, + BodySizeLimit = 32000, + BodyReadTimeout = TimeSpan.FromMinutes(5), + LogBody = true + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + await using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?{queryParamName}={queryParamValue}"); + + using var httpRequestMessage = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = uri, + Content = new StringContent(requestContent, Encoding.UTF8, "text/plain") + }; + + var logRecord = new LogRecord(); + var requestHeadersBuffer = new List>(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None); + logRecord.QueryString.Should().NotBeNullOrEmpty(); + logRecord.QueryString.Should().Contain("userId=REDACTED"); + } + + [Fact] + public async Task ReadAsync_SkipsQueryString_WhenClassificationEmpty() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary() // No data classification + }; + + var mockHeadersRedactor = new Mock(); + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + logRecord.QueryString.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadAsync_SetsEmptyQueryParameters_WhenNoMatchingClassification() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "otherParam", FakeTaxonomy.PrivateData } + } + }; + + var mockHeadersRedactor = new Mock(); + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + + logRecord.QueryString.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadAsync_SetsMultipleQueryParameters_WhenMultipleClassifications() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "userId", FakeTaxonomy.PrivateData }, + { "token", FakeTaxonomy.PrivateData } + } + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345&token=abc&other=not_logged"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + logRecord.QueryString.Should().NotBeNullOrEmpty(); + logRecord.QueryString.Should().Be("userId=REDACTED&token=REDACTED"); + } + + [Fact] + public async Task LogRequestStartAsync_LogsQueryParameters_TagArray() + { + // Arrange + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "userId", FakeTaxonomy.PrivateData } + }, + LogRequestStart = true + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + + var fakeLogger = new FakeLogger( + new FakeLogCollector( + Options.Options.Create( + new FakeLogCollectorOptions()))); + using var serviceProvider = GetServiceProvider(headersReader); + var enrichers = Enumerable.Empty(); + var httpRequestReader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var clientLogger = new HttpClientLogger( + fakeLogger, + httpRequestReader, + enrichers, + options); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + await clientLogger.LogRequestStartAsync(httpRequestMessage); + + // Assert + var logRecord = fakeLogger.Collector.GetSnapshot().First(); + var state = logRecord.GetStructuredState(); + + Assert.Contains( + state, + tag => tag.Key == "url.query" && (tag.Value!).Contains("userId=REDACTED")); + } + + [Fact] + public async Task ReadAsync_DoesntSetQueryString_WhenQueryValueEmpty() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "userId", FakeTaxonomy.PrivateData } + } + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny>(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId="); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + logRecord.QueryString.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadAsync_RedactsPathAndQueryParameters() + { + // Arrange + var requestContent = _fixture.Create(); + var queryParamName = "userId"; + var queryParamValue = "12345"; + var pathParamName = "orderId"; + var pathParamValue = "789"; + + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { queryParamName, FakeTaxonomy.PrivateData } + }, + LogBody = true, + RequestPathLoggingMode = OutgoingPathLoggingMode.Formatted + }; + options.RouteParameterDataClasses.Add("routeId", FakeTaxonomy.PrivateData); + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + // The route template includes a path parameter + var routeTemplate = $"/api/orders/{{{pathParamName}}}/details"; + var uri = new Uri($"https://{RequestedHost}/api/orders/{pathParamValue}/details?{queryParamName}={queryParamValue}"); + + using var httpRequestMessage = new HttpRequestMessage(); + httpRequestMessage.Method = HttpMethod.Get; + httpRequestMessage.RequestUri = uri; + httpRequestMessage.Content = new StringContent(requestContent, Encoding.UTF8, "text/plain"); + + // Attach request metadata for the route template + httpRequestMessage.SetRequestMetadata(new RequestMetadata + { + RequestRoute = routeTemplate + }); + + var logRecord = new LogRecord(); + var requestHeadersBuffer = new List>(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None); + + // Assert: path parameter is redacted in the path + logRecord.Path.Should().NotContain(pathParamValue); + logRecord.Path.Should().Contain(Redacted); + + logRecord.QueryString.Should().NotBeNullOrEmpty(); + logRecord.QueryString.Should().Contain($"{queryParamName}={Redacted}"); + logRecord.QueryString.Should().NotContain(queryParamValue); + } + private static ServiceProvider GetServiceProvider( HttpHeadersReader headersReader, string? serviceKey = null, diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs index 4d43c020d1f..996731b08bf 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs @@ -65,12 +65,16 @@ public async Task DisableFor_RespectsOriginalShouldHandlePredicate() } [Fact] - public async Task DisableFor_ResponseMessageIsNull_DoesNotDisableRetries() + public async Task DisableFor_ResponseMessageIsNull_RetrievesRequestMessageFromContext() { var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; options.DisableFor(HttpMethod.Post); - Assert.True(await options.ShouldHandle(CreatePredicateArguments(null))); + using var request = new HttpRequestMessage { Method = HttpMethod.Post }; + var context = ResilienceContextPool.Shared.Get(); + context.SetRequestMessage(request); + + Assert.False(await options.ShouldHandle(CreatePredicateArguments(null, context))); } [Fact] @@ -80,8 +84,10 @@ public async Task DisableFor_RequestMessageIsNull_DoesNotDisableRetries() options.DisableFor(HttpMethod.Post); using var response = new HttpResponseMessage { RequestMessage = null }; + var context = ResilienceContextPool.Shared.Get(); + context.SetRequestMessage(null); - Assert.True(await options.ShouldHandle(CreatePredicateArguments(response))); + Assert.True(await options.ShouldHandle(CreatePredicateArguments(response, context))); } [Theory] @@ -105,10 +111,10 @@ public async Task DisableForUnsafeHttpMethods_PositiveScenario(string httpMethod Assert.Equal(shouldHandle, await options.ShouldHandle(CreatePredicateArguments(response))); } - private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response) + private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response, ResilienceContext? context = null) { return new RetryPredicateArguments( - ResilienceContextPool.Shared.Get(), + context ?? ResilienceContextPool.Shared.Get(), Outcome.FromResult(response), attemptNumber: 1); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs index a1bb703ae5c..93d98707cc5 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs @@ -8,11 +8,11 @@ using System.Threading.Tasks; using FluentAssertions; using Grpc.Core; -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Http.Resilience.Test.Grpc; using Polly; using Xunit; @@ -21,21 +21,26 @@ namespace Microsoft.Extensions.Http.Resilience.Test.Resilience; public class GrpcResilienceTests { - private IWebHost _host; + private IHost _host; private HttpMessageHandler _handler; public GrpcResilienceTests() { - _host = WebHost - .CreateDefaultBuilder() - .ConfigureServices(services => services.AddGrpc()) - .Configure(builder => + _host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => { - builder.UseRouting(); - builder.UseEndpoints(endpoints => endpoints.MapGrpcService()); + webHostBuilder + .UseTestServer() + .ConfigureServices(services => services.AddGrpc()) + .Configure(builder => + { + builder.UseRouting(); + builder.UseEndpoints(endpoints => endpoints.MapGrpcService()); + }); }) - .UseTestServer() - .Start(); + .Build(); + + _host.Start(); _handler = _host.GetTestServer().CreateHandler(); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs index f7e164339f0..b5e50b9c273 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs @@ -16,6 +16,7 @@ using Xunit; namespace Microsoft.Extensions.Http.Resilience.Test.Resilience; + public class HttpStandardResilienceOptionsCustomValidatorTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/.gitignore b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/.gitignore new file mode 100644 index 00000000000..0151cc4e360 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/.gitignore @@ -0,0 +1,2 @@ +# corpuses generated by the fuzzing engine +corpuses/** \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.cs new file mode 100644 index 00000000000..2e4658e3ba2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +internal sealed class DnsResponseFuzzer : IFuzzer +{ + DnsResolver? _resolver; + byte[]? _buffer; + int _length; + + public void FuzzTarget(ReadOnlySpan data) + { + // lazy init + if (_resolver == null) + { + _buffer = new byte[4096]; + _resolver = new DnsResolver(new DnsResolverOptions + { + Servers = [new IPEndPoint(IPAddress.Loopback, 53)], + Timeout = TimeSpan.FromSeconds(5), + MaxAttempts = 1, + _transportOverride = (buffer, length) => + { + // the first two bytes are the random transaction ID, so we keep that + // and use the fuzzing payload for the rest of the DNS response + _buffer.AsSpan(0, Math.Min(_length, buffer.Length - 2)).CopyTo(buffer.Span.Slice(2)); + return _length + 2; + } + }); + } + + data.CopyTo(_buffer!); + _length = data.Length; + + // the _transportOverride makes the execution synchronous + ValueTask task = _resolver!.ResolveIPAddressesAsync("www.example.com", AddressFamily.InterNetwork, CancellationToken.None); + Debug.Assert(task.IsCompleted, "Task should be completed synchronously"); + task.GetAwaiter().GetResult(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/EncodedDomainNameFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/EncodedDomainNameFuzzer.cs new file mode 100644 index 00000000000..72f84b3c959 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/EncodedDomainNameFuzzer.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +internal sealed class EncodedDomainNameFuzzer : IFuzzer +{ + public void FuzzTarget(ReadOnlySpan data) + { + byte[] buffer = ArrayPool.Shared.Rent(data.Length); + try + { + data.CopyTo(buffer); + + // attempt to read at any offset to really stress the parser + for (int i = 0; i < data.Length; i++) + { + if (!DnsPrimitives.TryReadQName(buffer.AsMemory(0, data.Length), i, out EncodedDomainName name, out _)) + { + continue; + } + + // the domain name should be readable + _ = name.ToString(); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/WriteDomainNameRoundTripFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/WriteDomainNameRoundTripFuzzer.cs new file mode 100644 index 00000000000..f657245a842 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/WriteDomainNameRoundTripFuzzer.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +internal sealed class WriteDomainNameRoundTripFuzzer : IFuzzer +{ + private static readonly System.Globalization.IdnMapping s_idnMapping = new(); + public void FuzzTarget(ReadOnlySpan data) + { + // first byte is the offset of the domain name, rest is the actual + // (simulated) DNS message payload + + byte[] buffer = ArrayPool.Shared.Rent(data.Length * 2); + + try + { + string domainName = Encoding.UTF8.GetString(data); + if (!DnsPrimitives.TryWriteQName(buffer, domainName, out int written)) + { + return; + } + + if (!DnsPrimitives.TryReadQName(buffer.AsMemory(0, written), 0, out EncodedDomainName name, out int read)) + { + return; + } + + if (read != written) + { + throw new InvalidOperationException($"Read {read} bytes, but wrote {written} bytes"); + } + + string readName = name.ToString(); + + if (!string.Equals(s_idnMapping.GetAscii(domainName).TrimEnd('.'), readName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Domain name mismatch: {readName} != {domainName}"); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/GlobalUsings.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/GlobalUsings.cs new file mode 100644 index 00000000000..2ff9d86b2ce --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/GlobalUsings.cs @@ -0,0 +1,5 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using System.Buffers; +global using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/IFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/IFuzzer.cs new file mode 100644 index 00000000000..4b4c8c99b4b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/IFuzzer.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +public interface IFuzzer +{ + string Name => GetType().Name; + void FuzzTarget(ReadOnlySpan data); +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj new file mode 100644 index 00000000000..7a1e69033f9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj @@ -0,0 +1,21 @@ + + + + $(TestNetCoreTargetFrameworks) + enable + enable + Exe + Open + + $(NoWarn);IDE0040;IDE0061;IDE1006;S5034;SA1400;VSTHRD002 + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Program.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Program.cs new file mode 100644 index 00000000000..22b1580d1ac --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Program.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using SharpFuzz; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +public static class Program +{ + public static void Main(string[] args) + { + IFuzzer[] fuzzers = typeof(Program).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Contains(typeof(IFuzzer))) + .Select(t => (IFuzzer)Activator.CreateInstance(t)!) + .OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + void PrintUsage() + { + Console.Error.WriteLine($""" + Usage: + DotnetFuzzing list + DotnetFuzzing [input file/directory] + // DotnetFuzzing prepare-onefuzz + + Available fuzzers: + {string.Join(Environment.NewLine, fuzzers.Select(f => $" {f.Name}"))} + """); + } + + if (args.Length == 0) + { + PrintUsage(); + return; + } + + string arg = args[0]; + IFuzzer? fuzzer = fuzzers.FirstOrDefault(f => string.Equals(f.Name, arg, StringComparison.OrdinalIgnoreCase)); + if (fuzzer == null) + { + Console.Error.WriteLine($"Unknown fuzzer: {arg}"); + PrintUsage(); + return; + } + + string? inputFiles = args.Length > 1 ? args[1] : null; + if (string.IsNullOrEmpty(inputFiles)) + { + // no input files, let the fuzzer generate + Fuzzer.LibFuzzer.Run(fuzzer.FuzzTarget); + return; + } + + string[] files = Directory.Exists(inputFiles) + ? Directory.GetFiles(inputFiles) + : [inputFiles]; + + foreach (string inputFile in files) + { + fuzzer.FuzzTarget(File.ReadAllBytes(inputFile)); + } + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/ip-www.example.com b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/ip-www.example.com new file mode 100644 index 00000000000..bb40cd100c6 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/ip-www.example.com differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error new file mode 100644 index 00000000000..92a307a0f72 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error-2 b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error-2 new file mode 100644 index 00000000000..5b37565190e Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error-2 differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/no-data b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/no-data new file mode 100644 index 00000000000..23265fc7a8f Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/no-data differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/server-error b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/server-error new file mode 100644 index 00000000000..27f1054b9b1 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/server-error differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/EncodedDomainNameFuzzer/ip-www.example.com b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/EncodedDomainNameFuzzer/ip-www.example.com new file mode 100644 index 00000000000..c227840c168 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/EncodedDomainNameFuzzer/ip-www.example.com differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/example b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/example new file mode 100644 index 00000000000..8642ba7b94c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/example @@ -0,0 +1 @@ +www.example.com \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/nonascii b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/nonascii new file mode 100644 index 00000000000..692a5a8aee0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/nonascii @@ -0,0 +1 @@ +www.řffwefw.com \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/toolong b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/toolong new file mode 100644 index 00000000000..bb6d3a722e8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/toolong @@ -0,0 +1 @@ +aa.efaw.ef.wef.ef.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.fafeww.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.fwefefefefwefwf.wzzefwefwefwefwfeewfwefwefw.ffff \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/run.ps1 b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/run.ps1 new file mode 100644 index 00000000000..17b3d27055d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/run.ps1 @@ -0,0 +1,106 @@ +param( + # Name of the fuzzing target, see Fuzzers/*.cs files + [Parameter(Mandatory = $true, Position = 0)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $corpusSeedPath = Join-Path $PSScriptRoot "Fuzzers" + if (Test-Path $corpusSeedPath) { + Get-ChildItem -Path $corpusSeedPath -Filter "$wordToComplete*.cs" | ForEach-Object { $_.BaseName } + } + })] + [string] $Target, + + # Number of parallel jobs to run + [int] $Jobs, + + # Maximum length of the input + [int] $MaxLength = 512, + + # Ignore timeouts when running the fuzzer + [switch] $IgnoreTimeouts, + + # Skip the build of the project useful for reruning the fuzzer without recompiling + [switch] $NoBuild, + + # Path to the libfuzzer driver + [string] $LibFuzzer = "libfuzzer-dotnet-windows" +) + +$timeout = 30 +$SharpFuzz = "sharpfuzz" +$dict = $null + +$corpus = Join-Path $PSScriptRoot "corpuses" $Target +$null = New-Item -Path $corpus -ItemType Directory -Force + +$CorpusSeed = Join-Path $PSScriptRoot "corpus-seed" $Target + +if (Test-Path $CorpusSeed -ErrorAction SilentlyContinue) { + Write-Output "Copying corpus seed from $CorpusSeed to $corpus" + Get-ChildItem -Path $CorpusSeed | Copy-Item -Destination $corpus +} + +$project = Join-Path $PSScriptRoot "Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj" + +Set-StrictMode -Version Latest + +$outputDir = "bin" +$projectName = (Get-Item $project).BaseName +$projectDll = "$projectName.dll" +$executable = if ($IsWindows) { Join-Path $outputDir "$projectName.exe" } +else { Join-Path $outputDir "$projectName" } + +if (!$NoBuild) { + + if (Test-Path $outputDir) { + Remove-Item -Recurse -Force $outputDir + } + + dotnet publish $project -c release -o $outputDir + + $exclusions = @( + "dnlib.dll", + "SharpFuzz.dll", + "SharpFuzz.Common.dll", + $projectDll + ) + + $fuzzingTargets = @(Get-Item "$outputDir/Microsoft.Extensions.ServiceDiscovery.Dns.dll") + + if (($fuzzingTargets | Measure-Object).Count -eq 0) { + Write-Error "No fuzzing targets found" + exit 1 + } + + foreach ($fuzzingTarget in $fuzzingTargets) { + Write-Output "Instrumenting $fuzzingTarget" + & $SharpFuzz $fuzzingTarget.FullName + + if ($LastExitCode -ne 0) { + Write-Error "An error occurred while instrumenting $fuzzingTarget" + exit 1 + } + } +} + +$parameters = @( + "-timeout=$timeout" +) + +if ($Jobs) { + $parameters += "-fork=$Jobs" +} + +if ($IgnoreTimeouts) { + $parameters += "-ignore_timeouts=1" +} + +if ($MaxLength) { + $parameters += "-max_len=$MaxLength" +} + +if ($dict) { + $parameters += "-dict=$dict" +} + +& $LibFuzzer @parameters --target_path=$executable --target_arg=$Target $corpus \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..b949e713999 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServiceEndpointResolverTests.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +public class DnsServiceEndpointResolverTests +{ + [Fact] + public async Task ResolveServiceEndpoint_Dns_MultiShot() + { + var timeProvider = new FakeTimeProvider(); + var services = new ServiceCollection() + .AddSingleton(timeProvider) + .AddSingleton() + .AddServiceDiscoveryCore() + .AddDnsServiceEndpointProvider(o => o.DefaultRefreshPeriod = TimeSpan.FromSeconds(30)) + .BuildServiceProvider(); + var resolver = services.GetRequiredService(); + var initialResult = await resolver.GetEndpointsAsync("https://localhost", CancellationToken.None); + Assert.NotNull(initialResult); + Assert.True(initialResult.Endpoints.Count > 0); + timeProvider.Advance(TimeSpan.FromSeconds(7)); + var secondResult = await resolver.GetEndpointsAsync("https://localhost", CancellationToken.None); + Assert.NotNull(secondResult); + Assert.True(initialResult.Endpoints.Count > 0); + timeProvider.Advance(TimeSpan.FromSeconds(80)); + var thirdResult = await resolver.GetEndpointsAsync("https://localhost", CancellationToken.None); + Assert.NotNull(thirdResult); + Assert.True(initialResult.Endpoints.Count > 0); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServicePublicApiTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServicePublicApiTests.cs new file mode 100644 index 00000000000..69bb6e0e510 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServicePublicApiTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +public class DnsServicePublicApiTests +{ + [Fact] + public void AddDnsSrvServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddDnsSrvServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddDnsSrvServiceEndpointProviderWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddDnsSrvServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddDnsSrvServiceEndpointProviderWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + IServiceCollection services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddDnsSrvServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddDnsServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddDnsServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddDnsServiceEndpointProviderWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddDnsServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.ConfigureDnsResolver(_ => { }); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenConfigureOptionsIsNull() + { + IServiceCollection services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.ConfigureDnsResolver(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsSrvServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsSrvServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..ec21bf9fa9c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsSrvServiceEndpointResolverTests.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +/// +/// Tests for and . +/// These also cover and by extension. +/// +public class DnsSrvServiceEndpointResolverTests +{ + private sealed class FakeDnsResolver : IDnsResolver + { + public Func>? ResolveIPAddressesAsyncFunc { get; set; } + public ValueTask ResolveIPAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc!.Invoke(name, addressFamily, cancellationToken); + + public Func>? ResolveIPAddressesAsyncFunc2 { get; set; } + + public ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc2!.Invoke(name, cancellationToken); + + public Func>? ResolveServiceAsyncFunc { get; set; } + + public ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) => ResolveServiceAsyncFunc!.Invoke(name, cancellationToken); + } + + [Fact] + public async Task ResolveServiceEndpoint_DnsSrv() + { + var dnsClientMock = new FakeDnsResolver + { + ResolveServiceAsyncFunc = (name, cancellationToken) => + { + ServiceResult[] response = [ + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 66, 8888, "srv-a", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Parse("10.10.10.10"))]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 9999, "srv-b", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.IPv6Loopback)]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 7777, "srv-c", []) + ]; + + return ValueTask.FromResult(response); + } + }; + var services = new ServiceCollection() + .AddSingleton(dnsClientMock) + .AddServiceDiscoveryCore() + .AddDnsSrvServiceEndpointProvider(options => options.QuerySuffix = ".ns") + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + var eps = initialResult.EndpointSource.Endpoints; + Assert.Equal(new IPEndPoint(IPAddress.Parse("10.10.10.10"), 8888), eps[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.IPv6Loopback, 9999), eps[1].EndPoint); + Assert.Equal(new DnsEndPoint("srv-c", 7777), eps[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + + /// + /// Tests that when there are multiple resolvers registered, they are consulted in registration order and each provider only adds endpoints if the providers before it did not. + /// + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task ResolveServiceEndpoint_DnsSrv_MultipleProviders_PreventMixing(bool dnsFirst) + { + var dnsClientMock = new FakeDnsResolver + { + ResolveServiceAsyncFunc = (name, cancellationToken) => + { + ServiceResult[] response = [ + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 66, 8888, "srv-a", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Parse("10.10.10.10"))]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 9999, "srv-b", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.IPv6Loopback)]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 7777, "srv-c", []) + ]; + + return ValueTask.FromResult(response); + } + }; + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:http:0"] = "localhost:8080", + ["services:basket:http:1"] = "remotehost:9090", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var serviceCollection = new ServiceCollection() + .AddSingleton(dnsClientMock) + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore(); + if (dnsFirst) + { + serviceCollection + .AddDnsSrvServiceEndpointProvider(options => + { + options.QuerySuffix = ".ns"; + options.ShouldApplyHostNameMetadata = _ => true; + }) + .AddConfigurationServiceEndpointProvider(); + } + else + { + serviceCollection + .AddConfigurationServiceEndpointProvider() + .AddDnsSrvServiceEndpointProvider(options => options.QuerySuffix = ".ns"); + }; + var services = serviceCollection.BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.Null(initialResult.Exception); + Assert.True(initialResult.ResolvedSuccessfully); + + if (dnsFirst) + { + // We expect only the results from the DNS provider. + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + var eps = initialResult.EndpointSource.Endpoints; + Assert.Equal(new IPEndPoint(IPAddress.Parse("10.10.10.10"), 8888), eps[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.IPv6Loopback, 9999), eps[1].EndPoint); + Assert.Equal(new DnsEndPoint("srv-c", 7777), eps[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + else + { + // We expect only the results from the Configuration provider. + Assert.Equal(2, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new DnsEndPoint("localhost", 8080), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new DnsEndPoint("remotehost", 9090), initialResult.EndpointSource.Endpoints[1].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.csproj new file mode 100644 index 00000000000..d298b2c4699 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.csproj @@ -0,0 +1,27 @@ + + + + $(TestNetCoreTargetFrameworks) + enable + enable + Open + + $(NoWarn);IDE0004;IDE0017;IDE0040;IDE0055;IDE1006;CA1012;CA1031;CA1063;CA1816;CA2000;S103;S107;S1067;S1121;S1128;S1135;S1144;S1186;S2148;S3442;S3459;S4136;SA1106;SA1127;SA1204;SA1208;SA1210;SA1128;SA1316;SA1400;SA1402;SA1407;SA1414;SA1500;SA1513;SA1515;VSTHRD003 + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/CancellationTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/CancellationTests.cs new file mode 100644 index 00000000000..786882afc1d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/CancellationTests.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class CancellationTests : LoopbackDnsTestBase +{ + public CancellationTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task PreCanceledToken_Throws() + { + CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(async () => await Resolver.ResolveIPAddressesAsync("example.com", AddressFamily.InterNetwork, cts.Token)); + + Assert.Equal(cts.Token, ex.CancellationToken); + } + + [Fact] + public async Task CancellationInProgress_Throws() + { + CancellationTokenSource cts = new CancellationTokenSource(); + + var task = Assert.ThrowsAnyAsync(async () => await Resolver.ResolveIPAddressesAsync("example.com", AddressFamily.InterNetwork, cts.Token)); + + await DnsServer.ProcessUdpRequest(_ => + { + cts.Cancel(); + return Task.CompletedTask; + }); + + OperationCanceledException ex = await task; + Assert.Equal(cts.Token, ex.CancellationToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataReaderTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataReaderTests.cs new file mode 100644 index 00000000000..aad32fe785f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataReaderTests.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class DnsDataReaderTests +{ + [Fact] + public void ReadResourceRecord_Success() + { + // example A record for example.com + byte[] buffer = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + DnsDataReader reader = new DnsDataReader(buffer); + Assert.True(reader.TryReadResourceRecord(out DnsResourceRecord record)); + + Assert.Equal("www.example.com", record.Name.ToString()); + Assert.Equal(QueryType.A, record.Type); + Assert.Equal(QueryClass.Internet, record.Class); + Assert.Equal(3600, record.Ttl); + Assert.Equal(4, record.Data.Length); + } + + [Fact] + public void ReadResourceRecord_Truncated_Fails() + { + // example A record for example.com + byte[] buffer = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + for (int i = 0; i < buffer.Length; i++) + { + DnsDataReader reader = new DnsDataReader(new ArraySegment(buffer, 0, i)); + Assert.False(reader.TryReadResourceRecord(out _)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataWriterTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataWriterTests.cs new file mode 100644 index 00000000000..b2039ce5a4c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataWriterTests.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class DnsDataWriterTests +{ + [Fact] + public void WriteResourceRecord_Success() + { + // example A record for example.com + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + DnsResourceRecord record = new DnsResourceRecord(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet, 3600, new byte[4]); + + byte[] buffer = new byte[512]; + DnsDataWriter writer = new DnsDataWriter(buffer); + Assert.True(writer.TryWriteResourceRecord(record)); + Assert.Equal(expected, buffer.AsSpan().Slice(0, writer.Position).ToArray()); + } + + [Fact] + public void WriteResourceRecord_Truncated_Fails() + { + // example A record for example.com + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + DnsResourceRecord record = new DnsResourceRecord(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet, 3600, new byte[4]); + + byte[] buffer = new byte[512]; + for (int i = 0; i < expected.Length; i++) + { + DnsDataWriter writer = new DnsDataWriter(buffer.AsMemory(0, i)); + Assert.False(writer.TryWriteResourceRecord(record)); + } + } + + [Fact] + public void WriteQuestion_Success() + { + // example question for example.com (A record) + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01 + ]; + + byte[] buffer = new byte[512]; + DnsDataWriter writer = new DnsDataWriter(buffer); + Assert.True(writer.TryWriteQuestion(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet)); + Assert.Equal(expected, buffer.AsSpan().Slice(0, writer.Position).ToArray()); + } + + [Fact] + public void WriteQuestion_Truncated_Fails() + { + // example question for example.com (A record) + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01 + ]; + + byte[] buffer = new byte[512]; + for (int i = 0; i < expected.Length; i++) + { + DnsDataWriter writer = new DnsDataWriter(buffer.AsMemory(0, i)); + Assert.False(writer.TryWriteQuestion(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet)); + } + } + + [Fact] + public void WriteHeader_Success() + { + // example header + byte[] expected = [ + // ID (0x1234) + 0x12, 0x34, + // Flags (0x5678) + 0x56, 0x78, + // Question count (1) + 0x00, 0x01, + // Answer count (0) + 0x00, 0x02, + // Authority count (0) + 0x00, 0x03, + // Additional count (0) + 0x00, 0x04 + ]; + + DnsMessageHeader header = new() + { + TransactionId = 0x1234, + QueryFlags = (QueryFlags)0x5678, + QueryCount = 1, + AnswerCount = 2, + AuthorityCount = 3, + AdditionalRecordCount = 4, + }; + + byte[] buffer = new byte[512]; + DnsDataWriter writer = new DnsDataWriter(buffer); + Assert.True(writer.TryWriteHeader(header)); + Assert.Equal(expected, buffer.AsSpan().Slice(0, writer.Position).ToArray()); + } + + private static EncodedDomainName EncodeDomainName(string name) + { + byte[] nameBuffer = new byte[512]; + Assert.True(DnsPrimitives.TryWriteQName(nameBuffer, name, out int nameLength)); + Assert.True(DnsPrimitives.TryReadQName(nameBuffer.AsMemory(0, nameLength), 0, out EncodedDomainName encodedDomainName, out _)); + return encodedDomainName; + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsPrimitivesTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsPrimitivesTests.cs new file mode 100644 index 00000000000..6733a553bad --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsPrimitivesTests.cs @@ -0,0 +1,195 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class DnsPrimitivesTests +{ + public static TheoryData QNameData => new() + { + { "www.example.com", "\x0003www\x0007example\x0003com\x0000"u8.ToArray() }, + { "example.com", "\x0007example\x0003com\x0000"u8.ToArray() }, + { "com", "\x0003com\x0000"u8.ToArray() }, + { "example", "\x0007example\x0000"u8.ToArray() }, + { "www", "\x0003www\x0000"u8.ToArray() }, + { "a", "\x0001a\x0000"u8.ToArray() }, + }; + + [Theory] + [MemberData(nameof(QNameData))] + public void TryWriteQName_Success(string name, byte[] expected) + { + byte[] buffer = new byte[512]; + + Assert.True(DnsPrimitives.TryWriteQName(buffer, name, out int written)); + Assert.Equal(name.Length + 2, written); + Assert.Equal(expected, buffer.AsSpan().Slice(0, written).ToArray()); + } + + [Fact] + public void TryWriteQName_LabelTooLong_False() + { + byte[] buffer = new byte[512]; + + Assert.False(DnsPrimitives.TryWriteQName(buffer, new string('a', 70), out _)); + } + + [Fact] + public void TryWriteQName_BufferTooShort_Fails() + { + byte[] buffer = new byte[512]; + string name = "www.example.com"; + + for (int i = 0; i < name.Length + 2; i++) + { + Assert.False(DnsPrimitives.TryWriteQName(buffer.AsSpan(0, i), name, out _)); + } + } + + [Theory] + [InlineData("www.-0.com")] + [InlineData("www.-a.com")] + [InlineData("www.a-.com")] + [InlineData("www.a_a.com")] + [InlineData("www.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com")] // 64 occurrences of 'a' (too long) + [InlineData("www.a~a.com")] // 64 occurrences of 'a' (too long) + [InlineData("www..com")] + [InlineData("www..")] + public void TryWriteQName_InvalidName_ReturnsFalse(string name) + { + byte[] buffer = new byte[512]; + Assert.False(DnsPrimitives.TryWriteQName(buffer, name, out _)); + } + + [Fact] + public void TryWriteQName_ExplicitRoot_Success() + { + string name1 = "www.example.com"; + string name2 = "www.example.com."; + + byte[] buffer1 = new byte[512]; + byte[] buffer2 = new byte[512]; + + Assert.True(DnsPrimitives.TryWriteQName(buffer1, name1, out int written1)); + Assert.True(DnsPrimitives.TryWriteQName(buffer2, name2, out int written2)); + Assert.Equal(written1, written2); + Assert.Equal(buffer1.AsSpan().Slice(0, written1).ToArray(), buffer2.AsSpan().Slice(0, written2).ToArray()); + } + + [Theory] + [MemberData(nameof(QNameData))] + public void TryReadQName_Success(string expected, byte[] serialized) + { + Assert.True(DnsPrimitives.TryReadQName(serialized, 0, out EncodedDomainName actual, out int bytesRead)); + Assert.Equal(expected, actual.ToString()); + Assert.Equal(serialized.Length, bytesRead); + } + + [Fact] + public void TryReadQName_TruncatedData_Fails() + { + ReadOnlyMemory data = "\x0003www\x0007example\x0003com\x0000"u8.ToArray(); + + for (int i = 0; i < data.Length; i++) + { + Assert.False(DnsPrimitives.TryReadQName(data.Slice(0, i), 0, out _, out _)); + } + } + + [Fact] + public void TryReadQName_Pointer_Success() + { + // [7B padding], example.com. www->[ptr to example.com.] + Memory data = "padding\x0007example\x0003com\x0000\x0003www\x00\x07"u8.ToArray(); + data.Span[^2] = 0xc0; + + Assert.True(DnsPrimitives.TryReadQName(data, data.Length - 6, out EncodedDomainName actual, out int bytesRead)); + Assert.Equal("www.example.com", actual.ToString()); + Assert.Equal(6, bytesRead); + } + + [Fact] + public void TryReadQName_PointerTruncated_Fails() + { + // [7B padding], example.com. www->[ptr to example.com.] + Memory data = "padding\x0007example\x0003com\x0000\x0003www\x00\x07"u8.ToArray(); + data.Span[^2] = 0xc0; + + for (int i = 0; i < data.Length; i++) + { + Assert.False(DnsPrimitives.TryReadQName(data.Slice(0, i), data.Length - 6, out _, out _)); + } + } + + [Fact] + public void TryReadQName_ForwardPointer_Fails() + { + // www->[ptr to example.com], [7B padding], example.com. + Memory data = "\x03www\x00\x000dpadding\x0007example\x0003com\x00"u8.ToArray(); + data.Span[4] = 0xc0; + + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + + [Fact] + public void TryReadQName_PointerToSelf_Fails() + { + // www->[ptr to www->...] + Memory data = "\x0003www\0\0"u8.ToArray(); + data.Span[4] = 0xc0; + + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + + [Fact] + public void TryReadQName_PointerToPointer_Fails() + { + // com, example[->com], example2[->[->com]] + Memory data = "\x0003com\0\x0007example\0\0\x0008example2\0\0"u8.ToArray(); + data.Span[13] = 0xc0; + data.Span[14] = 0x00; // -> com + data.Span[24] = 0xc0; + data.Span[25] = 13; // -> -> com + + Assert.False(DnsPrimitives.TryReadQName(data, 15, out _, out _)); + } + + [Fact] + public void TryReadQName_ReservedBits() + { + Memory data = "\x0003www\x00c0"u8.ToArray(); + data.Span[0] = 0x40; + + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + + [Theory] + [InlineData(253)] + [InlineData(254)] + [InlineData(255)] + public void TryReadQName_NameTooLong(int length) + { + // longest possible label is 63 bytes + 1 byte for length + byte[] labelData = new byte[64]; + Array.Fill(labelData, (byte)'a'); + labelData[0] = 63; + + int remainder = length - 3 * 64; + + byte[] lastLabelData = new byte[remainder + 1]; + Array.Fill(lastLabelData, (byte)'a'); + lastLabelData[0] = (byte)remainder; + + byte[] data = Enumerable.Repeat(labelData, 3).SelectMany(x => x).Concat(lastLabelData).Concat(new byte[1]).ToArray(); + if (length > 253) + { + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + else + { + Assert.True(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsServer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsServer.cs new file mode 100644 index 00000000000..4789e21c575 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsServer.cs @@ -0,0 +1,331 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Buffers.Binary; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +internal sealed class LoopbackDnsServer : IDisposable +{ + private readonly Socket _dnsSocket; + private Socket? _tcpSocket; + + public IPEndPoint DnsEndPoint => (IPEndPoint)_dnsSocket.LocalEndPoint!; + + public LoopbackDnsServer() + { + _dnsSocket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _dnsSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + } + + public void Dispose() + { + _dnsSocket.Dispose(); + _tcpSocket?.Dispose(); + } + + private static async Task ProcessRequestCore(IPEndPoint remoteEndPoint, ArraySegment message, Func action, Memory responseBuffer) + { + DnsDataReader reader = new DnsDataReader(message); + + if (!reader.TryReadHeader(out DnsMessageHeader header) || + !reader.TryReadQuestion(out var name, out var type, out var @class)) + { + return 0; + } + + LoopbackDnsResponseBuilder responseBuilder = new(name.ToString(), type, @class); + responseBuilder.TransactionId = header.TransactionId; + responseBuilder.Flags = header.QueryFlags | QueryFlags.HasResponse; + responseBuilder.ResponseCode = QueryResponseCode.NoError; + + await action(responseBuilder, remoteEndPoint); + + return responseBuilder.Write(responseBuffer); + } + + public async Task ProcessUdpRequest(Func action) + { + byte[] buffer = ArrayPool.Shared.Rent(512); + try + { + EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0); + SocketReceiveFromResult result = await _dnsSocket.ReceiveFromAsync(buffer, remoteEndPoint); + + int bytesWritten = await ProcessRequestCore((IPEndPoint)result.RemoteEndPoint, new ArraySegment(buffer, 0, result.ReceivedBytes), action, buffer.AsMemory(0, 512)); + + await _dnsSocket.SendToAsync(buffer.AsMemory(0, bytesWritten), SocketFlags.None, result.RemoteEndPoint); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public Task ProcessUdpRequest(Func action) + { + return ProcessUdpRequest((builder, _) => action(builder)); + } + + public async Task ProcessTcpRequest(Func action) + { + if (_tcpSocket is null) + { + _tcpSocket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + _tcpSocket.Bind(new IPEndPoint(IPAddress.Loopback, ((IPEndPoint)_dnsSocket.LocalEndPoint!).Port)); + _tcpSocket.Listen(); + } + + using Socket tcpClient = await _tcpSocket.AcceptAsync(); + + byte[] buffer = ArrayPool.Shared.Rent(8 * 1024); + try + { + int bytesRead = 0; + int length = -1; + while (length < 0 || bytesRead < length + 2) + { + int toRead = length < 0 ? 2 : length + 2 - bytesRead; + int read = await tcpClient.ReceiveAsync(buffer.AsMemory(bytesRead, toRead), SocketFlags.None); + bytesRead += read; + + if (length < 0 && bytesRead >= 2) + { + length = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(0, 2)); + } + } + + int bytesWritten = await ProcessRequestCore((IPEndPoint)tcpClient.RemoteEndPoint!, new ArraySegment(buffer, 2, length), action, buffer.AsMemory(2)); + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(0, 2), (ushort)bytesWritten); + await tcpClient.SendAsync(buffer.AsMemory(0, bytesWritten + 2), SocketFlags.None); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public Task ProcessTcpRequest(Func action) + { + return ProcessTcpRequest((builder, _) => action(builder)); + } +} + +internal sealed class LoopbackDnsResponseBuilder +{ + private static readonly SearchValues s_domainNameValidChars = SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."); + + public LoopbackDnsResponseBuilder(string name, QueryType type, QueryClass @class) + { + Name = name; + Type = type; + Class = @class; + Questions.Add((name, type, @class)); + + if (name.AsSpan().ContainsAnyExcept(s_domainNameValidChars)) + { + throw new ArgumentException($"Invalid characters in domain name '{name}'"); + } + } + + public ushort TransactionId { get; set; } + public QueryFlags Flags { get; set; } + public QueryResponseCode ResponseCode { get; set; } + + public string Name { get; } + public QueryType Type { get; } + public QueryClass Class { get; } + + public List<(string, QueryType, QueryClass)> Questions { get; } = new List<(string, QueryType, QueryClass)>(); + public List Answers { get; } = new List(); + public List Authorities { get; } = new List(); + public List Additionals { get; } = new List(); + + public int Write(Memory responseBuffer) + { + DnsDataWriter writer = new(responseBuffer); + if (!writer.TryWriteHeader(new DnsMessageHeader + { + TransactionId = TransactionId, + QueryFlags = Flags | (QueryFlags)ResponseCode, + QueryCount = (ushort)Questions.Count, + AnswerCount = (ushort)Answers.Count, + AuthorityCount = (ushort)Authorities.Count, + AdditionalRecordCount = (ushort)Additionals.Count + })) + { + throw new InvalidOperationException("Failed to write header"); + } + + byte[] buffer = ArrayPool.Shared.Rent(512); + foreach (var (questionName, questionType, questionClass) in Questions) + { + if (!DnsPrimitives.TryWriteQName(buffer, questionName, out int length) || + !DnsPrimitives.TryReadQName(buffer.AsMemory(0, length), 0, out EncodedDomainName encodedName, out _)) + { + throw new InvalidOperationException("Failed to encode domain name"); + } + if (!writer.TryWriteQuestion(encodedName, questionType, questionClass)) + { + throw new InvalidOperationException("Failed to write question"); + } + } + ArrayPool.Shared.Return(buffer); + + foreach (var answer in Answers) + { + if (!writer.TryWriteResourceRecord(answer)) + { + throw new InvalidOperationException("Failed to write answer"); + } + } + + foreach (var authority in Authorities) + { + if (!writer.TryWriteResourceRecord(authority)) + { + throw new InvalidOperationException("Failed to write authority"); + } + } + + foreach (var additional in Additionals) + { + if (!writer.TryWriteResourceRecord(additional)) + { + throw new InvalidOperationException("Failed to write additional records"); + } + } + + return writer.Position; + } + + public byte[] GetMessageBytes() + { + byte[] buffer = ArrayPool.Shared.Rent(512); + try + { + int bytesWritten = Write(buffer.AsMemory(0, 512)); + return buffer.AsSpan(0, bytesWritten).ToArray(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} + +internal static class LoopbackDnsServerExtensions +{ + private static readonly IdnMapping s_idnMapping = new IdnMapping(); + + private static EncodedDomainName EncodeDomainName(string name) + { + var encodedLabels = name.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(label => (ReadOnlyMemory)Encoding.UTF8.GetBytes(s_idnMapping.GetAscii(label))) + .ToList(); + + return new EncodedDomainName(encodedLabels); + } + + public static List AddAddress(this List records, string name, int ttl, IPAddress address) + { + QueryType type = address.AddressFamily == AddressFamily.InterNetwork ? QueryType.A : QueryType.AAAA; + records.Add(new DnsResourceRecord(EncodeDomainName(name), type, QueryClass.Internet, ttl, address.GetAddressBytes())); + return records; + } + + public static List AddCname(this List records, string name, int ttl, string alias) + { + byte[] buff = new byte[256]; + if (!DnsPrimitives.TryWriteQName(buff, alias, out int length)) + { + throw new InvalidOperationException("Failed to encode domain name"); + } + + records.Add(new DnsResourceRecord(EncodeDomainName(name), QueryType.CNAME, QueryClass.Internet, ttl, buff.AsMemory(0, length))); + return records; + } + + public static List AddService(this List records, string name, int ttl, ushort priority, ushort weight, ushort port, string target) + { + byte[] buff = new byte[256]; + + // https://www.rfc-editor.org/rfc/rfc2782 + if (!BinaryPrimitives.TryWriteUInt16BigEndian(buff, priority) || + !BinaryPrimitives.TryWriteUInt16BigEndian(buff.AsSpan(2), weight) || + !BinaryPrimitives.TryWriteUInt16BigEndian(buff.AsSpan(4), port) || + !DnsPrimitives.TryWriteQName(buff.AsSpan(6), target, out int length)) + { + throw new InvalidOperationException("Failed to encode SRV record"); + } + + length += 6; + + records.Add(new DnsResourceRecord(EncodeDomainName(name), QueryType.SRV, QueryClass.Internet, ttl, buff.AsMemory(0, length))); + return records; + } + + public static List AddStartOfAuthority(this List records, string name, int ttl, string mname, string rname, uint serial, uint refresh, uint retry, uint expire, uint minimum) + { + byte[] buff = new byte[256]; + + // https://www.rfc-editor.org/rfc/rfc1035#section-3.3.13 + if (!DnsPrimitives.TryWriteQName(buff, mname, out int w1) || + !DnsPrimitives.TryWriteQName(buff.AsSpan(w1), rname, out int w2) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2), serial) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 4), refresh) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 8), retry) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 12), expire) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 16), minimum)) + { + throw new InvalidOperationException("Failed to encode SOA record"); + } + + int length = w1 + w2 + 20; + + records.Add(new DnsResourceRecord(EncodeDomainName(name), QueryType.SOA, QueryClass.Internet, ttl, buff.AsMemory(0, length))); + return records; + } +} + +internal static class DnsDataWriterExtensions +{ + internal static bool TryWriteResourceRecord(this DnsDataWriter writer, DnsResourceRecord record) + { + if (!TryWriteDomainName(writer, record.Name) || + !writer.TryWriteUInt16((ushort)record.Type) || + !writer.TryWriteUInt16((ushort)record.Class) || + !writer.TryWriteUInt32((uint)record.Ttl) || + !writer.TryWriteUInt16((ushort)record.Data.Length) || + !writer.TryWriteRawData(record.Data.Span)) + { + return false; + } + + return true; + } + + internal static bool TryWriteDomainName(this DnsDataWriter writer, EncodedDomainName name) + { + foreach (var label in name.Labels) + { + if (label.Length > 63) + { + throw new InvalidOperationException("Label length exceeds maximum of 63 bytes"); + } + + if (!writer.TryWriteByte((byte)label.Length) || + !writer.TryWriteRawData(label.Span)) + { + return false; + } + } + + // root label + return writer.TryWriteByte(0); + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsTestBase.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsTestBase.cs new file mode 100644 index 00000000000..6d2aba6cb64 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsTestBase.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Tests; +using Microsoft.Extensions.Time.Testing; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public abstract class LoopbackDnsTestBase : IDisposable +{ + protected readonly ITestOutputHelper Output; + + internal LoopbackDnsServer DnsServer { get; } + private readonly Lazy _resolverLazy; + internal DnsResolver Resolver => _resolverLazy.Value; + internal DnsResolverOptions Options { get; } + protected readonly FakeTimeProvider TimeProvider; + + public LoopbackDnsTestBase(ITestOutputHelper output) + { + Output = output; + DnsServer = new(); + TimeProvider = new(); + Options = new() + { + Servers = [DnsServer.DnsEndPoint], + Timeout = TimeSpan.FromSeconds(5), + MaxAttempts = 1, + }; + _resolverLazy = new(InitializeResolver); + } + + DnsResolver InitializeResolver() + { + ServiceCollection services = new(); + services.AddXunitLogging(Output); + + var resolver = new DnsResolver(TimeProvider, NullLogger.Instance, new OptionsWrapper(Options)); + return resolver; + } + + public void Dispose() + { + DnsServer.Dispose(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolvConfTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolvConfTests.cs new file mode 100644 index 00000000000..4c2bcadd8a5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolvConfTests.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; +using System.Net; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class ResolvConfTests +{ + [Fact] + public void GetServers() + { + var contents = @" +nameserver 10.96.0.10 +search default.svc.cluster.local svc.cluster.local cluster.local +options ndots:5 +@"; + + var reader = new StringReader(contents); + var servers = ResolvConf.GetServers(reader); + + IPEndPoint ipAddress = Assert.Single(servers); + Assert.Equal(new IPEndPoint(IPAddress.Parse("10.96.0.10"), 53), ipAddress); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs new file mode 100644 index 00000000000..c2d033ecdae --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs @@ -0,0 +1,307 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class ResolveAddressesTests : LoopbackDnsTestBase +{ + public ResolveAddressesTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveIPv4_NoData_Success(bool includeSoa) + { + string hostName = "nodata.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + if (includeSoa) + { + builder.Authorities.AddStartOfAuthority("ns.com", 240, "ns.com", "admin.ns.com", 1, 900, 180, 6048000, 3600); + } + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(results); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveIPv4_NoSuchName_Success(bool includeSoa) + { + string hostName = "nosuchname.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.ResponseCode = QueryResponseCode.NameError; + if (includeSoa) + { + builder.Authorities.AddStartOfAuthority("ns.com", 240, "ns.com", "admin.ns.com", 1, 900, 180, 6048000, 3600); + } + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(results); + } + + [Theory] + [InlineData("www.resolveipv4.com")] + [InlineData("www.resolveipv4.com.")] + [InlineData("www.ř.com")] + public async Task ResolveIPv4_Simple_Success(string name) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddAddress(name, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(name, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task ResolveIPv4_Aliases_InOrder_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-in-order.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddAddress("www.example3.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task ResolveIPv4_Aliases_OutOfOrder_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-out-of-order.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddAddress("www.example3.com", 3600, address); + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task ResolveIPv4_Aliases_Loop_ReturnsEmpty() + { + string hostName = "alias-loop2.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddCname("www.example3.com", 3600, hostName); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_Aliases_Loop_Reverse_ReturnsEmpty() + { + string hostName = "alias-loop2.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname("www.example3.com", 3600, hostName); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_Alias_And_Address() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-address.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddAddress("www.example2.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_DuplicateAlias() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "duplicate-alias.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example4.com"); + builder.Answers.AddAddress("www.example2.com", 3600, address); + builder.Answers.AddAddress("www.example4.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_Aliases_NotFound_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-no-found.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + + // extra address in the answer not connected to the above + builder.Answers.AddAddress("www.example4.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIP_InvalidAddressFamily_Throws() + { + await Assert.ThrowsAsync(async () => await Resolver.ResolveIPAddressesAsync("invalid-af.test", AddressFamily.Unknown)); + } + + [Theory] + [InlineData(AddressFamily.InterNetwork, "127.0.0.1")] + [InlineData(AddressFamily.InterNetworkV6, "::1")] + public async Task ResolveIP_Localhost_ReturnsLoopback(AddressFamily family, string addressAsString) + { + IPAddress address = IPAddress.Parse(addressAsString); + AddressResult[] results = await Resolver.ResolveIPAddressesAsync("localhost", family); + AddressResult result = Assert.Single(results); + + Assert.Equal(address, result.Address); + } + + [Fact] + public async Task Resolve_Timeout_ReturnsEmpty() + { + Options.Timeout = TimeSpan.FromSeconds(1); + AddressResult[] result = await Resolver.ResolveIPAddressesAsync("timeout-empty.test", AddressFamily.InterNetwork); + Assert.Empty(result); + } + + [Theory] + [InlineData("not-example.com", (int)QueryType.A, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.AAAA, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.A, 0)] + public async Task Resolve_QuestionMismatch_ReturnsEmpty(string name, int type, int @class) + { + Options.Timeout = TimeSpan.FromSeconds(1); + + IPAddress address = IPAddress.Parse("172.213.245.111"); + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Questions[0] = (name, (QueryType)type, (QueryClass)@class); + builder.Answers.AddAddress("www.example4.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] result = await Resolver.ResolveIPAddressesAsync("example.com", AddressFamily.InterNetwork); + Assert.Empty(result); + } + + [Fact] + public async Task Resolve_HeaderMismatch_Ignores() + { + string name = "header-mismatch.test"; + Options.Timeout = TimeSpan.FromSeconds(5); + + SemaphoreSlim responseSemaphore = new SemaphoreSlim(0, 1); + SemaphoreSlim requestSemaphore = new SemaphoreSlim(0, 1); + + IPEndPoint clientAddress = null!; + + IPAddress address = IPAddress.Parse("172.213.245.111"); + ushort transactionId = 0x1234; + _ = DnsServer.ProcessUdpRequest((builder, clientAddr) => + { + clientAddress = clientAddr; + transactionId = (ushort)(builder.TransactionId + 1); + + builder.Answers.AddAddress(name, 3600, address); + requestSemaphore.Release(); + return responseSemaphore.WaitAsync(); + }); + + ValueTask task = Resolver.ResolveIPAddressesAsync(name, AddressFamily.InterNetwork); + + await requestSemaphore.WaitAsync().WaitAsync(Options.Timeout); + + using Socket socket = new Socket(clientAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + LoopbackDnsResponseBuilder responseBuilder = new LoopbackDnsResponseBuilder(name, QueryType.A, QueryClass.Internet) + { + TransactionId = transactionId, + ResponseCode = QueryResponseCode.NoError + }; + + responseBuilder.Questions.Add((name, QueryType.A, QueryClass.Internet)); + responseBuilder.Answers.AddAddress(name, 3600, IPAddress.Loopback); + socket.SendTo(responseBuilder.GetMessageBytes(), clientAddress); + + responseSemaphore.Release(); + + AddressResult[] results = await task; + AddressResult result = Assert.Single(results); + + Assert.Equal(address, result.Address); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveServiceTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveServiceTests.cs new file mode 100644 index 00000000000..82ca3175789 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveServiceTests.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class ResolveServiceTests : LoopbackDnsTestBase +{ + public ResolveServiceTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task ResolveService_Simple_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddService("_s0._tcp.example.com", 3600, 1, 2, 8080, "www.example.com"); + builder.Additionals.AddAddress("www.example.com", 3600, address); + return Task.CompletedTask; + }); + + ServiceResult[] results = await Resolver.ResolveServiceAsync("_s0._tcp.example.com"); + + ServiceResult result = Assert.Single(results); + Assert.Equal("www.example.com", result.Target); + Assert.Equal(1, result.Priority); + Assert.Equal(2, result.Weight); + Assert.Equal(8080, result.Port); + + AddressResult addressResult = Assert.Single(result.Addresses); + Assert.Equal(address, addressResult.Address); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/RetryTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/RetryTests.cs new file mode 100644 index 00000000000..3d6f3724484 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/RetryTests.cs @@ -0,0 +1,309 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class RetryTests : LoopbackDnsTestBase +{ + public RetryTests(ITestOutputHelper output) : base(output) + { + Options.MaxAttempts = 3; + } + + private Task SetupUdpProcessFunction(LoopbackDnsServer server, Func func) + { + return Task.Run(async () => + { + try + { + while (true) + { + await server.ProcessUdpRequest(func); + } + } + catch (Exception ex) + { + Output.WriteLine($"UDP server stopped with exception: {ex}"); + // Test teardown closed the socket, ignore + } + }); + } + + private Task SetupUdpProcessFunction(Func func) + { + return SetupUdpProcessFunction(DnsServer, func); + } + + [Fact] + public async Task Retry_Simple_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "retry-simple-success.com"; + + int attempt = 0; + + Task t = SetupUdpProcessFunction(builder => + { + attempt++; + if (attempt == Options.MaxAttempts) + { + builder.Answers.AddAddress(hostName, 3600, address); + } + else + { + builder.ResponseCode = QueryResponseCode.ServerFailure; + } + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + public enum PersistentErrorType + { + NotImplemented, + Refused, + MalformedResponse + } + + [Theory] + [InlineData(PersistentErrorType.NotImplemented)] + [InlineData(PersistentErrorType.Refused)] + [InlineData(PersistentErrorType.MalformedResponse)] + public async Task PersistentErrorsResponseCode_FailoverToNextServer(PersistentErrorType type) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "www.persistent.com"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + builder => + { + primaryAttempt++; + switch (type) + { + case PersistentErrorType.NotImplemented: + builder.ResponseCode = QueryResponseCode.NotImplemented; + break; + + case PersistentErrorType.Refused: + builder.ResponseCode = QueryResponseCode.Refused; + break; + + case PersistentErrorType.MalformedResponse: + builder.ResponseCode = (QueryResponseCode)0xFF; + break; + } + return Task.CompletedTask; + }, + builder => + { + secondaryAttempt++; + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + Assert.Equal(1, primaryAttempt); + Assert.Equal(1, secondaryAttempt); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + public enum DefinitveAnswerType + { + NoError, + NoData, + NameError, + } + + [Theory] + [InlineData(DefinitveAnswerType.NoError, false)] + [InlineData(DefinitveAnswerType.NoData, false)] + [InlineData(DefinitveAnswerType.NoData, true)] + [InlineData(DefinitveAnswerType.NameError, false)] + [InlineData(DefinitveAnswerType.NameError, true)] + public async Task DefinitiveAnswers_NoRetryOrFailover(DefinitveAnswerType type, bool additionalData) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "www.retry.com"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + builder => + { + primaryAttempt++; + switch (type) + { + case DefinitveAnswerType.NoError: + builder.ResponseCode = QueryResponseCode.NoError; + builder.Answers.AddAddress(hostName, 3600, address); + break; + + case DefinitveAnswerType.NoData: + builder.ResponseCode = QueryResponseCode.NoError; + break; + + case DefinitveAnswerType.NameError: + builder.ResponseCode = QueryResponseCode.NameError; + break; + } + + if (additionalData) + { + builder.Authorities.AddStartOfAuthority(hostName, 300, "ns1.example.com", "hostmaster.example.com", 2023101001, 1, 3600, 300, 86400); + } + + return Task.CompletedTask; + }, + builder => + { + secondaryAttempt++; + builder.ResponseCode = QueryResponseCode.Refused; + return Task.CompletedTask; + }); + + Assert.Equal(1, primaryAttempt); + Assert.Equal(0, secondaryAttempt); + + if (type == DefinitveAnswerType.NoError) + { + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + else + { + Assert.Empty(results); + } + } + + [Fact] + public async Task ExhaustedRetries_FailoverToNextServer() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "ExhaustedRetriesFailoverToNextServer"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + builder => + { + primaryAttempt++; + builder.ResponseCode = QueryResponseCode.ServerFailure; + return Task.CompletedTask; + }, + builder => + { + secondaryAttempt++; + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + Assert.Equal(Options.MaxAttempts, primaryAttempt); + Assert.Equal(1, secondaryAttempt); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + public enum TransientErrorType + { + Timeout, + ServerFailure, + // TODO: simulate NetworkErrors + } + + [Theory] + [InlineData(TransientErrorType.Timeout)] + [InlineData(TransientErrorType.ServerFailure)] + public async Task TransientError_RetryOnSameServer(TransientErrorType type) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "www.transient.com"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + async builder => + { + primaryAttempt++; + if (primaryAttempt == 1) + { + switch (type) + { + case TransientErrorType.Timeout: + await Task.Delay(Options.Timeout.Multiply(1.5)); + builder.Answers.AddAddress(hostName, 3600, address); + break; + + case TransientErrorType.ServerFailure: + builder.ResponseCode = QueryResponseCode.ServerFailure; + break; + } + } + else + { + builder.Answers.AddAddress(hostName, 3600, address); + } + }, + builder => + { + secondaryAttempt++; + builder.ResponseCode = QueryResponseCode.Refused; + return Task.CompletedTask; + }); + + Assert.Equal(2, primaryAttempt); + Assert.Equal(0, secondaryAttempt); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + private async Task RunWithFallbackServerHelper(string name, Func primaryHandler, Func fallbackHandler) + { + Task t = SetupUdpProcessFunction(primaryHandler); + using LoopbackDnsServer fallbackServer = new LoopbackDnsServer(); + Task t2 = SetupUdpProcessFunction(fallbackServer, fallbackHandler); + + Options.Servers = [DnsServer.DnsEndPoint, fallbackServer.DnsEndPoint]; + + return await Resolver.ResolveIPAddressesAsync(name, AddressFamily.InterNetwork); + } + + [Fact] + public async Task NameError_NoRetry() + { + int counter = 0; + Task t = SetupUdpProcessFunction(builder => + { + counter++; + // authoritative answer that the name does not exist + builder.ResponseCode = QueryResponseCode.NameError; + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync("nameerror-noretry", AddressFamily.InterNetwork); + + Assert.Empty(results); + Assert.Equal(1, counter); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/TcpFailoverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/TcpFailoverTests.cs new file mode 100644 index 00000000000..b2891cfb512 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/TcpFailoverTests.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class TcpFailoverTests : LoopbackDnsTestBase +{ + public TcpFailoverTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task TcpFailover_Simple_Success() + { + string hostName = "tcp-simple.test"; + IPAddress address = IPAddress.Parse("172.213.245.111"); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + _ = DnsServer.ProcessTcpRequest(builder => + { + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task TcpFailover_ServerClosesWithoutData_EmptyResult() + { + string hostName = "tcp-server-closes.test"; + Options.MaxAttempts = 1; + Options.Timeout = TimeSpan.FromSeconds(60); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + Task serverTask = DnsServer.ProcessTcpRequest(builder => + { + throw new InvalidOperationException("This forces closing the socket without writing any data"); + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork).AsTask().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Empty(results); + + await Assert.ThrowsAsync(() => serverTask); + } + + [Fact] + public async Task TcpFailover_TcpNotAvailable_EmptyResult() + { + string hostName = "tcp-not-available.test"; + Options.MaxAttempts = 1; + Options.Timeout = TimeSpan.FromMilliseconds(100000); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(results); + } + + [Fact] + public async Task TcpFailover_HeaderMismatch_ReturnsEmpty() + { + string hostName = "tcp-header-mismatch.test"; + Options.Timeout = TimeSpan.FromSeconds(1); + IPAddress address = IPAddress.Parse("172.213.245.111"); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + _ = DnsServer.ProcessTcpRequest(builder => + { + builder.TransactionId++; + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] result = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(result); + } + + [Theory] + [InlineData("not-example.com", (int)QueryType.A, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.AAAA, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.A, 0)] + public async Task TcpFailover_QuestionMismatch_ReturnsEmpty(string name, int type, int @class) + { + string hostName = "tcp-question-mismatch.test"; + Options.Timeout = TimeSpan.FromSeconds(1); + IPAddress address = IPAddress.Parse("172.213.245.111"); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + _ = DnsServer.ProcessTcpRequest(builder => + { + builder.Questions[0] = (name, (QueryType)type, (QueryClass)@class); + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] result = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/ServiceDiscoveryDnsServiceCollectionExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/ServiceDiscoveryDnsServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000000..3631c2e8085 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/ServiceDiscoveryDnsServiceCollectionExtensionsTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +public class ServiceDiscoveryDnsServiceCollectionExtensionsTests +{ + [Fact] + public void AddDnsServiceEndpointProviderShouldRegisterDependentServices() + { + var services = new ServiceCollection(); + services.AddDnsServiceEndpointProvider(); + + using var serviceProvider = services.BuildServiceProvider(true); + + var exception = Record.Exception(() => serviceProvider.GetServices()); + Assert.Null(exception); + } + + [Fact] + public void AddDnsSrvServiceEndpointProviderShouldRegisterDependentServices() + { + var services = new ServiceCollection(); + services.AddDnsSrvServiceEndpointProvider(); + + using var serviceProvider = services.BuildServiceProvider(true); + + var exception = Record.Exception(() => serviceProvider.GetServices()); + Assert.Null(exception); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenServersIsNull() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.Servers = null!); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("Servers must not be null.", exception.Message); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenMaxAttemptsIsZero() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.MaxAttempts = 0); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("MaxAttempts must be one or greater.", exception.Message); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenTimeoutIsZero() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.Timeout = TimeSpan.Zero); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("Timeout must not be negative or zero.", exception.Message); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenTimeoutExceedsMaximum() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.Timeout = TimeSpan.FromMilliseconds(1L + int.MaxValue)); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("Timeout must not be greater than 2147483647 milliseconds.", exception.Message); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/XunitLoggerFactoryExtensions.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/XunitLoggerFactoryExtensions.cs new file mode 100644 index 00000000000..6667688f16e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/XunitLoggerFactoryExtensions.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +internal static class XunitLoggerFactoryExtensions +{ + public static ILoggingBuilder AddXunit(this ILoggingBuilder builder, ITestOutputHelper output) + { + builder.Services.AddSingleton(new XunitLoggerProvider(output)); + return builder; + } + + public static IServiceCollection AddXunitLogging(this IServiceCollection services, ITestOutputHelper output) => + services.AddLogging(b => b.AddXunit(output)); +} + +internal class XunitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _output; + private readonly LogLevel _minLevel; + private readonly DateTimeOffset? _logStart; + + public XunitLoggerProvider(ITestOutputHelper output) + : this(output, LogLevel.Trace) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel) + : this(output, minLevel, null) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel, DateTimeOffset? logStart) + { + _output = output; + _minLevel = minLevel; + _logStart = logStart; + } + + public ILogger CreateLogger(string categoryName) + { + return new XunitLogger(_output, categoryName, _minLevel, _logStart); + } + + public void Dispose() + { + } +} + +internal class XunitLogger : ILogger +{ + private static readonly string[] s_newLineChars = new[] { Environment.NewLine }; + private readonly string _category; + private readonly LogLevel _minLogLevel; + private readonly ITestOutputHelper _output; + private readonly DateTimeOffset? _logStart; + + public XunitLogger(ITestOutputHelper output, string category, LogLevel minLogLevel, DateTimeOffset? logStart) + { + _minLogLevel = minLogLevel; + _category = category; + _output = output; + _logStart = logStart; + } + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + // Buffer the message into a single string in order to avoid shearing the message when running across multiple threads. + var messageBuilder = new StringBuilder(); + + var timestamp = _logStart.HasValue ? + $"{(DateTimeOffset.UtcNow - _logStart.Value).TotalSeconds.ToString("N3", CultureInfo.InvariantCulture)}s" : + DateTimeOffset.UtcNow.ToString("s", CultureInfo.InvariantCulture); + + var firstLinePrefix = $"| [{timestamp}] {_category} {logLevel}: "; + var lines = formatter(state, exception).Split(s_newLineChars, StringSplitOptions.RemoveEmptyEntries); + messageBuilder.AppendLine(firstLinePrefix + lines.FirstOrDefault() ?? string.Empty); + + var additionalLinePrefix = "|" + new string(' ', firstLinePrefix.Length - 1); + foreach (var line in lines.Skip(1)) + { + messageBuilder.AppendLine(additionalLinePrefix + line); + } + + if (exception != null) + { + lines = exception.ToString().Split(s_newLineChars, StringSplitOptions.RemoveEmptyEntries); + additionalLinePrefix = "| "; + foreach (var line in lines) + { + messageBuilder.AppendLine(additionalLinePrefix + line); + } + } + + // Remove the last line-break, because ITestOutputHelper only has WriteLine. + var message = messageBuilder.ToString(); + if (message.EndsWith(Environment.NewLine, StringComparison.Ordinal)) + { + message = message.Substring(0, message.Length - Environment.NewLine.Length); + } + + try + { + _output.WriteLine(message); + } + catch (Exception) + { + // We could fail because we're on a background thread and our captured ITestOutputHelper is + // busted (if the test "completed" before the background thread fired). + // So, ignore this. There isn't really anything we can do but hope the + // caller has additional loggers registered + } + } + + public bool IsEnabled(LogLevel logLevel) + => logLevel >= _minLogLevel; + + public IDisposable BeginScope(TState state) where TState : notnull + => new NullScope(); + + private sealed class NullScope : IDisposable + { + public void Dispose() + { + } + } +} + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ConfigurationServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ConfigurationServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..9fc8832fa68 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ConfigurationServiceEndpointResolverTests.cs @@ -0,0 +1,430 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.Configuration; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests for . +/// These also cover and by extension. +/// +public class ConfigurationServiceEndpointResolverTests +{ + [Fact] + public async Task ResolveServiceEndpoint_Configuration_SingleResult_NoScheme() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:http"] = "localhost:8080", + }); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new DnsEndPoint("localhost", 8080), ep.EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_DisallowedScheme() + { + // Try to resolve an http endpoint when only https is allowed. + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:foo:0"] = "http://localhost:8080", + ["services:basket:foo:1"] = "https://localhost", + }); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .Configure(o => + { + o.AllowAllSchemes = false; + o.AllowedSchemes = ["https"]; + }) + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + + // Explicitly specifying http. + // We should get no endpoint back because http is not allowed by configuration. + await using ((watcher = watcherFactory.CreateWatcher("http://_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Empty(initialResult.EndpointSource.Endpoints); + } + + // Specifying no scheme. + // We should get the HTTPS endpoint back, since it is explicitly allowed + await using ((watcher = watcherFactory.CreateWatcher("_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost")), ep.EndPoint); + } + + // Specifying either https or http. + // We should only get the https endpoint back. + await using ((watcher = watcherFactory.CreateWatcher("https+http://_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost")), ep.EndPoint); + } + + // Specifying either https or http, but in reverse. + // We should only get the https endpoint back. + await using ((watcher = watcherFactory.CreateWatcher("http+https://_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost")), ep.EndPoint); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_DefaultEndpointName() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "https://localhost:8080", + ["services:basket:otlp:0"] = "https://localhost:8888", + }); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider(o => + { + o.ShouldApplyHostNameMetadata = _ => true; + }) + .Configure(o => + { + o.AllowAllSchemes = false; + o.AllowedSchemes = ["https"]; + }) + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + + // Explicitly specifying https as the scheme, but the endpoint section in configuration is the default value ("default"). + // We should get the endpoint back because it is an https endpoint (allowed) with the default endpoint name. + await using ((watcher = watcherFactory.CreateWatcher("https://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + + // Not specifying the scheme or endpoint name. + // We should get the endpoint back because it is an https endpoint (allowed) with the default endpoint name. + await using ((watcher = watcherFactory.CreateWatcher("basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + + // Not specifying the scheme, but specifying the default endpoint name. + // We should get the endpoint back because it is an https endpoint (allowed) with the default endpoint name. + await using ((watcher = watcherFactory.CreateWatcher("_default.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + } + + /// + /// Checks that when there is no named endpoint, configuration resolves first from the "default" section, then sections named by the scheme names. + /// + [Theory] + [InlineData(true, true, "https://basket", "https://default-host:8080")] + [InlineData(false, true, "https://basket","https://https-host:8080")] + [InlineData(true, false, "https://basket", "https://default-host:8080")] + [InlineData(true, true, "basket", "https://default-host:8080")] + [InlineData(false, true, "basket", null)] + [InlineData(true, false, "basket", "https://default-host:8080")] + [InlineData(true, true, "http+https://basket", "https://default-host:8080")] + [InlineData(false, true, "http+https://basket","https://https-host:8080")] + [InlineData(true, false, "http+https://basket", "https://default-host:8080")] + public async Task ResolveServiceEndpoint_Configuration_DefaultEndpointName_ResolutionOrder( + bool includeDefault, + bool includeSchemeNamed, + string serviceName, + string? expectedResult) + { + var data = new Dictionary(); + if (includeDefault) + { + data["services:basket:default:0"] = "https://default-host:8080"; + } + + if (includeSchemeNamed) + { + data["services:basket:https:0"] = "https://https-host:8080"; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection(data); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + + // Scheme in query + await using ((watcher = watcherFactory.CreateWatcher(serviceName)).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + if (expectedResult is not null) + { + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri(expectedResult)), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + else + { + Assert.Empty(initialResult.EndpointSource.Endpoints); + } + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_MultipleResults() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + ["services:basket:default:1"] = "http://remotehost:9090", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider(options => options.ShouldApplyHostNameMetadata = _ => true) + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(2, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new UriEndPoint(new Uri("http://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new UriEndPoint(new Uri("http://remotehost:9090")), initialResult.EndpointSource.Endpoints[1].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + + // Request either https or http. Since there are only http endpoints, we should get only http endpoints back. + await using ((watcher = watcherFactory.CreateWatcher("https+http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(2, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new UriEndPoint(new Uri("http://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new UriEndPoint(new Uri("http://remotehost:9090")), initialResult.EndpointSource.Endpoints[1].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_MultipleProtocols() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:http:0"] = "http://localhost:8080", + ["services:basket:https:1"] = "https://remotehost:9090", + ["services:basket:grpc:0"] = "localhost:2222", + ["services:basket:grpc:1"] = "127.0.0.1:3333", + ["services:basket:grpc:2"] = "http://remotehost:4444", + ["services:basket:grpc:3"] = "https://remotehost:5555", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://_grpc.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new DnsEndPoint("localhost", 2222), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.Loopback, 3333), initialResult.EndpointSource.Endpoints[1].EndPoint); + Assert.Equal(new UriEndPoint(new Uri("http://remotehost:4444")), initialResult.EndpointSource.Endpoints[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_MultipleProtocols_WithSpecificationByConsumer() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + ["services:basket:default:1"] = "remotehost:9090", + ["services:basket:grpc:0"] = "localhost:2222", + ["services:basket:grpc:1"] = "127.0.0.1:3333", + ["services:basket:grpc:2"] = "http://remotehost:4444", + ["services:basket:grpc:3"] = "https://remotehost:5555", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("https+http://_grpc.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + + // These must be treated as HTTPS by the HttpClient middleware, but that is not the responsibility of the resolver. + Assert.Equal(new DnsEndPoint("localhost", 2222), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.Loopback, 3333), initialResult.EndpointSource.Endpoints[1].EndPoint); + + // We expect the HTTPS endpoint back but not the HTTP one. + Assert.Equal(new UriEndPoint(new Uri("https://remotehost:5555")), initialResult.EndpointSource.Endpoints[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ExtensionsServicePublicApiTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ExtensionsServicePublicApiTests.cs new file mode 100644 index 00000000000..31781cf6722 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ExtensionsServicePublicApiTests.cs @@ -0,0 +1,218 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +#pragma warning disable IDE0200 + +public class ExtensionsServicePublicApiTests +{ + [Fact] + public void AddServiceDiscoveryShouldThrowWhenHttpClientBuilderIsNull() + { + IHttpClientBuilder httpClientBuilder = null!; + + var action = () => httpClientBuilder.AddServiceDiscovery(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(httpClientBuilder), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddServiceDiscovery(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddServiceDiscovery(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddServiceDiscovery(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryCoreShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddServiceDiscoveryCore(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryCoreWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddServiceDiscoveryCore(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryCoreWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddServiceDiscoveryCore(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddConfigurationServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddConfigurationServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddConfigurationServiceEndpointProviderWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddConfigurationServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddConfigurationServiceEndpointProviderWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddConfigurationServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddPassThroughServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddPassThroughServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public async Task GetEndpointsAsyncShouldThrowWhenServiceNameIsNull() + { + var serviceEndpointWatcherFactory = new ServiceEndpointWatcherFactory( + new List(), + new Logger(new NullLoggerFactory()), + Options.Options.Create(new ServiceDiscoveryOptions()), + TimeProvider.System); + + var serviceEndpointResolver = new ServiceEndpointResolver(serviceEndpointWatcherFactory, TimeProvider.System); + string serviceName = null!; + + var action = async () => await serviceEndpointResolver.GetEndpointsAsync(serviceName, CancellationToken.None); + + var exception = await Assert.ThrowsAsync(action); + Assert.Equal(nameof(serviceName), exception.ParamName); + } + + [Fact] + public void CreateShouldThrowWhenEndPointIsNull() + { + EndPoint endPoint = null!; + + var action = () => ServiceEndpoint.Create(endPoint); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(endPoint), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TryParseShouldThrowWhenEndPointIsNullOrEmpty(bool isNull) + { + var input = isNull ? null! : string.Empty; + + var action = () => + { + _ = ServiceEndpointQuery.TryParse(input, out _); + }; + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(input), exception.ParamName); + } + + [Fact] + public void CtorServiceEndpointSourceShouldThrowWhenChangeTokenIsNull() + { + IChangeToken changeToken = null!; + var features = new FeatureCollection(); + List? endpoints = null; + + var action = () => new ServiceEndpointSource(endpoints, changeToken, features); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(changeToken), exception.ParamName); + } + + [Fact] + public void CtorServiceEndpointSourceShouldThrowWhenFeaturesIsNull() + { + var changeToken = NullChangeToken.Singleton; + IFeatureCollection features = null!; + List? endpoints = null; + + var action = () => new ServiceEndpointSource(endpoints, changeToken, features); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(features), exception.ParamName); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/Microsoft.Extensions.ServiceDiscovery.Tests.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/Microsoft.Extensions.ServiceDiscovery.Tests.csproj new file mode 100644 index 00000000000..a589eccc256 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/Microsoft.Extensions.ServiceDiscovery.Tests.csproj @@ -0,0 +1,26 @@ + + + + enable + enable + Open + + $(NoWarn);IDE0004;IDE0040;IDE0055;IDE1006;CA2000;S1121;S1128;SA1316;SA1500;SA1513 + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/PassThroughServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/PassThroughServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..f8cc2f282e1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/PassThroughServiceEndpointResolverTests.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Microsoft.Extensions.ServiceDiscovery.PassThrough; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests for . +/// These also cover and by extension. +/// +public class PassThroughServiceEndpointResolverTests +{ + [Fact] + public async Task ResolveServiceEndpoint_PassThrough() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .AddPassThroughServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new DnsEndPoint("basket", 80), ep.EndPoint); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Superseded() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:http:0"] = "http://localhost:8080", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscovery() // Adds the configuration and pass-through providers. + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + + // We expect the basket service to be resolved from Configuration, not the pass-through provider. + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("http://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Fallback() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscovery() // Adds the configuration and pass-through providers. + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://catalog")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + + // We expect the CATALOG service to be resolved from the pass-through provider. + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new DnsEndPoint("catalog", 80), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + } + + // Ensures that pass-through resolution succeeds in scenarios where no scheme is specified during resolution. + [Fact] + public async Task ResolveServiceEndpoint_Fallback_NoScheme() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscovery() // Adds the configuration and pass-through providers. + .BuildServiceProvider(); + + var resolver = services.GetRequiredService(); + var result = await resolver.GetEndpointsAsync("catalog", default); + Assert.Equal(new DnsEndPoint("catalog", 0), result.Endpoints[0].EndPoint); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..c91f07c9300 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointResolverTests.cs @@ -0,0 +1,292 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using Microsoft.Extensions.ServiceDiscovery.Http; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests for and . +/// +public class ServiceEndpointResolverTests +{ + [Fact] + public void ResolveServiceEndpoint_NoProvidersConfigured_Throws() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolverFactory = services.GetRequiredService(); + var exception = Assert.Throws(() => resolverFactory.CreateWatcher("https://basket")); + Assert.Equal("No provider which supports the provided service name, 'https://basket', has been configured.", exception.Message); + } + + [Fact] + public async Task ServiceEndpointResolver_NoProvidersConfigured_Throws() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var watcher = new ServiceEndpointWatcher([], NullLogger.Instance, "foo", TimeProvider.System, Options.Options.Create(new ServiceDiscoveryOptions())); + var exception = Assert.Throws(watcher.Start); + Assert.Equal("No service endpoint providers are configured.", exception.Message); + exception = await Assert.ThrowsAsync(async () => await watcher.GetEndpointsAsync()); + Assert.Equal("No service endpoint providers are configured.", exception.Message); + } + + [Fact] + public void ResolveServiceEndpoint_NullServiceName_Throws() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolverFactory = services.GetRequiredService(); + Assert.Throws(() => resolverFactory.CreateWatcher(null!)); + } + + [Fact] + public async Task AddServiceDiscovery_NoProviders_Throws() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddHttpClient("foo", c => c.BaseAddress = new("http://foo")).AddServiceDiscovery(); + var services = serviceCollection.BuildServiceProvider(); + var client = services.GetRequiredService().CreateClient("foo"); + var exception = await Assert.ThrowsAsync(async () => await client.GetStringAsync("/")); + Assert.Equal("No provider which supports the provided service name, 'http://foo', has been configured.", exception.Message); + } + + private sealed class FakeEndpointResolverProvider(Func createResolverDelegate) : IServiceEndpointProviderFactory + { +#pragma warning disable CS0436 // Type conflicts with imported type + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? resolver) +#pragma warning restore CS0436 // Type conflicts with imported type + { + bool result; + (result, resolver) = createResolverDelegate(query); + return result; + } + } + + private sealed class FakeEndpointResolver(Func resolveAsync, Func disposeAsync) : IServiceEndpointProvider + { + public ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) => resolveAsync(endpoints, cancellationToken); + public ValueTask DisposeAsync() => disposeAsync(); + } + + [Fact] + public async Task ResolveServiceEndpoint() + { + var cts = new[] { new CancellationTokenSource() }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: (collection, ct) => + { + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + + if (cts[0].Token.IsCancellationRequested) + { + cts[0] = new(); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888))); + } + return default; + }, + disposeAsync: () => default); + var resolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(resolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var initialResult = await watcher.GetEndpointsAsync(CancellationToken.None); + Assert.NotNull(initialResult); + var sep = Assert.Single(initialResult.Endpoints); + var ip = Assert.IsType(sep.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + Assert.False(tcs.Task.IsCompleted); + + cts[0].Cancel(); + var resolverResult = await tcs.Task; + Assert.NotNull(resolverResult); + Assert.True(resolverResult.ResolvedSuccessfully); + Assert.Equal(2, resolverResult.EndpointSource.Endpoints.Count); + var endpoints = resolverResult.EndpointSource.Endpoints.Select(ep => ep.EndPoint).OfType().ToList(); + endpoints.Sort((l, r) => l.Port - r.Port); + Assert.Equal(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080), endpoints[0]); + Assert.Equal(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888), endpoints[1]); + } + } + + [Fact] + public async Task ResolveServiceEndpointOneShot() + { + var cts = new[] { new CancellationTokenSource() }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: (collection, ct) => + { + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + + if (cts[0].Token.IsCancellationRequested) + { + cts[0] = new(); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888))); + } + return default; + }, + disposeAsync: () => default); + var resolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(resolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolver = services.GetRequiredService(); + + Assert.NotNull(resolver); + var initialResult = await resolver.GetEndpointsAsync("http://basket", CancellationToken.None); + Assert.NotNull(initialResult); + var sep = Assert.Single(initialResult.Endpoints); + var ip = Assert.IsType(sep.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + + await services.DisposeAsync(); + } + + [Fact] + public async Task ResolveHttpServiceEndpointOneShot() + { + var cts = new[] { new CancellationTokenSource() }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: (collection, ct) => + { + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + + if (cts[0].Token.IsCancellationRequested) + { + cts[0] = new(); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888))); + } + return default; + }, + disposeAsync: () => default); + var fakeResolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(fakeResolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolverProvider = services.GetRequiredService(); + await using var resolver = new HttpServiceEndpointResolver(resolverProvider, services, TimeProvider.System); + + Assert.NotNull(resolver); + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "http://basket"); + var endpoint = await resolver.GetEndpointAsync(httpRequest, CancellationToken.None); + Assert.NotNull(endpoint); + var ip = Assert.IsType(endpoint.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + + await services.DisposeAsync(); + } + + [Fact] + public async Task ResolveServiceEndpoint_ThrowOnReload() + { + var sem = new SemaphoreSlim(0); + var cts = new[] { new CancellationTokenSource() }; + var throwOnNextResolve = new[] { false }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: async (collection, ct) => + { + await sem.WaitAsync(ct).ConfigureAwait(false); + if (cts[0].IsCancellationRequested) + { + // Always be sure to have a fresh token. + cts[0] = new(); + } + + if (throwOnNextResolve[0]) + { + throwOnNextResolve[0] = false; + throw new InvalidOperationException("throwing"); + } + + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + }, + disposeAsync: () => default); + var resolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(resolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var initialEndpointsTask = watcher.GetEndpointsAsync(CancellationToken.None); + sem.Release(1); + var initialEndpoints = await initialEndpointsTask; + Assert.NotNull(initialEndpoints); + Assert.Single(initialEndpoints.Endpoints); + + // Tell the resolver to throw on the next resolve call and then trigger a reload. + throwOnNextResolve[0] = true; + cts[0].Cancel(); + + var exception = await Assert.ThrowsAsync(async () => + { + var resolveTask = watcher.GetEndpointsAsync(CancellationToken.None); + sem.Release(1); + await resolveTask.ConfigureAwait(false); + }); + + Assert.Equal("throwing", exception.Message); + + var channel = Channel.CreateUnbounded(); + watcher.OnEndpointsUpdated = result => channel.Writer.TryWrite(result); + + do + { + cts[0].Cancel(); + sem.Release(1); + var resolveTask = watcher.GetEndpointsAsync(CancellationToken.None); + await resolveTask; + var next = await channel.Reader.ReadAsync(CancellationToken.None); + if (next.ResolvedSuccessfully) + { + break; + } + } while (true); + + var task = watcher.GetEndpointsAsync(CancellationToken.None); + sem.Release(1); + var result = await task; + Assert.NotSame(initialEndpoints, result); + var sep = Assert.Single(result.Endpoints); + var ip = Assert.IsType(sep.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointTests.cs new file mode 100644 index 00000000000..2943074c2b3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointTests.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +public class ServiceEndpointTests +{ + public static TheoryData ZeroPortEndPoints => new() + { + (EndPoint)IPEndPoint.Parse("127.0.0.1:0"), + (EndPoint)new DnsEndPoint("microsoft.com", 0), + (EndPoint)new UriEndPoint(new Uri("https://microsoft.com")) + }; + + public static TheoryData NonZeroPortEndPoints => new() + { + (EndPoint)IPEndPoint.Parse("127.0.0.1:8443"), + (EndPoint)new DnsEndPoint("microsoft.com", 8443), + (EndPoint)new UriEndPoint(new Uri("https://microsoft.com:8443")) + }; + + [Theory] + [MemberData(nameof(ZeroPortEndPoints))] + public void ServiceEndpointToStringOmitsUnspecifiedPort(EndPoint endpoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endpoint); + var epString = serviceEndpoint.ToString(); + Assert.DoesNotContain(":0", epString); + } + + [Theory] + [MemberData(nameof(NonZeroPortEndPoints))] + public void ServiceEndpointToStringContainsSpecifiedPort(EndPoint endpoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endpoint); + var epString = serviceEndpoint.ToString(); + Assert.Contains(":8443", epString); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests.csproj new file mode 100644 index 00000000000..714fe71f0ee --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests.csproj @@ -0,0 +1,24 @@ + + + + $(TestNetCoreTargetFrameworks) + enable + enable + Open + + $(NoWarn);CA2000;S103;S1144;S3459;S4136;SA1208;SA1210;VSTHRD003 + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryPublicApiTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryPublicApiTests.cs new file mode 100644 index 00000000000..a3b694c6d70 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryPublicApiTests.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp.Tests; + +#pragma warning disable IDE0200 + +public class YarpServiceDiscoveryPublicApiTests +{ + [Fact] + public void AddServiceDiscoveryDestinationResolverShouldThrowWhenBuilderIsNull() + { + IReverseProxyBuilder builder = null!; + + var action = () => builder.AddServiceDiscoveryDestinationResolver(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddHttpForwarderWithServiceDiscoveryShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddHttpForwarderWithServiceDiscovery(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryForwarderFactoryShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddServiceDiscoveryForwarderFactory(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryTests.cs new file mode 100644 index 00000000000..d38814621c4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryTests.cs @@ -0,0 +1,323 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; +using Yarp.ReverseProxy.Configuration; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp.Tests; + +/// +/// Tests for YARP with Service Discovery enabled. +/// +public class YarpServiceDiscoveryTests +{ + private static ServiceDiscoveryDestinationResolver CreateResolver(IServiceProvider serviceProvider) + { + var coreResolver = serviceProvider.GetRequiredService(); + return new ServiceDiscoveryDestinationResolver( + coreResolver, + serviceProvider.GetRequiredService>()); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_PassThrough() + { + await using var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .AddPassThroughServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://my-svc", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Single(result.Destinations); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("https://my-svc/", a)); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "ftp://localhost:2121", + ["services:basket:default:1"] = "https://localhost:8888", + ["services:basket:default:2"] = "http://localhost:1111", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https+http://basket", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Single(result.Destinations); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("https://localhost:8888/", a)); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration_NonPreferredScheme() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "ftp://localhost:2121", + ["services:basket:default:1"] = "http://localhost:1111", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https+http://basket", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Single(result.Destinations); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("http://localhost:1111/", a)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration_Host_Value(bool configHasHost) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "https://localhost:1111", + ["services:basket:default:1"] = "https://127.0.0.1:2222", + ["services:basket:default:2"] = "https://[::1]:3333", + ["services:basket:default:3"] = "https://baskets-galore.faketld", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://basket", + Host = configHasHost ? "my-basket-svc.faketld" : null + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Equal(4, result.Destinations.Count); + Assert.Collection(result.Destinations.Values, + a => + { + Assert.Equal("https://localhost:1111/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }, + a => + { + Assert.Equal("https://127.0.0.1:2222/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }, + a => + { + Assert.Equal("https://[::1]:3333/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }, + a => + { + Assert.Equal("https://baskets-galore.faketld/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration_DisallowedScheme() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "ftp://localhost:2121", + ["services:basket:default:1"] = "http://localhost:1111", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .Configure(o => + { + // Allow only "https://" + o.AllowAllSchemes = false; + o.AllowedSchemes = ["https"]; + }) + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https+http://basket", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + // No results: there are no 'https' endpoints in config and 'http' is disallowed. + Assert.Empty(result.Destinations); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Dns() + { + DnsResolver resolver = new DnsResolver(TimeProvider.System, NullLogger.Instance, new OptionsWrapper(new DnsResolverOptions())); + + await using var services = new ServiceCollection() + .AddSingleton(resolver) + .AddServiceDiscoveryCore() + .AddDnsServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://microsoft.com", + }, + ["dest-b"] = new() + { + Address = "http://msn.com", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + Assert.NotNull(result); + Assert.NotEmpty(result.Destinations); + Assert.All(result.Destinations, d => + { + var address = d.Value.Address; + Assert.True(Uri.TryCreate(address, default, out var uri), $"Failed to parse address '{address}' as URI."); + Assert.True(uri.IsDefaultPort, "URI should use the default port when resolved via DNS."); + var expectedScheme = d.Key.StartsWith("dest-a") ? "https" : "http"; + Assert.Equal(expectedScheme, uri.Scheme); + }); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_DnsSrv() + { + var dnsClientMock = new FakeDnsResolver + { + ResolveServiceAsyncFunc = (name, cancellationToken) => + { + ServiceResult[] response = [ + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 66, 8888, "srv-a", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Parse("10.10.10.10"))]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 9999, "srv-b", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.IPv6Loopback)]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 7777, "srv-c", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Loopback)]) + ]; + + return ValueTask.FromResult(response); + } + }; + + await using var services = new ServiceCollection() + .AddSingleton(dnsClientMock) + .AddServiceDiscoveryCore() + .AddDnsSrvServiceEndpointProvider(options => options.QuerySuffix = ".ns") + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://my-svc", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Equal(3, result.Destinations.Count); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("https://10.10.10.10:8888/", a), + a => Assert.Equal("https://[::1]:9999/", a), + a => Assert.Equal("https://127.0.0.1:7777/", a)); + } + + private sealed class FakeDnsResolver : IDnsResolver + { + public Func>? ResolveIPAddressesAsyncFunc { get; set; } + public ValueTask ResolveIPAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc!.Invoke(name, addressFamily, cancellationToken); + + public Func>? ResolveIPAddressesAsyncFunc2 { get; set; } + + public ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc2!.Invoke(name, cancellationToken); + + public Func>? ResolveServiceAsyncFunc { get; set; } + + public ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) => ResolveServiceAsyncFunc!.Invoke(name, cancellationToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/GlobalBufferLoggerBuilderExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/GlobalBufferLoggerBuilderExtensionsTests.cs index a267c708f50..7a1899f8eeb 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/GlobalBufferLoggerBuilderExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/GlobalBufferLoggerBuilderExtensionsTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Test; using Microsoft.Extensions.Options; using Xunit; @@ -68,5 +69,79 @@ public void WithConfiguration_RegistersInDI() Assert.Equal(TimeSpan.FromSeconds(30), options.CurrentValue.AutoFlushDuration); // value comes from default Assert.Equivalent(expectedData, options.CurrentValue.Rules); } + + [Fact] + public void WhenConfigUpdated_PicksUpConfigChanges() + { + List initialData = + [ + new(categoryName: "Program.MyLogger", logLevel: LogLevel.Information, eventId: 1, eventName: "number one"), + new(logLevel : LogLevel.Information), + ]; + List updatedData = + [ + new(logLevel: LogLevel.Information), + ]; + string jsonConfig = + @" +{ + ""GlobalLogBuffering"": { + ""Rules"": [ + { + ""CategoryName"": ""Program.MyLogger"", + ""LogLevel"": ""Information"", + ""EventId"": 1, + ""EventName"": ""number one"", + }, + { + ""LogLevel"": ""Information"", + }, + ] + } +} +"; + + using ConfigurationRoot config = TestConfiguration.Create(() => jsonConfig); + + using ExtendedLoggerTests.Provider provider = new ExtendedLoggerTests.Provider(); + using ILoggerFactory factory = Utils.CreateLoggerFactory( + builder => + { + builder.AddProvider(provider); + builder.AddGlobalBuffer(config); + }); + ILogger logger = factory.CreateLogger("Program.MyLogger"); + Utils.DisposingLoggerFactory dlf = (Utils.DisposingLoggerFactory)factory; + var bufferManager = dlf.ServiceProvider.GetRequiredService() as GlobalLogBufferManager; + + IOptionsMonitor? options = dlf.ServiceProvider.GetService>(); + Assert.NotNull(options); + Assert.NotNull(options.CurrentValue); + Assert.Equivalent(initialData, options.CurrentValue.Rules); + + // this is just to trigger creating an internal buffer: + logger.LogInformation(new EventId(1, "number one"), null); + + jsonConfig = +@" +{ + ""GlobalLogBuffering"": { + ""Rules"": [ + { + ""LogLevel"": ""Information"", + }, + ] + } +} +"; + config.Reload(); + + Assert.NotNull(bufferManager); + Assert.NotEmpty(bufferManager.Buffers); + foreach (GlobalBuffer buffer in bufferManager.Buffers.Values) + { + Assert.Equivalent(updatedData, buffer.LastKnownGoodFilterRules, strict: true); + } + } } #endif diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs index 222d2aacc9a..a3b6bad6340 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs @@ -7,6 +7,7 @@ using Xunit; namespace Microsoft.Extensions.Diagnostics.Buffering.Test; + public class LogBufferingFilterRuleTests { private readonly LogBufferingFilterRuleSelector _selector = new(); diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs index 6d4ed5908f8..58d2d50d9ba 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs @@ -5,6 +5,7 @@ using Moq; namespace Microsoft.Extensions.Diagnostics.Latency.Test; + internal static class MockLatencyContextRegistrationOptions { public static IOptions GetLatencyContextRegistrationOptions( diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs index 3d02c9ae578..f35dc88560a 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs @@ -8,6 +8,7 @@ using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Moq; using Xunit; @@ -536,6 +537,20 @@ public static void CreateDisposeDisposesInnerServiceProvider() Assert.True(disposed); } + [Fact] + public static void NullLoggerByProviderIsIgnored() + { + using var factory = Utils.CreateLoggerFactory(builder => + { + builder.AddProvider(NullLoggerProvider.Instance); + builder.AddProvider(new Provider()); + }); + var logger1 = (ExtendedLogger)factory.CreateLogger("C1"); + Assert.Single(logger1.MessageLoggers); + + logger1.LogInformation("This should not throw an exception."); + } + private class InternalScopeLoggerProvider : ILoggerProvider, ILogger { private IExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider(); diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerTests.cs index b31aabb0083..f91cbcabce7 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerTests.cs @@ -1114,7 +1114,7 @@ private enum ThrowExceptionAt IsEnabled } - private sealed class Provider : ILoggerProvider + internal sealed class Provider : ILoggerProvider { public FakeLogger? Logger { get; private set; } diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs index d0db83d943a..18d45449388 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs @@ -178,7 +178,7 @@ public async Task Change_WhenCalledAfterDisposeAsync_ReturnsFalse() Assert.False(t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1))); } - [Fact] + [Fact(Skip = "Flaky, https://github.com/dotnet/extensions/issues/6567")] public void CreateTimer_WhenDisposed_RemovesWaiterFromQueue() { var timer1Counter = 0; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 1b8c4177f40..f5d2bc52e3a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.TestUtilities; using Xunit; using Xunit.Abstractions; @@ -71,6 +72,44 @@ public async Task CreateRestoreAndBuild_AspireTemplate(params string[] args) await Fixture.BuildProjectAsync(project); } + /// + /// Runs a single test with --aspire true and a project name that will trigger the class + /// name normalization bug reported in https://github.com/dotnet/extensions/issues/6811. + /// + [Fact] + public async Task CreateRestoreAndBuild_AspireProjectName() + { + await CreateRestoreAndBuild_AspireProjectName_Variants("azureopenai", "mix.ed-dash_name 123"); + } + + /// + /// Tests build for various project name formats, including dots and other + /// separators, to trigger the class name normalization bug described + /// in https://github.com/dotnet/extensions/issues/6811 + /// This runs for all provider combinations with --aspire true and different + /// project names to ensure the bug is caught in all scenarios. + /// + /// + /// Because this test takes a long time to run, it is skipped by default. Set the + /// environment variable AI_TEMPLATES_TEST_PROJECT_NAMES to "true" or "1" + /// to enable it. + /// + [ConditionalTheory] + [EnvironmentVariableCondition("AI_TEMPLATES_TEST_PROJECT_NAMES", "true", "1")] + [MemberData(nameof(GetAspireProjectNameVariants))] + public async Task CreateRestoreAndBuild_AspireProjectName_Variants(string provider, string projectName) + { + var project = await Fixture.CreateProjectAsync( + templateName: "aichatweb", + projectName: projectName, + args: new[] { "--aspire", $"--provider={provider}" }); + + project.StartupProjectRelativePath = $"{projectName}.AppHost"; + + await Fixture.RestoreProjectAsync(project); + await Fixture.BuildProjectAsync(project); + } + private static readonly (string name, string[] values)[] _templateOptions = [ ("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]), ("--vector-store", ["azureaisearch", "local", "qdrant"]), @@ -158,4 +197,26 @@ private static IEnumerable GetAllPossibleOptions(ReadOnlyMemory<(strin } } } + + public static IEnumerable GetAspireProjectNameVariants() + { + foreach (string provider in new[] { "ollama", "openai", "azureopenai", "githubmodels" }) + { + foreach (string projectName in new[] + { + "mix.ed-dash_name 123", + "dot.name", + "project.123", + "space name", + ".1My.Projec-", + "1Project123", + "11double", + "1", + "nomatch" + }) + { + yield return new object[] { provider, projectName }; + } + } + } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index 1e4cf0415f4..4bf84ab4bd3 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -1,12 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.AI.Templates.Tests; using Microsoft.Extensions.Logging; using Microsoft.TemplateEngine.Authoring.TemplateVerifier; using Microsoft.TemplateEngine.TestHelper; @@ -31,6 +29,7 @@ public class AIChatWebSnapshotTests "**/ingestioncache.*", "**/NuGet.config", "**/Directory.Build.targets", + "**/Directory.Build.props", ]; private readonly ILogger _log; @@ -49,9 +48,9 @@ public async Task BasicTest() } [Fact] - public async Task BasicAspireTest() + public async Task Ollama_Qdrant() { - await TestTemplateCoreAsync(scenarioName: "BasicAspire", templateArgs: ["--aspire"]); + await TestTemplateCoreAsync(scenarioName: "Ollama_Qdrant", templateArgs: ["--provider", "ollama", "--vector-store", "qdrant"]); } [Fact] @@ -60,6 +59,18 @@ public async Task OpenAI_AzureAISearch() await TestTemplateCoreAsync(scenarioName: "OpenAI_AzureAISearch", templateArgs: ["--provider", "openai", "--vector-store", "azureaisearch"]); } + [Fact] + public async Task BasicAspireTest() + { + await TestTemplateCoreAsync(scenarioName: "BasicAspire", templateArgs: ["--aspire"]); + } + + [Fact] + public async Task AzureOpenAI_AzureAISearch_Aspire() + { + await TestTemplateCoreAsync(scenarioName: "AzureOpenAI_Qdrant_Aspire", templateArgs: ["--provider", "azureopenai", "--vector-store", "azureaisearch", "--aspire"]); + } + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) { string workingDir = TestUtils.CreateTemporaryFolder(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs index 38ced5b1867..317e81a661f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs @@ -8,30 +8,31 @@ namespace Microsoft.Extensions.AI.Templates.Tests; public sealed class Project(string rootPath, string name) { - private string? _startupProjectRelativePath; - private string? _startupProjectFullPath; - public string RootPath => rootPath; public string Name => name; public string? StartupProjectRelativePath { - get => _startupProjectRelativePath; + get; set { if (value is null) { - _startupProjectRelativePath = null; - _startupProjectFullPath = null; + field = null; + StartupProjectFullPath = null!; } - else if (!string.Equals(value, _startupProjectRelativePath, StringComparison.Ordinal)) + else if (!string.Equals(value, field, StringComparison.Ordinal)) { - _startupProjectRelativePath = value; - _startupProjectFullPath = Path.Combine(rootPath, _startupProjectRelativePath); + field = value; + StartupProjectFullPath = Path.Combine(rootPath, field); } } } - public string StartupProjectFullPath => _startupProjectFullPath ?? rootPath; + public string StartupProjectFullPath + { + get => field ?? rootPath; + private set; + } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs index 09d09d50a1c..4b5e2dd2a28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs @@ -7,12 +7,9 @@ namespace Microsoft.Extensions.AI.Templates.Tests; public sealed class TestCommandResult(StringBuilder standardOutputBuilder, StringBuilder standardErrorBuilder, int exitCode) { - private string? _standardOutput; - private string? _standardError; + public string StandardOutput => field ??= standardOutputBuilder.ToString(); - public string StandardOutput => _standardOutput ??= standardOutputBuilder.ToString(); - - public string StandardError => _standardError ??= standardErrorBuilder.ToString(); + public string StandardError => field ??= standardErrorBuilder.ToString(); public int ExitCode => exitCode; } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs new file mode 100644 index 00000000000..5fa1723af83 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.TemplateEngine.Authoring.TemplateVerifier; +using Microsoft.TemplateEngine.TestHelper; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.AI.Templates.Tests; + +public class McpServerSnapshotTests +{ + // Keep the exclude patterns below in sync with those in Microsoft.Extensions.AI.Templates.csproj. + private static readonly string[] _verificationExcludePatterns = [ + "**/bin/**", + "**/obj/**", + "**/.vs/**", + "**/*.sln", + "**/*.in", + ]; + + private readonly ILogger _log; + + public McpServerSnapshotTests(ITestOutputHelper log) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + _log = new XunitLoggerProvider(log).CreateLogger("TestRun"); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + + [Fact] + public async Task BasicTest() + { + await TestTemplateCoreAsync(scenarioName: "Basic"); + } + + [Fact] + public async Task SelfContainedFalse() + { + await TestTemplateCoreAsync(scenarioName: "SelfContainedFalse", templateArgs: ["--self-contained", bool.FalseString]); + } + + [Fact] + public async Task AotTrue() + { + await TestTemplateCoreAsync(scenarioName: "AotTrue", templateArgs: ["--aot", bool.TrueString]); + } + + [Fact] + public async Task Net10() + { + await TestTemplateCoreAsync(scenarioName: "net10", templateArgs: ["--framework", "net10.0"]); + } + + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) + { + string workingDir = TestUtils.CreateTemporaryFolder(); + string templateShortName = "mcpserver"; + + // Get the template location + string templateLocation = Path.Combine(WellKnownPaths.TemplateFeedLocation, "Microsoft.Extensions.AI.Templates", "src", "McpServer"); + + var verificationExcludePatterns = Path.DirectorySeparatorChar is '/' + ? _verificationExcludePatterns + : _verificationExcludePatterns.Select(p => p.Replace('/', Path.DirectorySeparatorChar)).ToArray(); + + TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: templateShortName) + { + TemplatePath = templateLocation, + TemplateSpecificArgs = templateArgs, + SnapshotsDirectory = "Snapshots", + OutputDirectory = workingDir, + DoNotPrependCallerMethodNameToScenarioName = true, + DoNotAppendTemplateArgsToScenarioName = true, + ScenarioName = scenarioName, + VerificationExcludePatterns = verificationExcludePatterns, + } + .WithCustomScrubbers( + ScrubbersDefinition.Empty.AddScrubber((path, content) => + { + string filePath = path.UnixifyDirSeparators(); + + if (filePath.EndsWith(".csproj")) + { + // Scrub references to just-built packages and remove the suffix, if it exists. + // This allows the snapshots to remain the same regardless of where the repo is built (e.g., locally, public CI, internal CI). + var pattern = @"(?<=)"; + content.ScrubByRegex(pattern, replacement: "$1"); + } + })); + + VerificationEngine engine = new VerificationEngine(_log); + await engine.Execute(options); + +#pragma warning disable CA1031 // Do not catch general exception types + try + { + Directory.Delete(workingDir, recursive: true); + } + catch + { + /* don't care */ + } +#pragma warning restore CA1031 // Do not catch general exception types + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj index d2fc26ea0ab..e4c52714b79 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/README.md index ca589c22c2c..5717aac1e1c 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/README.md @@ -2,4 +2,6 @@ Contains snapshot and execution tests for `Microsoft.Extensions.AI.Templates`. +To update test snapshots, install and run the `DiffEngineTray` tool following [these instructions](https://github.com/VerifyTests/DiffEngine/blob/main/docs/tray.md), run the snapshot tests either in VS or using `dotnet test`, and use `DiffEngineTray` to accept or discard changes. + For information on debugging template execution tests, see [this README](./TemplateSandbox/README.md). diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md new file mode 100644 index 00000000000..7bebc5d7595 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md @@ -0,0 +1,53 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + +>[!NOTE] +> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. + +### Prerequisites +To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). + +### Known Issues + +#### Errors running Ollama or Docker + +A recent incompatibility was found between Ollama and Docker Desktop. This issue results in runtime errors when connecting to Ollama, and the workaround for that can lead to Docker not working for Aspire projects. + +This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See [ollama/ollama#9509](https://github.com/ollama/ollama/issues/9509#issuecomment-2842461831) for more information and a link to install the version of Docker Desktop with the fix. + +# Configure the AI Model Provider + +## Using Azure Provisioning + +The project is set up to automatically provision Azure resources. When running the app for the first time, you will be prompted to provide Azure configuration values. For detailed instructions, see the [Local Provisioning documentation](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). + + +# Running the application + +## Using Visual Studio + +1. Open the `.sln` file in Visual Studio. +2. Press `Ctrl+F5` or click the "Start" button in the toolbar to run the project. + +## Using Visual Studio Code + +1. Open the project folder in Visual Studio Code. +2. Install the [C# Dev Kit extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) for Visual Studio Code. +3. Once installed, Open the `Program.cs` file in the aichatweb.AppHost project. +4. Run the project by clicking the "Run" button in the Debug view. + +## Trust the localhost certificate + +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. + +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. + +# Updating JavaScript dependencies + +This template leverages JavaScript libraries to provide essential functionality. These libraries are located in the wwwroot/lib folder of the aichatweb.Web project. For instructions on updating each dependency, please refer to the README.md file in each respective folder. + +# Learn More +To learn more about development with .NET and AI, check out the following links: + +* [AI for .NET Developers](https://learn.microsoft.com/dotnet/ai/) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs new file mode 100644 index 00000000000..da0220a0b1c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -0,0 +1,29 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var openai = builder.AddAzureOpenAI("openai"); + +openai.AddDeployment( + name: "gpt-4o-mini", + modelName: "gpt-4o-mini", + modelVersion: "2024-07-18"); + +openai.AddDeployment( + name: "text-embedding-3-small", + modelName: "text-embedding-3-small", + modelVersion: "1"); + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var search = builder.AddAzureSearch("search"); + +var webApp = builder.AddProject("aichatweb-app"); +webApp + .WithReference(openai) + .WaitFor(openai); +webApp + .WithReference(search) + .WaitFor(search); + +builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..681e3bf0d26 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj new file mode 100644 index 00000000000..d2d0f2890a6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net9.0 + enable + enable + secret + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..b0bacf42851 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/appsettings.json new file mode 100644 index 00000000000..bfad98588cd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000000..b44d60b604b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("Experimental.Microsoft.Extensions.AI"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddSource("Experimental.Microsoft.Extensions.AI"); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj new file mode 100644 index 00000000000..474dd445fae --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/App.razor new file mode 100644 index 00000000000..262359d5f5a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor new file mode 100644 index 00000000000..77557f20173 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor @@ -0,0 +1,13 @@ +
+ + +
+ How well is this template working for you? Please take a + brief survey + and tell us what you think. +
+
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css new file mode 100644 index 00000000000..c939b902afb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css @@ -0,0 +1,20 @@ +.surveyContainer { + display: flex; + justify-content: center; + gap: 0.5rem; + font-size: 0.9em; + margin: 0.5rem auto -0.7rem auto; + max-width: 1024px; + color: #444; +} + + .surveyContainer a { + text-decoration: underline; + } + + .surveyContainer .tool-icon { + margin-top: 0.15rem; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..8aa0ec9fd28 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -0,0 +1,122 @@ +@page "/" +@using System.ComponentModel +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search +@implements IDisposable + +Chat + + + + + +
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
+ + +
+
+ +
+ + + @* Remove this line to eliminate the template survey message *@ +
+ +@code { + private const string SystemPrompt = @" + You are an assistant who answers questions about information you retrieve. + Do not answer questions about anything else. + Use only simple markdown to format your responses. + + Use the search tool to find relevant information. When you do this, end your + reply with citations in the special XML format: + + exact quote here + + Always include the citation in your response if there are results. + + The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. + Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + [Description("Searches for information using a phrase or keyword")] + private async Task> SearchAsync( + [Description("The phrase to search for.")] string searchPhrase, + [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) + { + await InvokeAsync(StateHasChanged); + var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); + return results.Select(result => + $"{result.Text}"); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 00000000000..ccb5853cec4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,38 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public int? PageNumber { get; set; } + + [Parameter] + public required string Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..12b1d524e23 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

aichatweb.Web

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..92c20c70667 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,94 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+ + + @foreach (var citation in citations ?? []) + { + + } +
+
+ } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + + private List<(string File, int? Page, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var matches = CitationRegex.Matches(text); + citations = matches.Any() + ? matches.Select(m => (m.Groups["file"].Value, int.TryParse(m.Groups["page"].Value, out var page) ? page : (int?)null, m.Groups["quote"].Value)).ToList() + : null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,120 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +::deep pre > code { + background-color: white; + display: block; + padding: 0.5rem 1rem; + margin: 1rem 0; + overflow-x: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Routes.razor new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/_Imports.razor new file mode 100644 index 00000000000..fa7cadef6ea --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.AI +@using Microsoft.JSInterop +@using aichatweb.Web +@using aichatweb.Web.Components +@using aichatweb.Web.Components.Layout +@using aichatweb.Web.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs new file mode 100644 index 00000000000..450914c4461 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.AI; +using aichatweb.Web.Components; +using aichatweb.Web.Services; +using aichatweb.Web.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +var openai = builder.AddAzureOpenAIClient("openai"); +openai.AddChatClient("gpt-4o-mini") + .UseFunctionInvocation() + .UseOpenTelemetry(configure: c => + c.EnableSensitiveData = builder.Environment.IsDevelopment()); +openai.AddEmbeddingGenerator("text-embedding-3-small"); + +builder.AddAzureSearchClient("search"); +builder.Services.AddAzureAISearchCollection("data-aichatweb-chunks"); +builder.Services.AddAzureAISearchCollection("data-aichatweb-documents"); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.UseStaticFiles(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +// By default, we ingest PDF files from the /wwwroot/Data directory. You can ingest from +// other sources by implementing IIngestionSource. +// Important: ensure that any content you ingest is trusted, as it may be reflected back +// to users or could be a source of prompt injection risk. +await DataIngestor.IngestDataAsync( + app.Services, + new PDFDirectorySource(Path.Combine(builder.Environment.WebRootPath, "Data"))); + +app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json new file mode 100644 index 00000000000..e2d900a219d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs new file mode 100644 index 00000000000..0fd76874dfd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedChunk +{ + private const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string DocumentId { get; set; } + + [VectorStoreData] + public int PageNumber { get; set; } + + [VectorStoreData] + public required string Text { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs new file mode 100644 index 00000000000..370aef16fd9 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedDocument +{ + private const int VectorDimensions = 2; + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string SourceId { get; set; } + + [VectorStoreData] + public required string DocumentId { get; set; } + + [VectorStoreData] + public required string DocumentVersion { get; set; } + + // The vector is not used but required for some vector databases + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public ReadOnlyMemory Vector { get; set; } = new ReadOnlyMemory([0, 0]); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs new file mode 100644 index 00000000000..2fe43370071 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services.Ingestion; + +public class DataIngestor( + ILogger logger, + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) +{ + public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source) + { + using var scope = services.CreateScope(); + var ingestor = scope.ServiceProvider.GetRequiredService(); + await ingestor.IngestDataAsync(source); + } + + public async Task IngestDataAsync(IIngestionSource source) + { + await chunksCollection.EnsureCollectionExistsAsync(); + await documentsCollection.EnsureCollectionExistsAsync(); + + var sourceId = source.SourceId; + var documentsForSource = await documentsCollection.GetAsync(doc => doc.SourceId == sourceId, top: int.MaxValue).ToListAsync(); + + var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); + foreach (var deletedDocument in deletedDocuments) + { + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); + await DeleteChunksForDocumentAsync(deletedDocument); + await documentsCollection.DeleteAsync(deletedDocument.Key); + } + + var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); + foreach (var modifiedDocument in modifiedDocuments) + { + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); + await DeleteChunksForDocumentAsync(modifiedDocument); + + await documentsCollection.UpsertAsync(modifiedDocument); + + var newRecords = await source.CreateChunksForDocumentAsync(modifiedDocument); + await chunksCollection.UpsertAsync(newRecords); + } + + logger.LogInformation("Ingestion is up-to-date"); + + async Task DeleteChunksForDocumentAsync(IngestedDocument document) + { + var documentId = document.DocumentId; + var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); + if (chunksToDelete.Count != 0) + { + await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs new file mode 100644 index 00000000000..a1c6b2191d1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs @@ -0,0 +1,12 @@ +namespace aichatweb.Web.Services.Ingestion; + +public interface IIngestionSource +{ + string SourceId { get; } + + Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments); + + Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments); + + Task> CreateChunksForDocumentAsync(IngestedDocument document); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs new file mode 100644 index 00000000000..32e9f225c08 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs @@ -0,0 +1,71 @@ +using Microsoft.SemanticKernel.Text; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; + +namespace aichatweb.Web.Services.Ingestion; + +public class PDFDirectorySource(string sourceDirectory) : IIngestionSource +{ + public static string SourceFileId(string path) => Path.GetFileName(path); + public static string SourceFileVersion(string path) => File.GetLastWriteTimeUtc(path).ToString("o"); + + public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}"; + + public Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments) + { + var results = new List(); + var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var existingDocumentsById = existingDocuments.ToDictionary(d => d.DocumentId); + + foreach (var sourceFile in sourceFiles) + { + var sourceFileId = SourceFileId(sourceFile); + var sourceFileVersion = SourceFileVersion(sourceFile); + var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; + if (existingDocumentVersion != sourceFileVersion) + { + results.Add(new() { Key = Guid.CreateVersion7().ToString(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); + } + } + + return Task.FromResult((IEnumerable)results); + } + + public Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments) + { + var currentFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var currentFileIds = currentFiles.ToLookup(SourceFileId); + var deletedDocuments = existingDocuments.Where(d => !currentFileIds.Contains(d.DocumentId)); + return Task.FromResult(deletedDocuments); + } + + public Task> CreateChunksForDocumentAsync(IngestedDocument document) + { + using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, document.DocumentId)); + var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList(); + + return Task.FromResult(paragraphs.Select(p => new IngestedChunk + { + Key = Guid.CreateVersion7().ToString(), + DocumentId = document.DocumentId, + PageNumber = p.PageNumber, + Text = p.Text, + })); + } + + private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage) + { + var letters = pdfPage.Letters; + var words = NearestNeighbourWordExtractor.Instance.GetWords(letters); + var textBlocks = DocstrumBoundingBoxes.Instance.GetBlocks(words); + var pageText = string.Join(Environment.NewLine + Environment.NewLine, + textBlocks.Select(t => t.Text.ReplaceLineEndings(" "))); + +#pragma warning disable SKEXP0050 // Type is for evaluation purposes only + return TextChunker.SplitPlainTextParagraphs([pageText], 200) + .Select((text, index) => (pdfPage.Number, index, text)); +#pragma warning restore SKEXP0050 // Type is for evaluation purposes only + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs new file mode 100644 index 00000000000..84fb719f6ae --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class SemanticSearch( + VectorStoreCollection vectorCollection) +{ + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions + { + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, + }); + + return await nearest.Select(result => result.Record).ToListAsync(); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj new file mode 100644 index 00000000000..861a3a974c6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + secret + + + + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/appsettings.Development.json new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf new file mode 100644 index 00000000000..94625f0e0e0 Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf differ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf new file mode 100644 index 00000000000..c87df644c58 Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf differ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.css new file mode 100644 index 00000000000..0dec580e2fd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.css @@ -0,0 +1,94 @@ +@import url('lib/tailwindcss/dist/preflight.css'); + +html { + min-height: 100vh; +} + +html, .main-background-gradient { + background: linear-gradient(to bottom, rgb(225 227 233), #f4f4f4 25rem); +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + + html::after { + content: ''; + background-image: linear-gradient(to right, #3a4ed5, #3acfd5 15%, #d53abf 85%, red); + width: 100%; + height: 2px; + position: fixed; + top: 0; + } + +h1 { + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 600; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.btn-default { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #9CA3AF; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + background-color: #D1D5DB; +} + + .btn-default:hover { + background-color: #E5E7EB; + } + +.btn-subtle { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #D1D5DB; + font-size: 0.875rem; + line-height: 1.25rem; +} + + .btn-subtle:hover { + border-color: #93C5FD; + background-color: #DBEAFE; + } + +.page-width { + max-width: 1024px; + margin: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.js new file mode 100644 index 00000000000..8b2cecd007d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/app.js @@ -0,0 +1,24 @@ +import DOMPurify from './lib/dompurify/dist/purify.es.mjs'; +import * as marked from './lib/marked/dist/marked.esm.js'; + +const purify = DOMPurify(window); + +customElements.define('assistant-message', class extends HTMLElement { + static observedAttributes = ['markdown']; + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'markdown') { + newValue = newValue.replace(//gs, ''); + const elements = marked.parse(newValue.replace(/ 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + return apply(func, thisArg, args); + }; +} +/** + * Creates a new function that constructs an instance of the given constructor function with the provided arguments. + * + * @param func - The constructor function to be wrapped and called. + * @returns A new function that constructs an instance of the given constructor function with the provided arguments. + */ +function unconstruct(func) { + return function () { + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + return construct(func, args); + }; +} +/** + * Add properties to a lookup table + * + * @param set - The set to which elements will be added. + * @param array - The array containing elements to be added to the set. + * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. + * @returns The modified set with added elements. + */ +function addToSet(set, array) { + let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; + if (setPrototypeOf) { + // Make 'in' and truthy checks like Boolean(set.constructor) + // independent of any properties defined on Object.prototype. + // Prevent prototype setters from intercepting set as a this value. + setPrototypeOf(set, null); + } + let l = array.length; + while (l--) { + let element = array[l]; + if (typeof element === 'string') { + const lcElement = transformCaseFunc(element); + if (lcElement !== element) { + // Config presets (e.g. tags.js, attrs.js) are immutable. + if (!isFrozen(array)) { + array[l] = lcElement; + } + element = lcElement; + } + } + set[element] = true; + } + return set; +} +/** + * Clean up an array to harden against CSPP + * + * @param array - The array to be cleaned. + * @returns The cleaned version of the array + */ +function cleanArray(array) { + for (let index = 0; index < array.length; index++) { + const isPropertyExist = objectHasOwnProperty(array, index); + if (!isPropertyExist) { + array[index] = null; + } + } + return array; +} +/** + * Shallow clone an object + * + * @param object - The object to be cloned. + * @returns A new object that copies the original. + */ +function clone(object) { + const newObject = create(null); + for (const [property, value] of entries(object)) { + const isPropertyExist = objectHasOwnProperty(object, property); + if (isPropertyExist) { + if (Array.isArray(value)) { + newObject[property] = cleanArray(value); + } else if (value && typeof value === 'object' && value.constructor === Object) { + newObject[property] = clone(value); + } else { + newObject[property] = value; + } + } + } + return newObject; +} +/** + * This method automatically checks if the prop is function or getter and behaves accordingly. + * + * @param object - The object to look up the getter function in its prototype chain. + * @param prop - The property name for which to find the getter function. + * @returns The getter function found in the prototype chain or a fallback function. + */ +function lookupGetter(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) { + return unapply(desc.get); + } + if (typeof desc.value === 'function') { + return unapply(desc.value); + } + } + object = getPrototypeOf(object); + } + function fallbackValue() { + return null; + } + return fallbackValue; +} + +const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); +const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); +const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); +// List of SVG elements that are disallowed by default. +// We still need to know them so that we can do namespace +// checks properly in case one wants to add them to +// allow-list. +const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); +const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); +// Similarly to SVG, we want to know all MathML elements, +// even those that we disallow by default. +const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); +const text = freeze(['#text']); + +const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']); +const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); +const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); +const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + +// eslint-disable-next-line unicorn/better-regex +const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode +const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); +const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex +const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape +const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape +const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape +); +const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); +const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex +); +const DOCTYPE_NAME = seal(/^html$/i); +const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); + +var EXPRESSIONS = /*#__PURE__*/Object.freeze({ + __proto__: null, + ARIA_ATTR: ARIA_ATTR, + ATTR_WHITESPACE: ATTR_WHITESPACE, + CUSTOM_ELEMENT: CUSTOM_ELEMENT, + DATA_ATTR: DATA_ATTR, + DOCTYPE_NAME: DOCTYPE_NAME, + ERB_EXPR: ERB_EXPR, + IS_ALLOWED_URI: IS_ALLOWED_URI, + IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA, + MUSTACHE_EXPR: MUSTACHE_EXPR, + TMPLIT_EXPR: TMPLIT_EXPR +}); + +/* eslint-disable @typescript-eslint/indent */ +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType +const NODE_TYPE = { + element: 1, + attribute: 2, + text: 3, + cdataSection: 4, + entityReference: 5, + // Deprecated + entityNode: 6, + // Deprecated + progressingInstruction: 7, + comment: 8, + document: 9, + documentType: 10, + documentFragment: 11, + notation: 12 // Deprecated +}; +const getGlobal = function getGlobal() { + return typeof window === 'undefined' ? null : window; +}; +/** + * Creates a no-op policy for internal use only. + * Don't export this function outside this module! + * @param trustedTypes The policy factory. + * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). + * @return The policy created (or null, if Trusted Types + * are not supported or creating the policy failed). + */ +const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { + if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { + return null; + } + // Allow the callers to control the unique policy name + // by adding a data-tt-policy-suffix to the script element with the DOMPurify. + // Policy creation with duplicate names throws in Trusted Types. + let suffix = null; + const ATTR_NAME = 'data-tt-policy-suffix'; + if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { + suffix = purifyHostElement.getAttribute(ATTR_NAME); + } + const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); + try { + return trustedTypes.createPolicy(policyName, { + createHTML(html) { + return html; + }, + createScriptURL(scriptUrl) { + return scriptUrl; + } + }); + } catch (_) { + // Policy creation failed (most likely another DOMPurify script has + // already run). Skip creating the policy, as this will only cause errors + // if TT are enforced. + console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); + return null; + } +}; +const _createHooksMap = function _createHooksMap() { + return { + afterSanitizeAttributes: [], + afterSanitizeElements: [], + afterSanitizeShadowDOM: [], + beforeSanitizeAttributes: [], + beforeSanitizeElements: [], + beforeSanitizeShadowDOM: [], + uponSanitizeAttribute: [], + uponSanitizeElement: [], + uponSanitizeShadowNode: [] + }; +}; +function createDOMPurify() { + let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); + const DOMPurify = root => createDOMPurify(root); + DOMPurify.version = '3.2.4'; + DOMPurify.removed = []; + if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { + // Not running in a browser, provide a factory function + // so that you can pass your own Window + DOMPurify.isSupported = false; + return DOMPurify; + } + let { + document + } = window; + const originalDocument = document; + const currentScript = originalDocument.currentScript; + const { + DocumentFragment, + HTMLTemplateElement, + Node, + Element, + NodeFilter, + NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap, + HTMLFormElement, + DOMParser, + trustedTypes + } = window; + const ElementPrototype = Element.prototype; + const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); + const remove = lookupGetter(ElementPrototype, 'remove'); + const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); + const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); + const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); + // As per issue #47, the web-components registry is inherited by a + // new document created via createHTMLDocument. As per the spec + // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) + // a new empty registry is used when creating a template contents owner + // document, so we use that as our parent document to ensure nothing + // is inherited. + if (typeof HTMLTemplateElement === 'function') { + const template = document.createElement('template'); + if (template.content && template.content.ownerDocument) { + document = template.content.ownerDocument; + } + } + let trustedTypesPolicy; + let emptyHTML = ''; + const { + implementation, + createNodeIterator, + createDocumentFragment, + getElementsByTagName + } = document; + const { + importNode + } = originalDocument; + let hooks = _createHooksMap(); + /** + * Expose whether this browser supports running the full DOMPurify. + */ + DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; + const { + MUSTACHE_EXPR, + ERB_EXPR, + TMPLIT_EXPR, + DATA_ATTR, + ARIA_ATTR, + IS_SCRIPT_OR_DATA, + ATTR_WHITESPACE, + CUSTOM_ELEMENT + } = EXPRESSIONS; + let { + IS_ALLOWED_URI: IS_ALLOWED_URI$1 + } = EXPRESSIONS; + /** + * We consider the elements and attributes below to be safe. Ideally + * don't add any new ones but feel free to remove unwanted ones. + */ + /* allowed element names */ + let ALLOWED_TAGS = null; + const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); + /* Allowed attribute names */ + let ALLOWED_ATTR = null; + const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); + /* + * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements. + * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements) + * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list) + * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`. + */ + let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { + tagNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + allowCustomizedBuiltInElements: { + writable: true, + configurable: false, + enumerable: true, + value: false + } + })); + /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ + let FORBID_TAGS = null; + /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ + let FORBID_ATTR = null; + /* Decide if ARIA attributes are okay */ + let ALLOW_ARIA_ATTR = true; + /* Decide if custom data attributes are okay */ + let ALLOW_DATA_ATTR = true; + /* Decide if unknown protocols are okay */ + let ALLOW_UNKNOWN_PROTOCOLS = false; + /* Decide if self-closing tags in attributes are allowed. + * Usually removed due to a mXSS issue in jQuery 3.0 */ + let ALLOW_SELF_CLOSE_IN_ATTR = true; + /* Output should be safe for common template engines. + * This means, DOMPurify removes data attributes, mustaches and ERB + */ + let SAFE_FOR_TEMPLATES = false; + /* Output should be safe even for XML used within HTML and alike. + * This means, DOMPurify removes comments when containing risky content. + */ + let SAFE_FOR_XML = true; + /* Decide if document with ... should be returned */ + let WHOLE_DOCUMENT = false; + /* Track whether config is already set on this instance of DOMPurify. */ + let SET_CONFIG = false; + /* Decide if all elements (e.g. style, script) must be children of + * document.body. By default, browsers might move them to document.head */ + let FORCE_BODY = false; + /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported). + * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead + */ + let RETURN_DOM = false; + /* Decide if a DOM `DocumentFragment` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported) */ + let RETURN_DOM_FRAGMENT = false; + /* Try to return a Trusted Type object instead of a string, return a string in + * case Trusted Types are not supported */ + let RETURN_TRUSTED_TYPE = false; + /* Output should be free from DOM clobbering attacks? + * This sanitizes markups named with colliding, clobberable built-in DOM APIs. + */ + let SANITIZE_DOM = true; + /* Achieve full DOM Clobbering protection by isolating the namespace of named + * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. + * + * HTML/DOM spec rules that enable DOM Clobbering: + * - Named Access on Window (§7.3.3) + * - DOM Tree Accessors (§3.1.5) + * - Form Element Parent-Child Relations (§4.10.3) + * - Iframe srcdoc / Nested WindowProxies (§4.8.5) + * - HTMLCollection (§4.2.10.2) + * + * Namespace isolation is implemented by prefixing `id` and `name` attributes + * with a constant string, i.e., `user-content-` + */ + let SANITIZE_NAMED_PROPS = false; + const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; + /* Keep element content when removing element? */ + let KEEP_CONTENT = true; + /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead + * of importing it into a new Document and returning a sanitized copy */ + let IN_PLACE = false; + /* Allow usage of profiles like html, svg and mathMl */ + let USE_PROFILES = {}; + /* Tags to ignore content of when KEEP_CONTENT is true */ + let FORBID_CONTENTS = null; + const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); + /* Tags that are safe for data: URIs */ + let DATA_URI_TAGS = null; + const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); + /* Attributes safe for values like "javascript:" */ + let URI_SAFE_ATTRIBUTES = null; + const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']); + const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; + /* Document namespace */ + let NAMESPACE = HTML_NAMESPACE; + let IS_EMPTY_INPUT = false; + /* Allowed XHTML+XML namespaces */ + let ALLOWED_NAMESPACES = null; + const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString); + let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); + let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']); + // Certain elements are allowed in both SVG and HTML + // namespace. We need to specify them explicitly + // so that they don't get erroneously deleted from + // HTML namespace. + const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']); + /* Parsing of strict XHTML documents */ + let PARSER_MEDIA_TYPE = null; + const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html']; + const DEFAULT_PARSER_MEDIA_TYPE = 'text/html'; + let transformCaseFunc = null; + /* Keep a reference to config to pass to hooks */ + let CONFIG = null; + /* Ideally, do not touch anything below this line */ + /* ______________________________________________ */ + const formElement = document.createElement('form'); + const isRegexOrFunction = function isRegexOrFunction(testValue) { + return testValue instanceof RegExp || testValue instanceof Function; + }; + /** + * _parseConfig + * + * @param cfg optional config literal + */ + // eslint-disable-next-line complexity + const _parseConfig = function _parseConfig() { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + if (CONFIG && CONFIG === cfg) { + return; + } + /* Shield configuration object from tampering */ + if (!cfg || typeof cfg !== 'object') { + cfg = {}; + } + /* Shield configuration object from prototype pollution */ + cfg = clone(cfg); + PARSER_MEDIA_TYPE = + // eslint-disable-next-line unicorn/prefer-includes + SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; + // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is. + transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase; + /* Set configuration parameters */ + ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS; + ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR; + ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES; + URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES; + DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS; + FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS; + FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {}; + FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {}; + USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false; + ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true + ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true + ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false + ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true + SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false + SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true + WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false + RETURN_DOM = cfg.RETURN_DOM || false; // Default false + RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false + RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false + FORCE_BODY = cfg.FORCE_BODY || false; // Default false + SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true + SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false + KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true + IN_PLACE = cfg.IN_PLACE || false; // Default false + IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI; + NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE; + MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS; + HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS; + CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {}; + if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) { + CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck; + } + if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) { + CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck; + } + if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') { + CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements; + } + if (SAFE_FOR_TEMPLATES) { + ALLOW_DATA_ATTR = false; + } + if (RETURN_DOM_FRAGMENT) { + RETURN_DOM = true; + } + /* Parse profile info */ + if (USE_PROFILES) { + ALLOWED_TAGS = addToSet({}, text); + ALLOWED_ATTR = []; + if (USE_PROFILES.html === true) { + addToSet(ALLOWED_TAGS, html$1); + addToSet(ALLOWED_ATTR, html); + } + if (USE_PROFILES.svg === true) { + addToSet(ALLOWED_TAGS, svg$1); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.svgFilters === true) { + addToSet(ALLOWED_TAGS, svgFilters); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.mathMl === true) { + addToSet(ALLOWED_TAGS, mathMl$1); + addToSet(ALLOWED_ATTR, mathMl); + addToSet(ALLOWED_ATTR, xml); + } + } + /* Merge configuration parameters */ + if (cfg.ADD_TAGS) { + if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { + ALLOWED_TAGS = clone(ALLOWED_TAGS); + } + addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); + } + if (cfg.ADD_ATTR) { + if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { + ALLOWED_ATTR = clone(ALLOWED_ATTR); + } + addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); + } + if (cfg.ADD_URI_SAFE_ATTR) { + addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc); + } + if (cfg.FORBID_CONTENTS) { + if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) { + FORBID_CONTENTS = clone(FORBID_CONTENTS); + } + addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc); + } + /* Add #text in case KEEP_CONTENT is set to true */ + if (KEEP_CONTENT) { + ALLOWED_TAGS['#text'] = true; + } + /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ + if (WHOLE_DOCUMENT) { + addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); + } + /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ + if (ALLOWED_TAGS.table) { + addToSet(ALLOWED_TAGS, ['tbody']); + delete FORBID_TAGS.tbody; + } + if (cfg.TRUSTED_TYPES_POLICY) { + if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') { + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.'); + } + if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') { + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.'); + } + // Overwrite existing TrustedTypes policy. + trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; + // Sign local variables required by `sanitize`. + emptyHTML = trustedTypesPolicy.createHTML(''); + } else { + // Uninitialized policy, attempt to initialize the internal dompurify policy. + if (trustedTypesPolicy === undefined) { + trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); + } + // If creating the internal policy succeeded sign internal variables. + if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') { + emptyHTML = trustedTypesPolicy.createHTML(''); + } + } + // Prevent further manipulation of configuration. + // Not available in IE8, Safari 5, etc. + if (freeze) { + freeze(cfg); + } + CONFIG = cfg; + }; + /* Keep track of all possible SVG and MathML tags + * so that we can perform the namespace checks + * correctly. */ + const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]); + const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); + /** + * @param element a DOM element whose namespace is being checked + * @returns Return false if the element has a + * namespace that a spec-compliant parser would never + * return. Return true otherwise. + */ + const _checkValidNamespace = function _checkValidNamespace(element) { + let parent = getParentNode(element); + // In JSDOM, if we're inside shadow DOM, then parentNode + // can be null. We just simulate parent in this case. + if (!parent || !parent.tagName) { + parent = { + namespaceURI: NAMESPACE, + tagName: 'template' + }; + } + const tagName = stringToLowerCase(element.tagName); + const parentTagName = stringToLowerCase(parent.tagName); + if (!ALLOWED_NAMESPACES[element.namespaceURI]) { + return false; + } + if (element.namespaceURI === SVG_NAMESPACE) { + // The only way to switch from HTML namespace to SVG + // is via . If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'svg'; + } + // The only way to switch from MathML to SVG is via` + // svg if parent is either or MathML + // text integration points. + if (parent.namespaceURI === MATHML_NAMESPACE) { + return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); + } + // We only allow elements that are defined in SVG + // spec. All others are disallowed in SVG namespace. + return Boolean(ALL_SVG_TAGS[tagName]); + } + if (element.namespaceURI === MATHML_NAMESPACE) { + // The only way to switch from HTML namespace to MathML + // is via . If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'math'; + } + // The only way to switch from SVG to MathML is via + // and HTML integration points + if (parent.namespaceURI === SVG_NAMESPACE) { + return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; + } + // We only allow elements that are defined in MathML + // spec. All others are disallowed in MathML namespace. + return Boolean(ALL_MATHML_TAGS[tagName]); + } + if (element.namespaceURI === HTML_NAMESPACE) { + // The only way to switch from SVG to HTML is via + // HTML integration points, and from MathML to HTML + // is via MathML text integration points + if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { + return false; + } + if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { + return false; + } + // We disallow tags that are specific for MathML + // or SVG and should never appear in HTML namespace + return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); + } + // For XHTML and XML documents that support custom namespaces + if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) { + return true; + } + // The code should never reach this place (this means + // that the element somehow got namespace that is not + // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES). + // Return false just in case. + return false; + }; + /** + * _forceRemove + * + * @param node a DOM node + */ + const _forceRemove = function _forceRemove(node) { + arrayPush(DOMPurify.removed, { + element: node + }); + try { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + getParentNode(node).removeChild(node); + } catch (_) { + remove(node); + } + }; + /** + * _removeAttribute + * + * @param name an Attribute name + * @param element a DOM node + */ + const _removeAttribute = function _removeAttribute(name, element) { + try { + arrayPush(DOMPurify.removed, { + attribute: element.getAttributeNode(name), + from: element + }); + } catch (_) { + arrayPush(DOMPurify.removed, { + attribute: null, + from: element + }); + } + element.removeAttribute(name); + // We void attribute values for unremovable "is" attributes + if (name === 'is') { + if (RETURN_DOM || RETURN_DOM_FRAGMENT) { + try { + _forceRemove(element); + } catch (_) {} + } else { + try { + element.setAttribute(name, ''); + } catch (_) {} + } + } + }; + /** + * _initDocument + * + * @param dirty - a string of dirty markup + * @return a DOM, filled with the dirty markup + */ + const _initDocument = function _initDocument(dirty) { + /* Create a HTML document */ + let doc = null; + let leadingWhitespace = null; + if (FORCE_BODY) { + dirty = '' + dirty; + } else { + /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ + const matches = stringMatch(dirty, /^[\r\n\t ]+/); + leadingWhitespace = matches && matches[0]; + } + if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) { + // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict) + dirty = '' + dirty + ''; + } + const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; + /* + * Use the DOMParser API by default, fallback later if needs be + * DOMParser not work for svg when has multiple root element. + */ + if (NAMESPACE === HTML_NAMESPACE) { + try { + doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE); + } catch (_) {} + } + /* Use createHTMLDocument in case DOMParser is not available */ + if (!doc || !doc.documentElement) { + doc = implementation.createDocument(NAMESPACE, 'template', null); + try { + doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload; + } catch (_) { + // Syntax error if dirtyPayload is invalid xml + } + } + const body = doc.body || doc.documentElement; + if (dirty && leadingWhitespace) { + body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null); + } + /* Work on whole document or just its body */ + if (NAMESPACE === HTML_NAMESPACE) { + return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; + } + return WHOLE_DOCUMENT ? doc.documentElement : body; + }; + /** + * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document. + * + * @param root The root element or node to start traversing on. + * @return The created NodeIterator + */ + const _createNodeIterator = function _createNodeIterator(root) { + return createNodeIterator.call(root.ownerDocument || root, root, + // eslint-disable-next-line no-bitwise + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null); + }; + /** + * _isClobbered + * + * @param element element to check for clobbering attacks + * @return true if clobbered, false if safe + */ + const _isClobbered = function _isClobbered(element) { + return element instanceof HTMLFormElement && (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function'); + }; + /** + * Checks whether the given object is a DOM node. + * + * @param value object to check whether it's a DOM node + * @return true is object is a DOM node + */ + const _isNode = function _isNode(value) { + return typeof Node === 'function' && value instanceof Node; + }; + function _executeHooks(hooks, currentNode, data) { + arrayForEach(hooks, hook => { + hook.call(DOMPurify, currentNode, data, CONFIG); + }); + } + /** + * _sanitizeElements + * + * @protect nodeName + * @protect textContent + * @protect removeChild + * @param currentNode to check for permission to exist + * @return true if node was killed, false if left alive + */ + const _sanitizeElements = function _sanitizeElements(currentNode) { + let content = null; + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeElements, currentNode, null); + /* Check if element is clobbered or can clobber */ + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + return true; + } + /* Now let's check the element's type and name */ + const tagName = transformCaseFunc(currentNode.nodeName); + /* Execute a hook if present */ + _executeHooks(hooks.uponSanitizeElement, currentNode, { + tagName, + allowedTags: ALLOWED_TAGS + }); + /* Detect mXSS attempts abusing namespace confusion */ + if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { + _forceRemove(currentNode); + return true; + } + /* Remove any occurrence of processing instructions */ + if (currentNode.nodeType === NODE_TYPE.progressingInstruction) { + _forceRemove(currentNode); + return true; + } + /* Remove any kind of possibly harmful comments */ + if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\w]/g, currentNode.data)) { + _forceRemove(currentNode); + return true; + } + /* Remove element if anything forbids its presence */ + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + /* Check if we have a custom element to handle */ + if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) { + if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) { + return false; + } + if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) { + return false; + } + } + /* Keep content except for bad-listed elements */ + if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { + const parentNode = getParentNode(currentNode) || currentNode.parentNode; + const childNodes = getChildNodes(currentNode) || currentNode.childNodes; + if (childNodes && parentNode) { + const childCount = childNodes.length; + for (let i = childCount - 1; i >= 0; --i) { + const childClone = cloneNode(childNodes[i], true); + childClone.__removalCount = (currentNode.__removalCount || 0) + 1; + parentNode.insertBefore(childClone, getNextSibling(currentNode)); + } + } + } + _forceRemove(currentNode); + return true; + } + /* Check whether element has a valid namespace */ + if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { + _forceRemove(currentNode); + return true; + } + /* Make sure that older browsers don't get fallback-tag mXSS */ + if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) { + _forceRemove(currentNode); + return true; + } + /* Sanitize element content to be template-safe */ + if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) { + /* Get the element's text content */ + content = currentNode.textContent; + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + content = stringReplace(content, expr, ' '); + }); + if (currentNode.textContent !== content) { + arrayPush(DOMPurify.removed, { + element: currentNode.cloneNode() + }); + currentNode.textContent = content; + } + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeElements, currentNode, null); + return false; + }; + /** + * _isValidAttribute + * + * @param lcTag Lowercase tag name of containing element. + * @param lcName Lowercase attribute name. + * @param value Attribute value. + * @return Returns true if `value` is valid, otherwise false. + */ + // eslint-disable-next-line complexity + const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { + /* Make sure attribute cannot clobber */ + if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { + return false; + } + /* Allow valid data-* attributes: At least one character after "-" + (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes) + XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804) + We don't need to check the value; it's always URI safe. */ + if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ; else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) { + if ( + // First condition does a very basic check if a) it's basically a valid custom element tagname AND + // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck + // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck + _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || + // Alternative, second condition checks if it's an `is`-attribute, AND + // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck + lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ; else { + return false; + } + /* Check value is safe. First, is attr inert? If so, is safe */ + } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if (value) { + return false; + } else ; + return true; + }; + /** + * _isBasicCustomElement + * checks if at least one dash is included in tagName, and it's not the first char + * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name + * + * @param tagName name of the tag of the node to sanitize + * @returns Returns true if the tag name meets the basic criteria for a custom element, otherwise false. + */ + const _isBasicCustomElement = function _isBasicCustomElement(tagName) { + return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT); + }; + /** + * _sanitizeAttributes + * + * @protect attributes + * @protect nodeName + * @protect removeAttribute + * @protect setAttribute + * + * @param currentNode to sanitize + */ + const _sanitizeAttributes = function _sanitizeAttributes(currentNode) { + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null); + const { + attributes + } = currentNode; + /* Check if we have attributes; if not we might have a text node */ + if (!attributes || _isClobbered(currentNode)) { + return; + } + const hookEvent = { + attrName: '', + attrValue: '', + keepAttr: true, + allowedAttributes: ALLOWED_ATTR, + forceKeepAttr: undefined + }; + let l = attributes.length; + /* Go backwards over all attributes; safely remove bad ones */ + while (l--) { + const attr = attributes[l]; + const { + name, + namespaceURI, + value: attrValue + } = attr; + const lcName = transformCaseFunc(name); + let value = name === 'value' ? attrValue : stringTrim(attrValue); + /* Execute a hook if present */ + hookEvent.attrName = lcName; + hookEvent.attrValue = value; + hookEvent.keepAttr = true; + hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set + _executeHooks(hooks.uponSanitizeAttribute, currentNode, hookEvent); + value = hookEvent.attrValue; + /* Full DOM Clobbering protection via namespace isolation, + * Prefix id and name attributes with `user-content-` + */ + if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) { + // Remove the attribute with this value + _removeAttribute(name, currentNode); + // Prefix the value and later re-create the attribute with the sanitized value + value = SANITIZE_NAMED_PROPS_PREFIX + value; + } + /* Work around a security issue with comments inside attributes */ + if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title)/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + /* Did the hooks approve of the attribute? */ + if (hookEvent.forceKeepAttr) { + continue; + } + /* Remove attribute */ + _removeAttribute(name, currentNode); + /* Did the hooks approve of the attribute? */ + if (!hookEvent.keepAttr) { + continue; + } + /* Work around a security issue in jQuery 3.0 */ + if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + /* Sanitize attribute content to be template-safe */ + if (SAFE_FOR_TEMPLATES) { + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + value = stringReplace(value, expr, ' '); + }); + } + /* Is `value` valid for this attribute? */ + const lcTag = transformCaseFunc(currentNode.nodeName); + if (!_isValidAttribute(lcTag, lcName, value)) { + continue; + } + /* Handle attributes that require Trusted Types */ + if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') { + if (namespaceURI) ; else { + switch (trustedTypes.getAttributeType(lcTag, lcName)) { + case 'TrustedHTML': + { + value = trustedTypesPolicy.createHTML(value); + break; + } + case 'TrustedScriptURL': + { + value = trustedTypesPolicy.createScriptURL(value); + break; + } + } + } + } + /* Handle invalid data-* attribute set by try-catching it */ + try { + if (namespaceURI) { + currentNode.setAttributeNS(namespaceURI, name, value); + } else { + /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ + currentNode.setAttribute(name, value); + } + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + } else { + arrayPop(DOMPurify.removed); + } + } catch (_) {} + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeAttributes, currentNode, null); + }; + /** + * _sanitizeShadowDOM + * + * @param fragment to iterate over recursively + */ + const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { + let shadowNode = null; + const shadowIterator = _createNodeIterator(fragment); + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeShadowDOM, fragment, null); + while (shadowNode = shadowIterator.nextNode()) { + /* Execute a hook if present */ + _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null); + /* Sanitize tags and elements */ + _sanitizeElements(shadowNode); + /* Check attributes next */ + _sanitizeAttributes(shadowNode); + /* Deep shadow DOM detected */ + if (shadowNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(shadowNode.content); + } + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null); + }; + // eslint-disable-next-line complexity + DOMPurify.sanitize = function (dirty) { + let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + let body = null; + let importedNode = null; + let currentNode = null; + let returnNode = null; + /* Make sure we have a string to sanitize. + DO NOT return early, as this will return the wrong type if + the user has requested a DOM object rather than a string */ + IS_EMPTY_INPUT = !dirty; + if (IS_EMPTY_INPUT) { + dirty = ''; + } + /* Stringify, in case dirty is an object */ + if (typeof dirty !== 'string' && !_isNode(dirty)) { + if (typeof dirty.toString === 'function') { + dirty = dirty.toString(); + if (typeof dirty !== 'string') { + throw typeErrorCreate('dirty is not a string, aborting'); + } + } else { + throw typeErrorCreate('toString is not a function'); + } + } + /* Return dirty HTML if DOMPurify cannot run */ + if (!DOMPurify.isSupported) { + return dirty; + } + /* Assign config vars */ + if (!SET_CONFIG) { + _parseConfig(cfg); + } + /* Clean up removed elements */ + DOMPurify.removed = []; + /* Check if dirty is correctly typed for IN_PLACE */ + if (typeof dirty === 'string') { + IN_PLACE = false; + } + if (IN_PLACE) { + /* Do some early pre-sanitization to avoid unsafe root nodes */ + if (dirty.nodeName) { + const tagName = transformCaseFunc(dirty.nodeName); + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place'); + } + } + } else if (dirty instanceof Node) { + /* If dirty is a DOM element, append to an empty document to avoid + elements being stripped by the parser */ + body = _initDocument(''); + importedNode = body.ownerDocument.importNode(dirty, true); + if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === 'BODY') { + /* Node is already a body, use as is */ + body = importedNode; + } else if (importedNode.nodeName === 'HTML') { + body = importedNode; + } else { + // eslint-disable-next-line unicorn/prefer-dom-node-append + body.appendChild(importedNode); + } + } else { + /* Exit directly if we have nothing to do */ + if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && + // eslint-disable-next-line unicorn/prefer-includes + dirty.indexOf('<') === -1) { + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; + } + /* Initialize the document to work on */ + body = _initDocument(dirty); + /* Check we have a DOM node from the data */ + if (!body) { + return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : ''; + } + } + /* Remove first element node (ours) if FORCE_BODY is set */ + if (body && FORCE_BODY) { + _forceRemove(body.firstChild); + } + /* Get node iterator */ + const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body); + /* Now start iterating over the created document */ + while (currentNode = nodeIterator.nextNode()) { + /* Sanitize tags and elements */ + _sanitizeElements(currentNode); + /* Check attributes next */ + _sanitizeAttributes(currentNode); + /* Shadow DOM detected, sanitize it */ + if (currentNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(currentNode.content); + } + } + /* If we sanitized `dirty` in-place, return it. */ + if (IN_PLACE) { + return dirty; + } + /* Return sanitized string or DOM */ + if (RETURN_DOM) { + if (RETURN_DOM_FRAGMENT) { + returnNode = createDocumentFragment.call(body.ownerDocument); + while (body.firstChild) { + // eslint-disable-next-line unicorn/prefer-dom-node-append + returnNode.appendChild(body.firstChild); + } + } else { + returnNode = body; + } + if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) { + /* + AdoptNode() is not used because internal state is not reset + (e.g. the past names map of a HTMLFormElement), this is safe + in theory but we would rather not risk another attack vector. + The state that is cloned by importNode() is explicitly defined + by the specs. + */ + returnNode = importNode.call(originalDocument, returnNode, true); + } + return returnNode; + } + let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; + /* Serialize doctype if allowed */ + if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) { + serializedHTML = '\n' + serializedHTML; + } + /* Sanitize final string template-safe */ + if (SAFE_FOR_TEMPLATES) { + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + serializedHTML = stringReplace(serializedHTML, expr, ' '); + }); + } + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; + }; + DOMPurify.setConfig = function () { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + _parseConfig(cfg); + SET_CONFIG = true; + }; + DOMPurify.clearConfig = function () { + CONFIG = null; + SET_CONFIG = false; + }; + DOMPurify.isValidAttribute = function (tag, attr, value) { + /* Initialize shared config vars if necessary. */ + if (!CONFIG) { + _parseConfig({}); + } + const lcTag = transformCaseFunc(tag); + const lcName = transformCaseFunc(attr); + return _isValidAttribute(lcTag, lcName, value); + }; + DOMPurify.addHook = function (entryPoint, hookFunction) { + if (typeof hookFunction !== 'function') { + return; + } + arrayPush(hooks[entryPoint], hookFunction); + }; + DOMPurify.removeHook = function (entryPoint, hookFunction) { + if (hookFunction !== undefined) { + const index = arrayLastIndexOf(hooks[entryPoint], hookFunction); + return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0]; + } + return arrayPop(hooks[entryPoint]); + }; + DOMPurify.removeHooks = function (entryPoint) { + hooks[entryPoint] = []; + }; + DOMPurify.removeAllHooks = function () { + hooks = _createHooksMap(); + }; + return DOMPurify; +} +var purify = createDOMPurify(); + +export { purify as default }; +//# sourceMappingURL=purify.es.mjs.map diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/README.md new file mode 100644 index 00000000000..352b52d5503 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/README.md @@ -0,0 +1,5 @@ +marked version 15.0.6 +https://github.com/markedjs/marked +License: MIT + +To update, replace the `dist/marked.esm.js` file with with an updated version from https://www.npmjs.com/package/marked. diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/dist/marked.esm.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/dist/marked.esm.js new file mode 100644 index 00000000000..a32cd778363 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/dist/marked.esm.js @@ -0,0 +1,2568 @@ +/** + * marked v15.0.6 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null, + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +const noopTest = { exec: () => null }; +function edit(regex, opt = '') { + let source = typeof regex === 'string' ? regex : regex.source; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(other.caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + }, + }; + return obj; +} +const other = { + codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, + outputLinkReplace: /\\([\[\]])/g, + indentCodeCompensation: /^(\s+)(?:```)/, + beginningSpace: /^\s+/, + endingHash: /#$/, + startingSpaceChar: /^ /, + endingSpaceChar: / $/, + nonSpaceChar: /[^ ]/, + newLineCharGlobal: /\n/g, + tabCharGlobal: /\t/g, + multipleSpaceGlobal: /\s+/g, + blankLine: /^[ \t]*$/, + doubleBlankLine: /\n[ \t]*\n[ \t]*$/, + blockquoteStart: /^ {0,3}>/, + blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, + blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, + listReplaceTabs: /^\t+/, + listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, + listIsTask: /^\[[ xX]\] /, + listReplaceTask: /^\[[ xX]\] +/, + anyLine: /\n.*\n/, + hrefBrackets: /^<(.*)>$/, + tableDelimiter: /[:|]/, + tableAlignChars: /^\||\| *$/g, + tableRowBlankLine: /\n[ \t]*$/, + tableAlignRight: /^ *-+: *$/, + tableAlignCenter: /^ *:-+: *$/, + tableAlignLeft: /^ *:-+ *$/, + startATag: /^/i, + startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, + endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, + startAngleBracket: /^$/, + pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, + unicodeAlphaNumeric: /[\p{L}\p{N}]/u, + escapeTest: /[&<>"']/, + escapeReplace: /[&<>"']/g, + escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, + unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, + caret: /(^|[^\[])\^/g, + percentDecode: /%25/g, + findPipe: /\|/g, + splitPipe: / \|/, + slashPipe: /\\\|/g, + carriageReturn: /\r\n|\r/g, + spaceLine: /^ +$/gm, + notSpaceStart: /^\S*/, + endingNewline: /\n$/, + listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), + nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`), + hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), + fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), + headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), + htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, 'i'), +}; +/** + * Block-Level Grammar + */ +const newline = /^(?:[ \t]*(?:\n|$))+/; +const blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheading = edit(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' + + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' + + '|tr|track|ul'; +const _comment = /|$))/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText, +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', '(?: {4}| {0,3}\t)[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(), +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .replace(/tag/g, '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') + .getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex(), +}; +/** + * Inline-Level Grammar + */ +const escape$1 = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ +const blockSkip = /\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; +const emStrongLDelim = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongLDelimGfm = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +const emStrongRDelimAstCore = '^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)punct(\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=punct)' // (4) ***# can only be Left Delimiter + + '|(?!\\*)punct(\\*+)(?!\\*)(?=punct)' // (5) #***# can be either Left or Right Delimiter + + '|notPunctSpace(\\*+)(?=notPunctSpace)'; // (6) a***a can be either Left or Right Delimiter +const emStrongRDelimAst = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm) + .replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm) + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)punct(_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|notPunctSpace(_+)(?!_)(?=punctSpace|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)punctSpace(_+)(?=notPunctSpace)' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=punct)' // (4) ___# can only be Left Delimiter + + '|(?!_)punct(_+)(?!_)(?=punct)', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\(punct)/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) + .getRegex(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape: escape$1, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest, +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex(), +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + emStrongRDelimAst: emStrongRDelimAstGfm, + emStrongLDelim: emStrongLDelimGfm, + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') + .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) + .getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\': '>', + '"': '"', + "'": ''', +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape(html, encode) { + if (encode) { + if (other.escapeTest.test(html)) { + return html.replace(other.escapeReplace, getEscapeReplacement); + } + } + else { + if (other.escapeTestNoEncode.test(html)) { + return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(other.percentDecode, '%'); + } + catch { + return null; + } + return href; +} +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(other.findPipe, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(other.splitPipe); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells.at(-1)?.trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(other.slashPipe, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && true) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer, rules) { + const href = link.href; + const title = link.title || null; + const text = cap[1].replace(rules.other.outputLinkReplace, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text), + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text, + }; +} +function indentCodeCompensation(raw, text, rules) { + const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(rules.other.beginningSpace); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0], + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(this.rules.other.codeRemoveIndent, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text, + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || '', this.rules); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text, + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (this.rules.other.endingHash.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text), + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: rtrim(cap[0], '\n'), + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], '\n').split('\n'); + let raw = ''; + let text = ''; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + // get lines up to a continuation + if (this.rules.other.blockquoteStart.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } + else if (!inBlockquote) { + currentLines.push(lines[i]); + } + else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join('\n'); + const currentText = currentRaw + // precede setext continuation with 4 spaces so it isn't a setext + .replace(this.rules.other.blockquoteSetextReplace, '\n $1') + .replace(this.rules.other.blockquoteSetextReplace2, ''); + raw = raw ? `${raw}\n${currentRaw}` : currentRaw; + text = text ? `${text}\n${currentText}` : currentText; + // parse blockquote lines as top level tokens + // merge paragraphs if this is a continuation + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + // if there is no continuation then we are done + if (lines.length === 0) { + break; + } + const lastToken = tokens.at(-1); + if (lastToken?.type === 'code') { + // blockquote continuation cannot be preceded by a code block + break; + } + else if (lastToken?.type === 'blockquote') { + // include continuation in nested blockquote + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } + else if (lastToken?.type === 'list') { + // include continuation in nested list + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens.at(-1).raw.length).split('\n'); + continue; + } + } + return { + type: 'blockquote', + raw, + tokens, + text, + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [], + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = this.rules.other.listItemRegex(bull); + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + let raw = ''; + let itemContents = ''; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(this.rules.other.listReplaceTabs, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else if (blankLine) { + indent = cap[1].length + 1; + } + else { + indent = cap[2].search(this.rules.other.nonSpaceChar); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && this.rules.other.blankLine.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = this.rules.other.nextBulletRegex(indent); + const hrRegex = this.rules.other.hrRegex(indent); + const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); + const headingBeginRegex = this.rules.other.headingBeginRegex(indent); + const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + let nextLineWithoutTabs; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(this.rules.other.listReplaceNesting, ' '); + nextLineWithoutTabs = nextLine; + } + else { + nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of html block + if (htmlBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(nextLine)) { + break; + } + if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLineWithoutTabs.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.replace(this.rules.other.tabCharGlobal, ' ').search(this.rules.other.nonSpaceChar) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLineWithoutTabs.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (this.rules.other.doubleBlankLine.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = this.rules.other.listIsTask.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(this.rules.other.listReplaceTask, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [], + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + const lastItem = list.items.at(-1); + if (lastItem) { + lastItem.raw = lastItem.raw.trimEnd(); + lastItem.text = lastItem.text.trimEnd(); + } + else { + // not a list since there were no items + return; + } + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => this.rules.other.anyLine.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0], + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, ' '); + const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title, + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!this.rules.other.tableDelimiter.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(this.rules.other.tableAlignChars, '').split('|'); + const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [], + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (this.rules.other.tableAlignRight.test(align)) { + item.align.push('right'); + } + else if (this.rules.other.tableAlignCenter.test(align)) { + item.align.push('center'); + } + else if (this.rules.other.tableAlignLeft.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i], + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i], + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]), + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text), + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]), + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: cap[1], + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { + this.lexer.state.inLink = true; + } + else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0], + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { + // commonmark requires matching angle brackets + if (!(this.rules.other.endAngleBracket.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = this.rules.other.pedanticHrefTitle.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (this.rules.other.startAngleBracket.test(href)) { + if (this.options.pedantic && !(this.rules.other.endAngleBracket.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, + }, cap[0], this.lexer, this.rules); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text, + }; + } + return outputLink(cap, link, cap[0], this.lexer, this.rules); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(this.rules.other.newLineCharGlobal, ' '); + const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); + const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + return { + type: 'codespan', + raw: cap[0], + text, + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0], + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]), + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = cap[1]; + href = 'mailto:' + text; + } + else { + text = cap[1]; + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = cap[0]; + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = cap[0]; + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + const escaped = this.lexer.state.inRawBlock; + return { + type: 'text', + raw: cap[0], + text: cap[0], + escaped, + }; + } + } +} + +/** + * Block Lexer + */ +class _Lexer { + tokens; + options; + state; + tokenizer; + inlineQueue; + constructor(options) { + // TokenList cannot be created in one go + this.tokens = []; + this.tokens.links = Object.create(null); + this.options = options || _defaults; + this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); + this.tokenizer = this.options.tokenizer; + this.tokenizer.options = this.options; + this.tokenizer.lexer = this; + this.inlineQueue = []; + this.state = { + inLink: false, + inRawBlock: false, + top: true, + }; + const rules = { + other, + block: block.normal, + inline: inline.normal, + }; + if (this.options.pedantic) { + rules.block = block.pedantic; + rules.inline = inline.pedantic; + } + else if (this.options.gfm) { + rules.block = block.gfm; + if (this.options.breaks) { + rules.inline = inline.breaks; + } + else { + rules.inline = inline.gfm; + } + } + this.tokenizer.rules = rules; + } + /** + * Expose Rules + */ + static get rules() { + return { + block, + inline, + }; + } + /** + * Static Lex Method + */ + static lex(src, options) { + const lexer = new _Lexer(options); + return lexer.lex(src); + } + /** + * Static Lex Inline Method + */ + static lexInline(src, options) { + const lexer = new _Lexer(options); + return lexer.inlineTokens(src); + } + /** + * Preprocessing + */ + lex(src) { + src = src.replace(other.carriageReturn, '\n'); + this.blockTokens(src, this.tokens); + for (let i = 0; i < this.inlineQueue.length; i++) { + const next = this.inlineQueue[i]; + this.inlineTokens(next.src, next.tokens); + } + this.inlineQueue = []; + return this.tokens; + } + blockTokens(src, tokens = [], lastParagraphClipped = false) { + if (this.options.pedantic) { + src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''); + } + while (src) { + let token; + if (this.options.extensions?.block?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.raw.length === 1 && lastToken !== undefined) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + lastToken.raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + // An indented code block cannot interrupt a paragraph. + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue.at(-1).src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title, + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + const lastToken = tokens.at(-1); + if (lastParagraphClipped && lastToken?.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = cutSrc.length !== src.length; + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match = null; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + + '[' + 'a'.repeat(match[0].length - 2) + ']' + + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + let keepPrevChar = false; + let prevChar = ''; + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + let token; + // extensions + if (this.options.extensions?.inline?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.type === 'text' && lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + parser; // set by the parser + constructor(options) { + this.options = options || _defaults; + } + space(token) { + return ''; + } + code({ text, lang, escaped }) { + const langString = (lang || '').match(other.notSpaceStart)?.[0]; + const code = text.replace(other.endingNewline, '') + '\n'; + if (!langString) { + return '
'
+                + (escaped ? code : escape(code, true))
+                + '
\n'; + } + return '
'
+            + (escaped ? code : escape(code, true))
+            + '
\n'; + } + blockquote({ tokens }) { + const body = this.parser.parse(tokens); + return `
\n${body}
\n`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `${this.parser.parseInline(tokens)}\n`; + } + hr(token) { + return '
\n'; + } + list(token) { + const ordered = token.ordered; + const start = token.start; + let body = ''; + for (let j = 0; j < token.items.length; j++) { + const item = token.items[j]; + body += this.listitem(item); + } + const type = ordered ? 'ol' : 'ul'; + const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; + return '<' + type + startAttr + '>\n' + body + '\n'; + } + listitem(item) { + let itemBody = ''; + if (item.task) { + const checkbox = this.checkbox({ checked: !!item.checked }); + if (item.loose) { + if (item.tokens[0]?.type === 'paragraph') { + item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { + item.tokens[0].tokens[0].text = checkbox + ' ' + escape(item.tokens[0].tokens[0].text); + item.tokens[0].tokens[0].escaped = true; + } + } + else { + item.tokens.unshift({ + type: 'text', + raw: checkbox + ' ', + text: checkbox + ' ', + escaped: true, + }); + } + } + else { + itemBody += checkbox + ' '; + } + } + itemBody += this.parser.parse(item.tokens, !!item.loose); + return `
  • ${itemBody}
  • \n`; + } + checkbox({ checked }) { + return ''; + } + paragraph({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    \n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `
    ${body}`; + return '
    \n' + + '\n' + + header + + '\n' + + body + + '
    \n'; + } + tablerow({ text }) { + return `\n${text}\n`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? 'th' : 'td'; + const tag = token.align + ? `<${type} align="${token.align}">` + : `<${type}>`; + return tag + content + `\n`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${escape(text, true)}`; + } + br(token) { + return '
    '; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    '; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return escape(text); + } + href = cleanHref; + let out = `${text} { + const tokens = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens, callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (['options', 'parser'].includes(prop)) { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (['options', 'block'].includes(prop)) { + // ignore options and block properties + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + parseMarkdown(blockType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parse = (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + // throw error if an extension set async to true but parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); + } + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); + const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + return parse; + } + onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '

    An error occurred:

    '
    +                    + escape(e.message + '', true)
    +                    + '
    '; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; +//# sourceMappingURL=marked.esm.js.map diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.html b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.html new file mode 100644 index 00000000000..32ac36286a7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.html @@ -0,0 +1,31 @@ + + + + + + PDF viewer + + + + + + +
    +
    +
    + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.mjs new file mode 100644 index 00000000000..8a4a6b76f5e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.mjs @@ -0,0 +1,62 @@ +import { GlobalWorkerOptions } from '../pdfjs-dist/dist/build/pdf.min.mjs'; +import { EventBus, PDFLinkService, PDFFindController, PDFViewer } from '../pdfjs-dist/dist/web/pdf_viewer.mjs'; + +GlobalWorkerOptions.workerSrc = '../pdfjs-dist/dist/build/pdf.worker.min.mjs'; + +// Extract the file path from the URL query string. +const url = new URL(window.location); +const fileUrl = url.searchParams.get('file'); +if (!fileUrl) { + throw new Error('File not specified in the URL query string'); +} + +const container = document.getElementById('viewerContainer'); +const eventBus = new EventBus(); + +// Enable hyperlinks within PDF files. +const pdfLinkService = new PDFLinkService({ + eventBus, +}); + +// Enable the find controller. +const pdfFindController = new PDFFindController({ + eventBus, + linkService: pdfLinkService, +}); + +// Create the PDF viewer. +const pdfViewer = new PDFViewer({ + container, + eventBus, + linkService: pdfLinkService, + findController: pdfFindController, +}); +pdfLinkService.setViewer(pdfViewer); + +// Allow navigation to a citation from the URL hash. +eventBus.on('pagesinit', function () { + pdfLinkService.setHash(window.location.hash.substring(1)); +}); + +// Define how the "search" query parameter is handled. +eventBus.on('findfromurlhash', function(evt) { + eventBus.dispatch('find', { + source: evt.source, + type: '', + query: evt.query, + caseSensitive: false, + entireWord: false, + highlightAll: false, + findPrevious: false, + matchDiacritics: true, + }); +}); + +// Load and initialize the document. +const pdfDocument = await pdfjsLib.getDocument({ + url: fileUrl, + enableXfa: true, +}).promise; + +pdfViewer.setDocument(pdfDocument); +pdfLinkService.setDocument(pdfDocument, null); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/README.md new file mode 100644 index 00000000000..8e77fba7d43 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/README.md @@ -0,0 +1,10 @@ +pdfjs-dist version 4.10.38 +https://github.com/mozilla/pdf.js +License: Apache-2.0 + +To update, replace the following files with updated versions from https://www.npmjs.com/package/pdfjs-dist: +* `build/pdf.min.mjs` +* `build/pdf.worker.min.mjs` +* `web/pdf_viewer.css` +* `web/pdf_viewer.mjs` +* `web/images/loading-icon.gif` diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs new file mode 100644 index 00000000000..d7cfa914562 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs @@ -0,0 +1,21 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2024 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */var t={d:(e,i)=>{for(var s in i)t.o(i,s)&&!t.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:i[s]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)},__webpack_exports__ = globalThis.pdfjsLib = {};t.d(__webpack_exports__,{AbortException:()=>AbortException,AnnotationEditorLayer:()=>AnnotationEditorLayer,AnnotationEditorParamsType:()=>m,AnnotationEditorType:()=>g,AnnotationEditorUIManager:()=>AnnotationEditorUIManager,AnnotationLayer:()=>AnnotationLayer,AnnotationMode:()=>p,ColorPicker:()=>ColorPicker,DOMSVGFactory:()=>DOMSVGFactory,DrawLayer:()=>DrawLayer,FeatureTest:()=>util_FeatureTest,GlobalWorkerOptions:()=>GlobalWorkerOptions,ImageKind:()=>_,InvalidPDFException:()=>InvalidPDFException,MissingPDFException:()=>MissingPDFException,OPS:()=>X,OutputScale:()=>OutputScale,PDFDataRangeTransport:()=>PDFDataRangeTransport,PDFDateString:()=>PDFDateString,PDFWorker:()=>PDFWorker,PasswordResponses:()=>K,PermissionFlag:()=>f,PixelsPerInch:()=>PixelsPerInch,RenderingCancelledException:()=>RenderingCancelledException,TextLayer:()=>TextLayer,TouchManager:()=>TouchManager,UnexpectedResponseException:()=>UnexpectedResponseException,Util:()=>Util,VerbosityLevel:()=>q,XfaLayer:()=>XfaLayer,build:()=>Nt,createValidAbsoluteUrl:()=>createValidAbsoluteUrl,fetchData:()=>fetchData,getDocument:()=>getDocument,getFilenameFromUrl:()=>getFilenameFromUrl,getPdfFilenameFromUrl:()=>getPdfFilenameFromUrl,getXfaPageViewport:()=>getXfaPageViewport,isDataScheme:()=>isDataScheme,isPdfFile:()=>isPdfFile,noContextMenu:()=>noContextMenu,normalizeUnicode:()=>normalizeUnicode,setLayerDimensions:()=>setLayerDimensions,shadow:()=>shadow,stopEvent:()=>stopEvent,version:()=>Ot});const e=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],s=[.001,0,0,.001,0,0],n=1.35,a=1,r=2,o=4,l=16,h=32,d=64,c=128,u=256,p={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3},g={DISABLE:-1,NONE:0,FREETEXT:3,HIGHLIGHT:9,STAMP:13,INK:15},m={RESIZE:1,CREATE:2,FREETEXT_SIZE:11,FREETEXT_COLOR:12,FREETEXT_OPACITY:13,INK_COLOR:21,INK_THICKNESS:22,INK_OPACITY:23,HIGHLIGHT_COLOR:31,HIGHLIGHT_DEFAULT_COLOR:32,HIGHLIGHT_THICKNESS:33,HIGHLIGHT_FREE:34,HIGHLIGHT_SHOW_ALL:35,DRAW_STEP:41},f={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},b=0,A=1,w=2,v=3,y=3,x=4,_={GRAYSCALE_1BPP:1,RGB_24BPP:2,RGBA_32BPP:3},E=1,S=2,C=3,T=4,M=5,P=6,D=7,k=8,R=9,I=10,F=11,L=12,O=13,N=14,B=15,H=16,z=17,U=20,G=1,$=2,V=3,j=4,W=5,q={ERRORS:0,WARNINGS:1,INFOS:5},X={dependency:1,setLineWidth:2,setLineCap:3,setLineJoin:4,setMiterLimit:5,setDash:6,setRenderingIntent:7,setFlatness:8,setGState:9,save:10,restore:11,transform:12,moveTo:13,lineTo:14,curveTo:15,curveTo2:16,curveTo3:17,closePath:18,rectangle:19,stroke:20,closeStroke:21,fill:22,eoFill:23,fillStroke:24,eoFillStroke:25,closeFillStroke:26,closeEOFillStroke:27,endPath:28,clip:29,eoClip:30,beginText:31,endText:32,setCharSpacing:33,setWordSpacing:34,setHScale:35,setLeading:36,setFont:37,setTextRenderingMode:38,setTextRise:39,moveText:40,setLeadingMoveText:41,setTextMatrix:42,nextLine:43,showText:44,showSpacedText:45,nextLineShowText:46,nextLineSetSpacingShowText:47,setCharWidth:48,setCharWidthAndBounds:49,setStrokeColorSpace:50,setFillColorSpace:51,setStrokeColor:52,setStrokeColorN:53,setFillColor:54,setFillColorN:55,setStrokeGray:56,setFillGray:57,setStrokeRGBColor:58,setFillRGBColor:59,setStrokeCMYKColor:60,setFillCMYKColor:61,shadingFill:62,beginInlineImage:63,beginImageData:64,endInlineImage:65,paintXObject:66,markPoint:67,markPointProps:68,beginMarkedContent:69,beginMarkedContentProps:70,endMarkedContent:71,beginCompat:72,endCompat:73,paintFormXObjectBegin:74,paintFormXObjectEnd:75,beginGroup:76,endGroup:77,beginAnnotation:80,endAnnotation:81,paintImageMaskXObject:83,paintImageMaskXObjectGroup:84,paintImageXObject:85,paintInlineImageXObject:86,paintInlineImageXObjectGroup:87,paintImageXObjectRepeat:88,paintImageMaskXObjectRepeat:89,paintSolidColorImageMask:90,constructPath:91,setStrokeTransparent:92,setFillTransparent:93},K={NEED_PASSWORD:1,INCORRECT_PASSWORD:2};let Y=q.WARNINGS;function setVerbosityLevel(t){Number.isInteger(t)&&(Y=t)}function getVerbosityLevel(){return Y}function info(t){Y>=q.INFOS&&console.log(`Info: ${t}`)}function warn(t){Y>=q.WARNINGS&&console.log(`Warning: ${t}`)}function unreachable(t){throw new Error(t)}function assert(t,e){t||unreachable(e)}function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=function stringToUTF8String(t){return decodeURIComponent(escape(t))}(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null}function shadow(t,e,i,s=!1){Object.defineProperty(t,e,{value:i,enumerable:!s,configurable:!0,writable:!1});return i}const Q=function BaseExceptionClosure(){function BaseException(t,e){this.message=t;this.name=e}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends Q{constructor(t,e){super(t,"PasswordException");this.code=e}}class UnknownErrorException extends Q{constructor(t,e){super(t,"UnknownErrorException");this.details=e}}class InvalidPDFException extends Q{constructor(t){super(t,"InvalidPDFException")}}class MissingPDFException extends Q{constructor(t){super(t,"MissingPDFException")}}class UnexpectedResponseException extends Q{constructor(t,e){super(t,"UnexpectedResponseException");this.status=e}}class FormatError extends Q{constructor(t){super(t,"FormatError")}}class AbortException extends Q{constructor(t){super(t,"AbortException")}}function bytesToString(t){"object"==typeof t&&void 0!==t?.length||unreachable("Invalid argument for bytesToString");const e=t.length,i=8192;if(et.toString(16).padStart(2,"0")));class Util{static makeHexColor(t,e,i){return`#${J[t]}${J[e]}${J[i]}`}static scaleMinMax(t,e){let i;if(t[0]){if(t[0]<0){i=e[0];e[0]=e[2];e[2]=i}e[0]*=t[0];e[2]*=t[0];if(t[3]<0){i=e[1];e[1]=e[3];e[3]=i}e[1]*=t[3];e[3]*=t[3]}else{i=e[0];e[0]=e[1];e[1]=i;i=e[2];e[2]=e[3];e[3]=i;if(t[1]<0){i=e[1];e[1]=e[3];e[3]=i}e[1]*=t[1];e[3]*=t[1];if(t[2]<0){i=e[0];e[0]=e[2];e[2]=i}e[0]*=t[2];e[2]*=t[2]}e[0]+=t[4];e[1]+=t[5];e[2]+=t[4];e[3]+=t[5]}static transform(t,e){return[t[0]*e[0]+t[2]*e[1],t[1]*e[0]+t[3]*e[1],t[0]*e[2]+t[2]*e[3],t[1]*e[2]+t[3]*e[3],t[0]*e[4]+t[2]*e[5]+t[4],t[1]*e[4]+t[3]*e[5]+t[5]]}static applyTransform(t,e){return[t[0]*e[0]+t[1]*e[2]+e[4],t[0]*e[1]+t[1]*e[3]+e[5]]}static applyInverseTransform(t,e){const i=e[0]*e[3]-e[1]*e[2];return[(t[0]*e[3]-t[1]*e[2]+e[2]*e[5]-e[4]*e[3])/i,(-t[0]*e[1]+t[1]*e[0]+e[4]*e[1]-e[5]*e[0])/i]}static getAxialAlignedBoundingBox(t,e){const i=this.applyTransform(t,e),s=this.applyTransform(t.slice(2,4),e),n=this.applyTransform([t[0],t[3]],e),a=this.applyTransform([t[2],t[1]],e);return[Math.min(i[0],s[0],n[0],a[0]),Math.min(i[1],s[1],n[1],a[1]),Math.max(i[0],s[0],n[0],a[0]),Math.max(i[1],s[1],n[1],a[1])]}static inverseTransform(t){const e=t[0]*t[3]-t[1]*t[2];return[t[3]/e,-t[1]/e,-t[2]/e,t[0]/e,(t[2]*t[5]-t[4]*t[3])/e,(t[4]*t[1]-t[5]*t[0])/e]}static singularValueDecompose2dScale(t){const e=[t[0],t[2],t[1],t[3]],i=t[0]*e[0]+t[1]*e[2],s=t[0]*e[1]+t[1]*e[3],n=t[2]*e[0]+t[3]*e[2],a=t[2]*e[1]+t[3]*e[3],r=(i+a)/2,o=Math.sqrt((i+a)**2-4*(i*a-n*s))/2,l=r+o||1,h=r-o||1;return[Math.sqrt(l),Math.sqrt(h)]}static normalizeRect(t){const e=t.slice(0);if(t[0]>t[2]){e[0]=t[2];e[2]=t[0]}if(t[1]>t[3]){e[1]=t[3];e[3]=t[1]}return e}static intersect(t,e){const i=Math.max(Math.min(t[0],t[2]),Math.min(e[0],e[2])),s=Math.min(Math.max(t[0],t[2]),Math.max(e[0],e[2]));if(i>s)return null;const n=Math.max(Math.min(t[1],t[3]),Math.min(e[1],e[3])),a=Math.min(Math.max(t[1],t[3]),Math.max(e[1],e[3]));return n>a?null:[i,n,s,a]}static#t(t,e,i,s,n,a,r,o,l,h){if(l<=0||l>=1)return;const d=1-l,c=l*l,u=c*l,p=d*(d*(d*t+3*l*e)+3*c*i)+u*s,g=d*(d*(d*n+3*l*a)+3*c*r)+u*o;h[0]=Math.min(h[0],p);h[1]=Math.min(h[1],g);h[2]=Math.max(h[2],p);h[3]=Math.max(h[3],g)}static#e(t,e,i,s,n,a,r,o,l,h,d,c){if(Math.abs(l)<1e-12){Math.abs(h)>=1e-12&&this.#t(t,e,i,s,n,a,r,o,-d/h,c);return}const u=h**2-4*d*l;if(u<0)return;const p=Math.sqrt(u),g=2*l;this.#t(t,e,i,s,n,a,r,o,(-h+p)/g,c);this.#t(t,e,i,s,n,a,r,o,(-h-p)/g,c)}static bezierBoundingBox(t,e,i,s,n,a,r,o,l){if(l){l[0]=Math.min(l[0],t,r);l[1]=Math.min(l[1],e,o);l[2]=Math.max(l[2],t,r);l[3]=Math.max(l[3],e,o)}else l=[Math.min(t,r),Math.min(e,o),Math.max(t,r),Math.max(e,o)];this.#e(t,i,n,r,e,s,a,o,3*(3*(i-n)-t+r),6*(t-2*i+n),3*(i-t),l);this.#e(t,i,n,r,e,s,a,o,3*(3*(s-a)-e+o),6*(e-2*s+a),3*(s-e),l);return l}}let Z=null,tt=null;function normalizeUnicode(t){if(!Z){Z=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;tt=new Map([["ſt","ſt"]])}return t.replaceAll(Z,((t,e,i)=>e?e.normalize("NFKC"):tt.get(i)))}const et="pdfjs_internal_id_";"function"!=typeof Promise.try&&(Promise.try=function(t,...e){return new Promise((i=>{i(t(...e))}))});const it="http://www.w3.org/2000/svg";class PixelsPerInch{static CSS=96;static PDF=72;static PDF_TO_CSS_UNITS=this.CSS/this.PDF}async function fetchData(t,e="text"){if(isValidFetchUrl(t,document.baseURI)){const i=await fetch(t);if(!i.ok)throw new Error(i.statusText);switch(e){case"arraybuffer":return i.arrayBuffer();case"blob":return i.blob();case"json":return i.json()}return i.text()}return new Promise(((i,s)=>{const n=new XMLHttpRequest;n.open("GET",t,!0);n.responseType=e;n.onreadystatechange=()=>{if(n.readyState===XMLHttpRequest.DONE)if(200!==n.status&&0!==n.status)s(new Error(n.statusText));else{switch(e){case"arraybuffer":case"blob":case"json":i(n.response);return}i(n.responseText)}};n.send(null)}))}class PageViewport{constructor({viewBox:t,userUnit:e,scale:i,rotation:s,offsetX:n=0,offsetY:a=0,dontFlip:r=!1}){this.viewBox=t;this.userUnit=e;this.scale=i;this.rotation=s;this.offsetX=n;this.offsetY=a;i*=e;const o=(t[2]+t[0])/2,l=(t[3]+t[1])/2;let h,d,c,u,p,g,m,f;(s%=360)<0&&(s+=360);switch(s){case 180:h=-1;d=0;c=0;u=1;break;case 90:h=0;d=1;c=1;u=0;break;case 270:h=0;d=-1;c=-1;u=0;break;case 0:h=1;d=0;c=0;u=-1;break;default:throw new Error("PageViewport: Invalid rotation, must be a multiple of 90 degrees.")}if(r){c=-c;u=-u}if(0===h){p=Math.abs(l-t[1])*i+n;g=Math.abs(o-t[0])*i+a;m=(t[3]-t[1])*i;f=(t[2]-t[0])*i}else{p=Math.abs(o-t[0])*i+n;g=Math.abs(l-t[1])*i+a;m=(t[2]-t[0])*i;f=(t[3]-t[1])*i}this.transform=[h*i,d*i,c*i,u*i,p-h*i*o-c*i*l,g-d*i*o-u*i*l];this.width=m;this.height=f}get rawDims(){const{userUnit:t,viewBox:e}=this,i=e.map((e=>e*t));return shadow(this,"rawDims",{pageWidth:i[2]-i[0],pageHeight:i[3]-i[1],pageX:i[0],pageY:i[1]})}clone({scale:t=this.scale,rotation:e=this.rotation,offsetX:i=this.offsetX,offsetY:s=this.offsetY,dontFlip:n=!1}={}){return new PageViewport({viewBox:this.viewBox.slice(),userUnit:this.userUnit,scale:t,rotation:e,offsetX:i,offsetY:s,dontFlip:n})}convertToViewportPoint(t,e){return Util.applyTransform([t,e],this.transform)}convertToViewportRectangle(t){const e=Util.applyTransform([t[0],t[1]],this.transform),i=Util.applyTransform([t[2],t[3]],this.transform);return[e[0],e[1],i[0],i[1]]}convertToPdfPoint(t,e){return Util.applyInverseTransform([t,e],this.transform)}}class RenderingCancelledException extends Q{constructor(t,e=0){super(t,"RenderingCancelledException");this.extraDelay=e}}function isDataScheme(t){const e=t.length;let i=0;for(;i=1&&s<=12?s-1:0;let n=parseInt(e[3],10);n=n>=1&&n<=31?n:1;let a=parseInt(e[4],10);a=a>=0&&a<=23?a:0;let r=parseInt(e[5],10);r=r>=0&&r<=59?r:0;let o=parseInt(e[6],10);o=o>=0&&o<=59?o:0;const l=e[7]||"Z";let h=parseInt(e[8],10);h=h>=0&&h<=23?h:0;let d=parseInt(e[9],10)||0;d=d>=0&&d<=59?d:0;if("-"===l){a+=h;r+=d}else if("+"===l){a-=h;r-=d}return new Date(Date.UTC(i,s,n,a,r,o))}}function getXfaPageViewport(t,{scale:e=1,rotation:i=0}){const{width:s,height:n}=t.attributes.style,a=[0,0,parseInt(s),parseInt(n)];return new PageViewport({viewBox:a,userUnit:1,scale:e,rotation:i})}function getRGB(t){if(t.startsWith("#")){const e=parseInt(t.slice(1),16);return[(16711680&e)>>16,(65280&e)>>8,255&e]}if(t.startsWith("rgb("))return t.slice(4,-1).split(",").map((t=>parseInt(t)));if(t.startsWith("rgba("))return t.slice(5,-1).split(",").map((t=>parseInt(t))).slice(0,3);warn(`Not a valid color format: "${t}"`);return[0,0,0]}function getCurrentTransform(t){const{a:e,b:i,c:s,d:n,e:a,f:r}=t.getTransform();return[e,i,s,n,a,r]}function getCurrentTransformInverse(t){const{a:e,b:i,c:s,d:n,e:a,f:r}=t.getTransform().invertSelf();return[e,i,s,n,a,r]}function setLayerDimensions(t,e,i=!1,s=!0){if(e instanceof PageViewport){const{pageWidth:s,pageHeight:n}=e.rawDims,{style:a}=t,r=util_FeatureTest.isCSSRoundSupported,o=`var(--scale-factor) * ${s}px`,l=`var(--scale-factor) * ${n}px`,h=r?`round(down, ${o}, var(--scale-round-x, 1px))`:`calc(${o})`,d=r?`round(down, ${l}, var(--scale-round-y, 1px))`:`calc(${l})`;if(i&&e.rotation%180!=0){a.width=d;a.height=h}else{a.width=h;a.height=d}}s&&t.setAttribute("data-main-rotation",e.rotation)}class OutputScale{constructor(){const t=window.devicePixelRatio||1;this.sx=t;this.sy=t}get scaled(){return 1!==this.sx||1!==this.sy}get symmetric(){return this.sx===this.sy}}class EditorToolbar{#s=null;#n=null;#a;#r=null;#o=null;static#l=null;constructor(t){this.#a=t;EditorToolbar.#l||=Object.freeze({freetext:"pdfjs-editor-remove-freetext-button",highlight:"pdfjs-editor-remove-highlight-button",ink:"pdfjs-editor-remove-ink-button",stamp:"pdfjs-editor-remove-stamp-button"})}render(){const t=this.#s=document.createElement("div");t.classList.add("editToolbar","hidden");t.setAttribute("role","toolbar");const e=this.#a._uiManager._signal;t.addEventListener("contextmenu",noContextMenu,{signal:e});t.addEventListener("pointerdown",EditorToolbar.#h,{signal:e});const i=this.#r=document.createElement("div");i.className="buttons";t.append(i);const s=this.#a.toolbarPosition;if(s){const{style:e}=t,i="ltr"===this.#a._uiManager.direction?1-s[0]:s[0];e.insetInlineEnd=100*i+"%";e.top=`calc(${100*s[1]}% + var(--editor-toolbar-vert-offset))`}this.#d();return t}get div(){return this.#s}static#h(t){t.stopPropagation()}#c(t){this.#a._focusEventsAllowed=!1;stopEvent(t)}#u(t){this.#a._focusEventsAllowed=!0;stopEvent(t)}#p(t){const e=this.#a._uiManager._signal;t.addEventListener("focusin",this.#c.bind(this),{capture:!0,signal:e});t.addEventListener("focusout",this.#u.bind(this),{capture:!0,signal:e});t.addEventListener("contextmenu",noContextMenu,{signal:e})}hide(){this.#s.classList.add("hidden");this.#n?.hideDropdown()}show(){this.#s.classList.remove("hidden");this.#o?.shown()}#d(){const{editorType:t,_uiManager:e}=this.#a,i=document.createElement("button");i.className="delete";i.tabIndex=0;i.setAttribute("data-l10n-id",EditorToolbar.#l[t]);this.#p(i);i.addEventListener("click",(t=>{e.delete()}),{signal:e._signal});this.#r.append(i)}get#g(){const t=document.createElement("div");t.className="divider";return t}async addAltText(t){const e=await t.render();this.#p(e);this.#r.prepend(e,this.#g);this.#o=t}addColorPicker(t){this.#n=t;const e=t.renderButton();this.#p(e);this.#r.prepend(e,this.#g)}remove(){this.#s.remove();this.#n?.destroy();this.#n=null}}class HighlightToolbar{#r=null;#s=null;#m;constructor(t){this.#m=t}#f(){const t=this.#s=document.createElement("div");t.className="editToolbar";t.setAttribute("role","toolbar");t.addEventListener("contextmenu",noContextMenu,{signal:this.#m._signal});const e=this.#r=document.createElement("div");e.className="buttons";t.append(e);this.#b();return t}#A(t,e){let i=0,s=0;for(const n of t){const t=n.y+n.height;if(ti){s=a;i=t}else e?a>s&&(s=a):a{this.#m.highlightSelection("floating_button")}),{signal:i});this.#r.append(t)}}function bindEvents(t,e,i){for(const s of i)e.addEventListener(s,t[s].bind(t))}class IdManager{#w=0;get id(){return"pdfjs_internal_editor_"+this.#w++}}class ImageManager{#v=function getUuid(){if("function"==typeof crypto.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);crypto.getRandomValues(t);return bytesToString(t)}();#w=0;#y=null;static get _isSVGFittingCanvas(){const t=new OffscreenCanvas(1,3).getContext("2d",{willReadFrequently:!0}),e=new Image;e.src='data:image/svg+xml;charset=UTF-8,';return shadow(this,"_isSVGFittingCanvas",e.decode().then((()=>{t.drawImage(e,0,0,1,1,0,0,1,3);return 0===new Uint32Array(t.getImageData(0,0,1,1).data.buffer)[0]})))}async#x(t,e){this.#y||=new Map;let i=this.#y.get(t);if(null===i)return null;if(i?.bitmap){i.refCounter+=1;return i}try{i||={bitmap:null,id:`image_${this.#v}_${this.#w++}`,refCounter:0,isSvg:!1};let t;if("string"==typeof e){i.url=e;t=await fetchData(e,"blob")}else e instanceof File?t=i.file=e:e instanceof Blob&&(t=e);if("image/svg+xml"===t.type){const e=ImageManager._isSVGFittingCanvas,s=new FileReader,n=new Image,a=new Promise(((t,a)=>{n.onload=()=>{i.bitmap=n;i.isSvg=!0;t()};s.onload=async()=>{const t=i.svgUrl=s.result;n.src=await e?`${t}#svgView(preserveAspectRatio(none))`:t};n.onerror=s.onerror=a}));s.readAsDataURL(t);await a}else i.bitmap=await createImageBitmap(t);i.refCounter=1}catch(t){warn(t);i=null}this.#y.set(t,i);i&&this.#y.set(i.id,i);return i}async getFromFile(t){const{lastModified:e,name:i,size:s,type:n}=t;return this.#x(`${e}_${i}_${s}_${n}`,t)}async getFromUrl(t){return this.#x(t,t)}async getFromBlob(t,e){const i=await e;return this.#x(t,i)}async getFromId(t){this.#y||=new Map;const e=this.#y.get(t);if(!e)return null;if(e.bitmap){e.refCounter+=1;return e}if(e.file)return this.getFromFile(e.file);if(e.blobPromise){const{blobPromise:t}=e;delete e.blobPromise;return this.getFromBlob(e.id,t)}return this.getFromUrl(e.url)}getFromCanvas(t,e){this.#y||=new Map;let i=this.#y.get(t);if(i?.bitmap){i.refCounter+=1;return i}const s=new OffscreenCanvas(e.width,e.height);s.getContext("2d").drawImage(e,0,0);i={bitmap:s.transferToImageBitmap(),id:`image_${this.#v}_${this.#w++}`,refCounter:1,isSvg:!1};this.#y.set(t,i);this.#y.set(i.id,i);return i}getSvgUrl(t){const e=this.#y.get(t);return e?.isSvg?e.svgUrl:null}deleteId(t){this.#y||=new Map;const e=this.#y.get(t);if(!e)return;e.refCounter-=1;if(0!==e.refCounter)return;const{bitmap:i}=e;if(!e.url&&!e.file){const t=new OffscreenCanvas(i.width,i.height);t.getContext("bitmaprenderer").transferFromImageBitmap(i);e.blobPromise=t.convertToBlob()}i.close?.();e.bitmap=null}isValidId(t){return t.startsWith(`image_${this.#v}_`)}}class CommandManager{#_=[];#E=!1;#S;#C=-1;constructor(t=128){this.#S=t}add({cmd:t,undo:e,post:i,mustExec:s,type:n=NaN,overwriteIfSameType:a=!1,keepUndo:r=!1}){s&&t();if(this.#E)return;const o={cmd:t,undo:e,post:i,type:n};if(-1===this.#C){this.#_.length>0&&(this.#_.length=0);this.#C=0;this.#_.push(o);return}if(a&&this.#_[this.#C].type===n){r&&(o.undo=this.#_[this.#C].undo);this.#_[this.#C]=o;return}const l=this.#C+1;if(l===this.#S)this.#_.splice(0,1);else{this.#C=l;l=0;e--)if(this.#_[e].type!==t){this.#_.splice(e+1,this.#C-e);this.#C=e;return}this.#_.length=0;this.#C=-1}}destroy(){this.#_=null}}class KeyboardManager{constructor(t){this.buffer=[];this.callbacks=new Map;this.allKeys=new Set;const{isMac:e}=util_FeatureTest.platform;for(const[i,s,n={}]of t)for(const t of i){const i=t.startsWith("mac+");if(e&&i){this.callbacks.set(t.slice(4),{callback:s,options:n});this.allKeys.add(t.split("+").at(-1))}else if(!e&&!i){this.callbacks.set(t,{callback:s,options:n});this.allKeys.add(t.split("+").at(-1))}}}#T(t){t.altKey&&this.buffer.push("alt");t.ctrlKey&&this.buffer.push("ctrl");t.metaKey&&this.buffer.push("meta");t.shiftKey&&this.buffer.push("shift");this.buffer.push(t.key);const e=this.buffer.join("+");this.buffer.length=0;return e}exec(t,e){if(!this.allKeys.has(e.key))return;const i=this.callbacks.get(this.#T(e));if(!i)return;const{callback:s,options:{bubbles:n=!1,args:a=[],checker:r=null}}=i;if(!r||r(t,e)){s.bind(t,...a,e)();n||stopEvent(e)}}}class ColorManager{static _colorsMapping=new Map([["CanvasText",[0,0,0]],["Canvas",[255,255,255]]]);get _colors(){const t=new Map([["CanvasText",null],["Canvas",null]]);!function getColorValues(t){const e=document.createElement("span");e.style.visibility="hidden";document.body.append(e);for(const i of t.keys()){e.style.color=i;const s=window.getComputedStyle(e).color;t.set(i,getRGB(s))}e.remove()}(t);return shadow(this,"_colors",t)}convert(t){const e=getRGB(t);if(!window.matchMedia("(forced-colors: active)").matches)return e;for(const[t,i]of this._colors)if(i.every(((t,i)=>t===e[i])))return ColorManager._colorsMapping.get(t);return e}getHexCode(t){const e=this._colors.get(t);return e?Util.makeHexColor(...e):t}}class AnnotationEditorUIManager{#M=new AbortController;#P=null;#D=new Map;#k=new Map;#R=null;#I=null;#F=null;#L=new CommandManager;#O=null;#N=null;#B=0;#H=new Set;#z=null;#U=null;#G=new Set;_editorUndoBar=null;#$=!1;#V=!1;#j=!1;#W=null;#q=null;#X=null;#K=null;#Y=!1;#Q=null;#J=new IdManager;#Z=!1;#tt=!1;#et=null;#it=null;#st=null;#nt=null;#at=g.NONE;#rt=new Set;#ot=null;#lt=null;#ht=null;#dt={isEditing:!1,isEmpty:!0,hasSomethingToUndo:!1,hasSomethingToRedo:!1,hasSelectedEditor:!1,hasSelectedText:!1};#ct=[0,0];#ut=null;#pt=null;#gt=null;#mt=null;static TRANSLATE_SMALL=1;static TRANSLATE_BIG=10;static get _keyboardManager(){const t=AnnotationEditorUIManager.prototype,arrowChecker=t=>t.#pt.contains(document.activeElement)&&"BUTTON"!==document.activeElement.tagName&&t.hasSomethingToControl(),textInputChecker=(t,{target:e})=>{if(e instanceof HTMLInputElement){const{type:t}=e;return"text"!==t&&"number"!==t}return!0},e=this.TRANSLATE_SMALL,i=this.TRANSLATE_BIG;return shadow(this,"_keyboardManager",new KeyboardManager([[["ctrl+a","mac+meta+a"],t.selectAll,{checker:textInputChecker}],[["ctrl+z","mac+meta+z"],t.undo,{checker:textInputChecker}],[["ctrl+y","ctrl+shift+z","mac+meta+shift+z","ctrl+shift+Z","mac+meta+shift+Z"],t.redo,{checker:textInputChecker}],[["Backspace","alt+Backspace","ctrl+Backspace","shift+Backspace","mac+Backspace","mac+alt+Backspace","mac+ctrl+Backspace","Delete","ctrl+Delete","shift+Delete","mac+Delete"],t.delete,{checker:textInputChecker}],[["Enter","mac+Enter"],t.addNewEditorFromKeyboard,{checker:(t,{target:e})=>!(e instanceof HTMLButtonElement)&&t.#pt.contains(e)&&!t.isEnterHandled}],[[" ","mac+ "],t.addNewEditorFromKeyboard,{checker:(t,{target:e})=>!(e instanceof HTMLButtonElement)&&t.#pt.contains(document.activeElement)}],[["Escape","mac+Escape"],t.unselectAll],[["ArrowLeft","mac+ArrowLeft"],t.translateSelectedEditors,{args:[-e,0],checker:arrowChecker}],[["ctrl+ArrowLeft","mac+shift+ArrowLeft"],t.translateSelectedEditors,{args:[-i,0],checker:arrowChecker}],[["ArrowRight","mac+ArrowRight"],t.translateSelectedEditors,{args:[e,0],checker:arrowChecker}],[["ctrl+ArrowRight","mac+shift+ArrowRight"],t.translateSelectedEditors,{args:[i,0],checker:arrowChecker}],[["ArrowUp","mac+ArrowUp"],t.translateSelectedEditors,{args:[0,-e],checker:arrowChecker}],[["ctrl+ArrowUp","mac+shift+ArrowUp"],t.translateSelectedEditors,{args:[0,-i],checker:arrowChecker}],[["ArrowDown","mac+ArrowDown"],t.translateSelectedEditors,{args:[0,e],checker:arrowChecker}],[["ctrl+ArrowDown","mac+shift+ArrowDown"],t.translateSelectedEditors,{args:[0,i],checker:arrowChecker}]]))}constructor(t,e,i,s,n,a,r,o,l,h,d,c,u){const p=this._signal=this.#M.signal;this.#pt=t;this.#gt=e;this.#R=i;this._eventBus=s;s._on("editingaction",this.onEditingAction.bind(this),{signal:p});s._on("pagechanging",this.onPageChanging.bind(this),{signal:p});s._on("scalechanging",this.onScaleChanging.bind(this),{signal:p});s._on("rotationchanging",this.onRotationChanging.bind(this),{signal:p});s._on("setpreference",this.onSetPreference.bind(this),{signal:p});s._on("switchannotationeditorparams",(t=>this.updateParams(t.type,t.value)),{signal:p});this.#ft();this.#bt();this.#At();this.#I=n.annotationStorage;this.#W=n.filterFactory;this.#lt=a;this.#K=r||null;this.#$=o;this.#V=l;this.#j=h;this.#nt=d||null;this.viewParameters={realScale:PixelsPerInch.PDF_TO_CSS_UNITS,rotation:0};this.isShiftKeyDown=!1;this._editorUndoBar=c||null;this._supportsPinchToZoom=!1!==u}destroy(){this.#mt?.resolve();this.#mt=null;this.#M?.abort();this.#M=null;this._signal=null;for(const t of this.#k.values())t.destroy();this.#k.clear();this.#D.clear();this.#G.clear();this.#P=null;this.#rt.clear();this.#L.destroy();this.#R?.destroy();this.#Q?.hide();this.#Q=null;if(this.#q){clearTimeout(this.#q);this.#q=null}if(this.#ut){clearTimeout(this.#ut);this.#ut=null}this._editorUndoBar?.destroy()}combinedSignal(t){return AbortSignal.any([this._signal,t.signal])}get mlManager(){return this.#nt}get useNewAltTextFlow(){return this.#V}get useNewAltTextWhenAddingImage(){return this.#j}get hcmFilter(){return shadow(this,"hcmFilter",this.#lt?this.#W.addHCMFilter(this.#lt.foreground,this.#lt.background):"none")}get direction(){return shadow(this,"direction",getComputedStyle(this.#pt).direction)}get highlightColors(){return shadow(this,"highlightColors",this.#K?new Map(this.#K.split(",").map((t=>t.split("=").map((t=>t.trim()))))):null)}get highlightColorNames(){return shadow(this,"highlightColorNames",this.highlightColors?new Map(Array.from(this.highlightColors,(t=>t.reverse()))):null)}setCurrentDrawingSession(t){if(t){this.unselectAll();this.disableUserSelect(!0)}else this.disableUserSelect(!1);this.#N=t}setMainHighlightColorPicker(t){this.#st=t}editAltText(t,e=!1){this.#R?.editAltText(this,t,e)}switchToMode(t,e){this._eventBus.on("annotationeditormodechanged",e,{once:!0,signal:this._signal});this._eventBus.dispatch("showannotationeditorui",{source:this,mode:t})}setPreference(t,e){this._eventBus.dispatch("setpreference",{source:this,name:t,value:e})}onSetPreference({name:t,value:e}){if("enableNewAltTextWhenAddingImage"===t)this.#j=e}onPageChanging({pageNumber:t}){this.#B=t-1}focusMainContainer(){this.#pt.focus()}findParent(t,e){for(const i of this.#k.values()){const{x:s,y:n,width:a,height:r}=i.div.getBoundingClientRect();if(t>=s&&t<=s+a&&e>=n&&e<=n+r)return i}return null}disableUserSelect(t=!1){this.#gt.classList.toggle("noUserSelect",t)}addShouldRescale(t){this.#G.add(t)}removeShouldRescale(t){this.#G.delete(t)}onScaleChanging({scale:t}){this.commitOrRemove();this.viewParameters.realScale=t*PixelsPerInch.PDF_TO_CSS_UNITS;for(const t of this.#G)t.onScaleChanging();this.#N?.onScaleChanging()}onRotationChanging({pagesRotation:t}){this.commitOrRemove();this.viewParameters.rotation=t}#wt({anchorNode:t}){return t.nodeType===Node.TEXT_NODE?t.parentElement:t}#vt(t){const{currentLayer:e}=this;if(e.hasTextLayer(t))return e;for(const e of this.#k.values())if(e.hasTextLayer(t))return e;return null}highlightSelection(t=""){const e=document.getSelection();if(!e||e.isCollapsed)return;const{anchorNode:i,anchorOffset:s,focusNode:n,focusOffset:a}=e,r=e.toString(),o=this.#wt(e).closest(".textLayer"),l=this.getSelectionBoxes(o);if(!l)return;e.empty();const h=this.#vt(o),d=this.#at===g.NONE,callback=()=>{h?.createAndAddNewEditor({x:0,y:0},!1,{methodOfCreation:t,boxes:l,anchorNode:i,anchorOffset:s,focusNode:n,focusOffset:a,text:r});d&&this.showAllEditors("highlight",!0,!0)};d?this.switchToMode(g.HIGHLIGHT,callback):callback()}#yt(){const t=document.getSelection();if(!t||t.isCollapsed)return;const e=this.#wt(t).closest(".textLayer"),i=this.getSelectionBoxes(e);if(i){this.#Q||=new HighlightToolbar(this);this.#Q.show(e,i,"ltr"===this.direction)}}addToAnnotationStorage(t){t.isEmpty()||!this.#I||this.#I.has(t.id)||this.#I.setValue(t.id,t)}#xt(){const t=document.getSelection();if(!t||t.isCollapsed){if(this.#ot){this.#Q?.hide();this.#ot=null;this.#_t({hasSelectedText:!1})}return}const{anchorNode:e}=t;if(e===this.#ot)return;const i=this.#wt(t).closest(".textLayer");if(i){this.#Q?.hide();this.#ot=e;this.#_t({hasSelectedText:!0});if(this.#at===g.HIGHLIGHT||this.#at===g.NONE){this.#at===g.HIGHLIGHT&&this.showAllEditors("highlight",!0,!0);this.#Y=this.isShiftKeyDown;if(!this.isShiftKeyDown){const t=this.#at===g.HIGHLIGHT?this.#vt(i):null;t?.toggleDrawing();const e=new AbortController,s=this.combinedSignal(e),pointerup=i=>{if("pointerup"!==i.type||0===i.button){e.abort();t?.toggleDrawing(!0);"pointerup"===i.type&&this.#Et("main_toolbar")}};window.addEventListener("pointerup",pointerup,{signal:s});window.addEventListener("blur",pointerup,{signal:s})}}}else if(this.#ot){this.#Q?.hide();this.#ot=null;this.#_t({hasSelectedText:!1})}}#Et(t=""){this.#at===g.HIGHLIGHT?this.highlightSelection(t):this.#$&&this.#yt()}#ft(){document.addEventListener("selectionchange",this.#xt.bind(this),{signal:this._signal})}#St(){if(this.#X)return;this.#X=new AbortController;const t=this.combinedSignal(this.#X);window.addEventListener("focus",this.focus.bind(this),{signal:t});window.addEventListener("blur",this.blur.bind(this),{signal:t})}#Ct(){this.#X?.abort();this.#X=null}blur(){this.isShiftKeyDown=!1;if(this.#Y){this.#Y=!1;this.#Et("main_toolbar")}if(!this.hasSelection)return;const{activeElement:t}=document;for(const e of this.#rt)if(e.div.contains(t)){this.#it=[e,t];e._focusEventsAllowed=!1;break}}focus(){if(!this.#it)return;const[t,e]=this.#it;this.#it=null;e.addEventListener("focusin",(()=>{t._focusEventsAllowed=!0}),{once:!0,signal:this._signal});e.focus()}#At(){if(this.#et)return;this.#et=new AbortController;const t=this.combinedSignal(this.#et);window.addEventListener("keydown",this.keydown.bind(this),{signal:t});window.addEventListener("keyup",this.keyup.bind(this),{signal:t})}#Tt(){this.#et?.abort();this.#et=null}#Mt(){if(this.#O)return;this.#O=new AbortController;const t=this.combinedSignal(this.#O);document.addEventListener("copy",this.copy.bind(this),{signal:t});document.addEventListener("cut",this.cut.bind(this),{signal:t});document.addEventListener("paste",this.paste.bind(this),{signal:t})}#Pt(){this.#O?.abort();this.#O=null}#bt(){const t=this._signal;document.addEventListener("dragover",this.dragOver.bind(this),{signal:t});document.addEventListener("drop",this.drop.bind(this),{signal:t})}addEditListeners(){this.#At();this.#Mt()}removeEditListeners(){this.#Tt();this.#Pt()}dragOver(t){for(const{type:e}of t.dataTransfer.items)for(const i of this.#U)if(i.isHandlingMimeForPasting(e)){t.dataTransfer.dropEffect="copy";t.preventDefault();return}}drop(t){for(const e of t.dataTransfer.items)for(const i of this.#U)if(i.isHandlingMimeForPasting(e.type)){i.paste(e,this.currentLayer);t.preventDefault();return}}copy(t){t.preventDefault();this.#P?.commitOrRemove();if(!this.hasSelection)return;const e=[];for(const t of this.#rt){const i=t.serialize(!0);i&&e.push(i)}0!==e.length&&t.clipboardData.setData("application/pdfjs",JSON.stringify(e))}cut(t){this.copy(t);this.delete()}async paste(t){t.preventDefault();const{clipboardData:e}=t;for(const t of e.items)for(const e of this.#U)if(e.isHandlingMimeForPasting(t.type)){e.paste(t,this.currentLayer);return}let i=e.getData("application/pdfjs");if(!i)return;try{i=JSON.parse(i)}catch(t){warn(`paste: "${t.message}".`);return}if(!Array.isArray(i))return;this.unselectAll();const s=this.currentLayer;try{const t=[];for(const e of i){const i=await s.deserialize(e);if(!i)return;t.push(i)}const cmd=()=>{for(const e of t)this.#Dt(e);this.#kt(t)},undo=()=>{for(const e of t)e.remove()};this.addCommands({cmd,undo,mustExec:!0})}catch(t){warn(`paste: "${t.message}".`)}}keydown(t){this.isShiftKeyDown||"Shift"!==t.key||(this.isShiftKeyDown=!0);this.#at===g.NONE||this.isEditorHandlingKeyboard||AnnotationEditorUIManager._keyboardManager.exec(this,t)}keyup(t){if(this.isShiftKeyDown&&"Shift"===t.key){this.isShiftKeyDown=!1;if(this.#Y){this.#Y=!1;this.#Et("main_toolbar")}}}onEditingAction({name:t}){switch(t){case"undo":case"redo":case"delete":case"selectAll":this[t]();break;case"highlightSelection":this.highlightSelection("context_menu")}}#_t(t){if(Object.entries(t).some((([t,e])=>this.#dt[t]!==e))){this._eventBus.dispatch("annotationeditorstateschanged",{source:this,details:Object.assign(this.#dt,t)});this.#at===g.HIGHLIGHT&&!1===t.hasSelectedEditor&&this.#Rt([[m.HIGHLIGHT_FREE,!0]])}}#Rt(t){this._eventBus.dispatch("annotationeditorparamschanged",{source:this,details:t})}setEditingState(t){if(t){this.#St();this.#Mt();this.#_t({isEditing:this.#at!==g.NONE,isEmpty:this.#It(),hasSomethingToUndo:this.#L.hasSomethingToUndo(),hasSomethingToRedo:this.#L.hasSomethingToRedo(),hasSelectedEditor:!1})}else{this.#Ct();this.#Pt();this.#_t({isEditing:!1});this.disableUserSelect(!1)}}registerEditorTypes(t){if(!this.#U){this.#U=t;for(const t of this.#U)this.#Rt(t.defaultPropertiesToUpdate)}}getId(){return this.#J.id}get currentLayer(){return this.#k.get(this.#B)}getLayer(t){return this.#k.get(t)}get currentPageIndex(){return this.#B}addLayer(t){this.#k.set(t.pageIndex,t);this.#Z?t.enable():t.disable()}removeLayer(t){this.#k.delete(t.pageIndex)}async updateMode(t,e=null,i=!1){if(this.#at!==t){if(this.#mt){await this.#mt.promise;if(!this.#mt)return}this.#mt=Promise.withResolvers();this.#at=t;if(t!==g.NONE){this.setEditingState(!0);await this.#Ft();this.unselectAll();for(const e of this.#k.values())e.updateMode(t);if(e){for(const t of this.#D.values())if(t.annotationElementId===e){this.setSelected(t);t.enterInEditMode()}else t.unselect();this.#mt.resolve()}else{i&&this.addNewEditorFromKeyboard();this.#mt.resolve()}}else{this.setEditingState(!1);this.#Lt();this._editorUndoBar?.hide();this.#mt.resolve()}}}addNewEditorFromKeyboard(){this.currentLayer.canCreateNewEmptyEditor()&&this.currentLayer.addNewEditor()}updateToolbar(t){t!==this.#at&&this._eventBus.dispatch("switchannotationeditormode",{source:this,mode:t})}updateParams(t,e){if(this.#U){switch(t){case m.CREATE:this.currentLayer.addNewEditor();return;case m.HIGHLIGHT_DEFAULT_COLOR:this.#st?.updateColor(e);break;case m.HIGHLIGHT_SHOW_ALL:this._eventBus.dispatch("reporttelemetry",{source:this,details:{type:"editing",data:{type:"highlight",action:"toggle_visibility"}}});(this.#ht||=new Map).set(t,e);this.showAllEditors("highlight",e)}for(const i of this.#rt)i.updateParams(t,e);for(const i of this.#U)i.updateDefaultParams(t,e)}}showAllEditors(t,e,i=!1){for(const i of this.#D.values())i.editorType===t&&i.show(e);(this.#ht?.get(m.HIGHLIGHT_SHOW_ALL)??!0)!==e&&this.#Rt([[m.HIGHLIGHT_SHOW_ALL,e]])}enableWaiting(t=!1){if(this.#tt!==t){this.#tt=t;for(const e of this.#k.values()){t?e.disableClick():e.enableClick();e.div.classList.toggle("waiting",t)}}}async#Ft(){if(!this.#Z){this.#Z=!0;const t=[];for(const e of this.#k.values())t.push(e.enable());await Promise.all(t);for(const t of this.#D.values())t.enable()}}#Lt(){this.unselectAll();if(this.#Z){this.#Z=!1;for(const t of this.#k.values())t.disable();for(const t of this.#D.values())t.disable()}}getEditors(t){const e=[];for(const i of this.#D.values())i.pageIndex===t&&e.push(i);return e}getEditor(t){return this.#D.get(t)}addEditor(t){this.#D.set(t.id,t)}removeEditor(t){if(t.div.contains(document.activeElement)){this.#q&&clearTimeout(this.#q);this.#q=setTimeout((()=>{this.focusMainContainer();this.#q=null}),0)}this.#D.delete(t.id);this.unselect(t);t.annotationElementId&&this.#H.has(t.annotationElementId)||this.#I?.remove(t.id)}addDeletedAnnotationElement(t){this.#H.add(t.annotationElementId);this.addChangedExistingAnnotation(t);t.deleted=!0}isDeletedAnnotationElement(t){return this.#H.has(t)}removeDeletedAnnotationElement(t){this.#H.delete(t.annotationElementId);this.removeChangedExistingAnnotation(t);t.deleted=!1}#Dt(t){const e=this.#k.get(t.pageIndex);if(e)e.addOrRebuild(t);else{this.addEditor(t);this.addToAnnotationStorage(t)}}setActiveEditor(t){if(this.#P!==t){this.#P=t;t&&this.#Rt(t.propertiesToUpdate)}}get#Ot(){let t=null;for(t of this.#rt);return t}updateUI(t){this.#Ot===t&&this.#Rt(t.propertiesToUpdate)}updateUIForDefaultProperties(t){this.#Rt(t.defaultPropertiesToUpdate)}toggleSelected(t){if(this.#rt.has(t)){this.#rt.delete(t);t.unselect();this.#_t({hasSelectedEditor:this.hasSelection})}else{this.#rt.add(t);t.select();this.#Rt(t.propertiesToUpdate);this.#_t({hasSelectedEditor:!0})}}setSelected(t){this.#N?.commitOrRemove();for(const e of this.#rt)e!==t&&e.unselect();this.#rt.clear();this.#rt.add(t);t.select();this.#Rt(t.propertiesToUpdate);this.#_t({hasSelectedEditor:!0})}isSelected(t){return this.#rt.has(t)}get firstSelectedEditor(){return this.#rt.values().next().value}unselect(t){t.unselect();this.#rt.delete(t);this.#_t({hasSelectedEditor:this.hasSelection})}get hasSelection(){return 0!==this.#rt.size}get isEnterHandled(){return 1===this.#rt.size&&this.firstSelectedEditor.isEnterHandled}undo(){this.#L.undo();this.#_t({hasSomethingToUndo:this.#L.hasSomethingToUndo(),hasSomethingToRedo:!0,isEmpty:this.#It()});this._editorUndoBar?.hide()}redo(){this.#L.redo();this.#_t({hasSomethingToUndo:!0,hasSomethingToRedo:this.#L.hasSomethingToRedo(),isEmpty:this.#It()})}addCommands(t){this.#L.add(t);this.#_t({hasSomethingToUndo:!0,hasSomethingToRedo:!1,isEmpty:this.#It()})}cleanUndoStack(t){this.#L.cleanType(t)}#It(){if(0===this.#D.size)return!0;if(1===this.#D.size)for(const t of this.#D.values())return t.isEmpty();return!1}delete(){this.commitOrRemove();const t=this.currentLayer?.endDrawingSession(!0);if(!this.hasSelection&&!t)return;const e=t?[t]:[...this.#rt],undo=()=>{for(const t of e)this.#Dt(t)};this.addCommands({cmd:()=>{this._editorUndoBar?.show(undo,1===e.length?e[0].editorType:e.length);for(const t of e)t.remove()},undo,mustExec:!0})}commitOrRemove(){this.#P?.commitOrRemove()}hasSomethingToControl(){return this.#P||this.hasSelection}#kt(t){for(const t of this.#rt)t.unselect();this.#rt.clear();for(const e of t)if(!e.isEmpty()){this.#rt.add(e);e.select()}this.#_t({hasSelectedEditor:this.hasSelection})}selectAll(){for(const t of this.#rt)t.commit();this.#kt(this.#D.values())}unselectAll(){if(this.#P){this.#P.commitOrRemove();if(this.#at!==g.NONE)return}if(!this.#N?.commitOrRemove()&&this.hasSelection){for(const t of this.#rt)t.unselect();this.#rt.clear();this.#_t({hasSelectedEditor:!1})}}translateSelectedEditors(t,e,i=!1){i||this.commitOrRemove();if(!this.hasSelection)return;this.#ct[0]+=t;this.#ct[1]+=e;const[s,n]=this.#ct,a=[...this.#rt];this.#ut&&clearTimeout(this.#ut);this.#ut=setTimeout((()=>{this.#ut=null;this.#ct[0]=this.#ct[1]=0;this.addCommands({cmd:()=>{for(const t of a)this.#D.has(t.id)&&t.translateInPage(s,n)},undo:()=>{for(const t of a)this.#D.has(t.id)&&t.translateInPage(-s,-n)},mustExec:!1})}),1e3);for(const i of a)i.translateInPage(t,e)}setUpDragSession(){if(this.hasSelection){this.disableUserSelect(!0);this.#z=new Map;for(const t of this.#rt)this.#z.set(t,{savedX:t.x,savedY:t.y,savedPageIndex:t.pageIndex,newX:0,newY:0,newPageIndex:-1})}}endDragSession(){if(!this.#z)return!1;this.disableUserSelect(!1);const t=this.#z;this.#z=null;let e=!1;for(const[{x:i,y:s,pageIndex:n},a]of t){a.newX=i;a.newY=s;a.newPageIndex=n;e||=i!==a.savedX||s!==a.savedY||n!==a.savedPageIndex}if(!e)return!1;const move=(t,e,i,s)=>{if(this.#D.has(t.id)){const n=this.#k.get(s);if(n)t._setParentAndPosition(n,e,i);else{t.pageIndex=s;t.x=e;t.y=i}}};this.addCommands({cmd:()=>{for(const[e,{newX:i,newY:s,newPageIndex:n}]of t)move(e,i,s,n)},undo:()=>{for(const[e,{savedX:i,savedY:s,savedPageIndex:n}]of t)move(e,i,s,n)},mustExec:!0});return!0}dragSelectedEditors(t,e){if(this.#z)for(const i of this.#z.keys())i.drag(t,e)}rebuild(t){if(null===t.parent){const e=this.getLayer(t.pageIndex);if(e){e.changeParent(t);e.addOrRebuild(t)}else{this.addEditor(t);this.addToAnnotationStorage(t);t.rebuild()}}else t.parent.addOrRebuild(t)}get isEditorHandlingKeyboard(){return this.getActive()?.shouldGetKeyboardEvents()||1===this.#rt.size&&this.firstSelectedEditor.shouldGetKeyboardEvents()}isActive(t){return this.#P===t}getActive(){return this.#P}getMode(){return this.#at}get imageManager(){return shadow(this,"imageManager",new ImageManager)}getSelectionBoxes(t){if(!t)return null;const e=document.getSelection();for(let i=0,s=e.rangeCount;i({x:(e-s)/a,y:1-(t+r-i)/n,width:o/a,height:r/n});break;case"180":r=(t,e,r,o)=>({x:1-(t+r-i)/n,y:1-(e+o-s)/a,width:r/n,height:o/a});break;case"270":r=(t,e,r,o)=>({x:1-(e+o-s)/a,y:(t-i)/n,width:o/a,height:r/n});break;default:r=(t,e,r,o)=>({x:(t-i)/n,y:(e-s)/a,width:r/n,height:o/a})}const o=[];for(let t=0,i=e.rangeCount;tt.stopPropagation()),{signal:i});const onClick=t=>{t.preventDefault();this.#a._uiManager.editAltText(this.#a);this.#Wt&&this.#a._reportTelemetry({action:"pdfjs.image.alt_text.image_status_label_clicked",data:{label:this.#Xt}})};t.addEventListener("click",onClick,{capture:!0,signal:i});t.addEventListener("keydown",(e=>{if(e.target===t&&"Enter"===e.key){this.#Gt=!0;onClick(e)}}),{signal:i});await this.#Kt();return t}get#Xt(){return(this.#o?"added":null===this.#o&&this.guessedText&&"review")||"missing"}finish(){if(this.#Bt){this.#Bt.focus({focusVisible:this.#Gt});this.#Gt=!1}}isEmpty(){return this.#Wt?null===this.#o:!this.#o&&!this.#Nt}hasData(){return this.#Wt?null!==this.#o||!!this.#Vt:this.isEmpty()}get guessedText(){return this.#Vt}async setGuessedText(t){if(null===this.#o){this.#Vt=t;this.#jt=await AltText._l10n.get("pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer",{generatedAltText:t});this.#Kt()}}toggleAltTextBadge(t=!1){if(this.#Wt&&!this.#o){if(!this.#$t){const t=this.#$t=document.createElement("div");t.className="noAltTextBadge";this.#a.div.append(t)}this.#$t.classList.toggle("hidden",!t)}else{this.#$t?.remove();this.#$t=null}}serialize(t){let e=this.#o;t||this.#Vt!==e||(e=this.#jt);return{altText:e,decorative:this.#Nt,guessedText:this.#Vt,textWithDisclaimer:this.#jt}}get data(){return{altText:this.#o,decorative:this.#Nt}}set data({altText:t,decorative:e,guessedText:i,textWithDisclaimer:s,cancel:n=!1}){if(i){this.#Vt=i;this.#jt=s}if(this.#o!==t||this.#Nt!==e){if(!n){this.#o=t;this.#Nt=e}this.#Kt()}}toggle(t=!1){if(this.#Bt){if(!t&&this.#Ut){clearTimeout(this.#Ut);this.#Ut=null}this.#Bt.disabled=!t}}shown(){this.#a._reportTelemetry({action:"pdfjs.image.alt_text.image_status_label_displayed",data:{label:this.#Xt}})}destroy(){this.#Bt?.remove();this.#Bt=null;this.#Ht=null;this.#zt=null;this.#$t?.remove();this.#$t=null}async#Kt(){const t=this.#Bt;if(!t)return;if(this.#Wt){t.classList.toggle("done",!!this.#o);t.setAttribute("data-l10n-id",AltText.#qt[this.#Xt]);this.#Ht?.setAttribute("data-l10n-id",AltText.#qt[`${this.#Xt}-label`]);if(!this.#o){this.#zt?.remove();return}}else{if(!this.#o&&!this.#Nt){t.classList.remove("done");this.#zt?.remove();return}t.classList.add("done");t.setAttribute("data-l10n-id","pdfjs-editor-alt-text-edit-button")}let e=this.#zt;if(!e){this.#zt=e=document.createElement("span");e.className="tooltip";e.setAttribute("role","tooltip");e.id=`alt-text-tooltip-${this.#a.id}`;const i=100,s=this.#a._uiManager._signal;s.addEventListener("abort",(()=>{clearTimeout(this.#Ut);this.#Ut=null}),{once:!0});t.addEventListener("mouseenter",(()=>{this.#Ut=setTimeout((()=>{this.#Ut=null;this.#zt.classList.add("show");this.#a._reportTelemetry({action:"alt_text_tooltip"})}),i)}),{signal:s});t.addEventListener("mouseleave",(()=>{if(this.#Ut){clearTimeout(this.#Ut);this.#Ut=null}this.#zt?.classList.remove("show")}),{signal:s})}if(this.#Nt)e.setAttribute("data-l10n-id","pdfjs-editor-alt-text-decorative-tooltip");else{e.removeAttribute("data-l10n-id");e.textContent=this.#o}e.parentNode||t.append(e);const i=this.#a.getImageForAltText();i?.setAttribute("aria-describedby",e.id)}}class TouchManager{#pt;#Yt=!1;#Qt=null;#Jt;#Zt;#te;#ee;#ie;#se=null;#ne;#ae=null;constructor({container:t,isPinchingDisabled:e=null,isPinchingStopped:i=null,onPinchStart:s=null,onPinching:n=null,onPinchEnd:a=null,signal:r}){this.#pt=t;this.#Qt=i;this.#Jt=e;this.#Zt=s;this.#te=n;this.#ee=a;this.#ne=new AbortController;this.#ie=AbortSignal.any([r,this.#ne.signal]);t.addEventListener("touchstart",this.#re.bind(this),{passive:!1,signal:this.#ie})}get MIN_TOUCH_DISTANCE_TO_PINCH(){return shadow(this,"MIN_TOUCH_DISTANCE_TO_PINCH",35/(window.devicePixelRatio||1))}#re(t){if(this.#Jt?.()||t.touches.length<2)return;if(!this.#ae){this.#ae=new AbortController;const t=AbortSignal.any([this.#ie,this.#ae.signal]),e=this.#pt,i={signal:t,passive:!1};e.addEventListener("touchmove",this.#oe.bind(this),i);e.addEventListener("touchend",this.#le.bind(this),i);e.addEventListener("touchcancel",this.#le.bind(this),i);this.#Zt?.()}stopEvent(t);if(2!==t.touches.length||this.#Qt?.()){this.#se=null;return}let[e,i]=t.touches;e.identifier>i.identifier&&([e,i]=[i,e]);this.#se={touch0X:e.screenX,touch0Y:e.screenY,touch1X:i.screenX,touch1Y:i.screenY}}#oe(t){if(!this.#se||2!==t.touches.length)return;let[e,i]=t.touches;e.identifier>i.identifier&&([e,i]=[i,e]);const{screenX:s,screenY:n}=e,{screenX:a,screenY:r}=i,o=this.#se,{touch0X:l,touch0Y:h,touch1X:d,touch1Y:c}=o,u=d-l,p=c-h,g=a-s,m=r-n,f=Math.hypot(g,m)||1,b=Math.hypot(u,p)||1;if(!this.#Yt&&Math.abs(b-f)<=TouchManager.MIN_TOUCH_DISTANCE_TO_PINCH)return;o.touch0X=s;o.touch0Y=n;o.touch1X=a;o.touch1Y=r;t.preventDefault();if(!this.#Yt){this.#Yt=!0;return}const A=[(s+a)/2,(n+r)/2];this.#te?.(A,b,f)}#le(t){this.#ae.abort();this.#ae=null;this.#ee?.();if(this.#se){t.preventDefault();this.#se=null;this.#Yt=!1}}destroy(){this.#ne?.abort();this.#ne=null}}class AnnotationEditor{#he=null;#de=null;#o=null;#ce=!1;#ue=null;#pe="";#ge=!1;#me=null;#fe=null;#be=null;#Ae=null;#we="";#ve=!1;#ye=null;#xe=!1;#_e=!1;#Ee=!1;#Se=null;#Ce=0;#Te=0;#Me=null;#Pe=null;_editToolbar=null;_initialOptions=Object.create(null);_initialData=null;_isVisible=!0;_uiManager=null;_focusEventsAllowed=!0;static _l10n=null;static _l10nResizer=null;#De=!1;#ke=AnnotationEditor._zIndex++;static _borderLineWidth=-1;static _colorManager=new ColorManager;static _zIndex=1;static _telemetryTimeout=1e3;static get _resizerKeyboardManager(){const t=AnnotationEditor.prototype._resizeWithKeyboard,e=AnnotationEditorUIManager.TRANSLATE_SMALL,i=AnnotationEditorUIManager.TRANSLATE_BIG;return shadow(this,"_resizerKeyboardManager",new KeyboardManager([[["ArrowLeft","mac+ArrowLeft"],t,{args:[-e,0]}],[["ctrl+ArrowLeft","mac+shift+ArrowLeft"],t,{args:[-i,0]}],[["ArrowRight","mac+ArrowRight"],t,{args:[e,0]}],[["ctrl+ArrowRight","mac+shift+ArrowRight"],t,{args:[i,0]}],[["ArrowUp","mac+ArrowUp"],t,{args:[0,-e]}],[["ctrl+ArrowUp","mac+shift+ArrowUp"],t,{args:[0,-i]}],[["ArrowDown","mac+ArrowDown"],t,{args:[0,e]}],[["ctrl+ArrowDown","mac+shift+ArrowDown"],t,{args:[0,i]}],[["Escape","mac+Escape"],AnnotationEditor.prototype._stopResizingWithKeyboard]]))}constructor(t){this.parent=t.parent;this.id=t.id;this.width=this.height=null;this.pageIndex=t.parent.pageIndex;this.name=t.name;this.div=null;this._uiManager=t.uiManager;this.annotationElementId=null;this._willKeepAspectRatio=!1;this._initialOptions.isCentered=t.isCentered;this._structTreeParentId=null;const{rotation:e,rawDims:{pageWidth:i,pageHeight:s,pageX:n,pageY:a}}=this.parent.viewport;this.rotation=e;this.pageRotation=(360+e-this._uiManager.viewParameters.rotation)%360;this.pageDimensions=[i,s];this.pageTranslation=[n,a];const[r,o]=this.parentDimensions;this.x=t.x/r;this.y=t.y/o;this.isAttachedToDOM=!1;this.deleted=!1}get editorType(){return Object.getPrototypeOf(this).constructor._type}static get isDrawer(){return!1}static get _defaultLineColor(){return shadow(this,"_defaultLineColor",this._colorManager.getHexCode("CanvasText"))}static deleteAnnotationElement(t){const e=new FakeEditor({id:t.parent.getNextId(),parent:t.parent,uiManager:t._uiManager});e.annotationElementId=t.annotationElementId;e.deleted=!0;e._uiManager.addToAnnotationStorage(e)}static initialize(t,e){AnnotationEditor._l10n??=t;AnnotationEditor._l10nResizer||=Object.freeze({topLeft:"pdfjs-editor-resizer-top-left",topMiddle:"pdfjs-editor-resizer-top-middle",topRight:"pdfjs-editor-resizer-top-right",middleRight:"pdfjs-editor-resizer-middle-right",bottomRight:"pdfjs-editor-resizer-bottom-right",bottomMiddle:"pdfjs-editor-resizer-bottom-middle",bottomLeft:"pdfjs-editor-resizer-bottom-left",middleLeft:"pdfjs-editor-resizer-middle-left"});if(-1!==AnnotationEditor._borderLineWidth)return;const i=getComputedStyle(document.documentElement);AnnotationEditor._borderLineWidth=parseFloat(i.getPropertyValue("--outline-width"))||0}static updateDefaultParams(t,e){}static get defaultPropertiesToUpdate(){return[]}static isHandlingMimeForPasting(t){return!1}static paste(t,e){unreachable("Not implemented")}get propertiesToUpdate(){return[]}get _isDraggable(){return this.#De}set _isDraggable(t){this.#De=t;this.div?.classList.toggle("draggable",t)}get isEnterHandled(){return!0}center(){const[t,e]=this.pageDimensions;switch(this.parentRotation){case 90:this.x-=this.height*e/(2*t);this.y+=this.width*t/(2*e);break;case 180:this.x+=this.width/2;this.y+=this.height/2;break;case 270:this.x+=this.height*e/(2*t);this.y-=this.width*t/(2*e);break;default:this.x-=this.width/2;this.y-=this.height/2}this.fixAndSetPosition()}addCommands(t){this._uiManager.addCommands(t)}get currentLayer(){return this._uiManager.currentLayer}setInBackground(){this.div.style.zIndex=0}setInForeground(){this.div.style.zIndex=this.#ke}setParent(t){if(null!==t){this.pageIndex=t.pageIndex;this.pageDimensions=t.pageDimensions}else this.#Re();this.parent=t}focusin(t){this._focusEventsAllowed&&(this.#ve?this.#ve=!1:this.parent.setSelected(this))}focusout(t){if(!this._focusEventsAllowed)return;if(!this.isAttachedToDOM)return;const e=t.relatedTarget;if(!e?.closest(`#${this.id}`)){t.preventDefault();this.parent?.isMultipleSelection||this.commitOrRemove()}}commitOrRemove(){this.isEmpty()?this.remove():this.commit()}commit(){this.addToAnnotationStorage()}addToAnnotationStorage(){this._uiManager.addToAnnotationStorage(this)}setAt(t,e,i,s){const[n,a]=this.parentDimensions;[i,s]=this.screenToPageTranslation(i,s);this.x=(t+i)/n;this.y=(e+s)/a;this.fixAndSetPosition()}#Ie([t,e],i,s){[i,s]=this.screenToPageTranslation(i,s);this.x+=i/t;this.y+=s/e;this._onTranslating(this.x,this.y);this.fixAndSetPosition()}translate(t,e){this.#Ie(this.parentDimensions,t,e)}translateInPage(t,e){this.#ye||=[this.x,this.y,this.width,this.height];this.#Ie(this.pageDimensions,t,e);this.div.scrollIntoView({block:"nearest"})}drag(t,e){this.#ye||=[this.x,this.y,this.width,this.height];const{div:i,parentDimensions:[s,n]}=this;this.x+=t/s;this.y+=e/n;if(this.parent&&(this.x<0||this.x>1||this.y<0||this.y>1)){const{x:t,y:e}=this.div.getBoundingClientRect();if(this.parent.findNewParent(this,t,e)){this.x-=Math.floor(this.x);this.y-=Math.floor(this.y)}}let{x:a,y:r}=this;const[o,l]=this.getBaseTranslation();a+=o;r+=l;const{style:h}=i;h.left=`${(100*a).toFixed(2)}%`;h.top=`${(100*r).toFixed(2)}%`;this._onTranslating(a,r);i.scrollIntoView({block:"nearest"})}_onTranslating(t,e){}_onTranslated(t,e){}get _hasBeenMoved(){return!!this.#ye&&(this.#ye[0]!==this.x||this.#ye[1]!==this.y)}get _hasBeenResized(){return!!this.#ye&&(this.#ye[2]!==this.width||this.#ye[3]!==this.height)}getBaseTranslation(){const[t,e]=this.parentDimensions,{_borderLineWidth:i}=AnnotationEditor,s=i/t,n=i/e;switch(this.rotation){case 90:return[-s,n];case 180:return[s,n];case 270:return[s,-n];default:return[-s,-n]}}get _mustFixPosition(){return!0}fixAndSetPosition(t=this.rotation){const{div:{style:e},pageDimensions:[i,s]}=this;let{x:n,y:a,width:r,height:o}=this;r*=i;o*=s;n*=i;a*=s;if(this._mustFixPosition)switch(t){case 0:n=Math.max(0,Math.min(i-r,n));a=Math.max(0,Math.min(s-o,a));break;case 90:n=Math.max(0,Math.min(i-o,n));a=Math.min(s,Math.max(r,a));break;case 180:n=Math.min(i,Math.max(r,n));a=Math.min(s,Math.max(o,a));break;case 270:n=Math.min(i,Math.max(o,n));a=Math.max(0,Math.min(s-r,a))}this.x=n/=i;this.y=a/=s;const[l,h]=this.getBaseTranslation();n+=l;a+=h;e.left=`${(100*n).toFixed(2)}%`;e.top=`${(100*a).toFixed(2)}%`;this.moveInDOM()}static#Fe(t,e,i){switch(i){case 90:return[e,-t];case 180:return[-t,-e];case 270:return[-e,t];default:return[t,e]}}screenToPageTranslation(t,e){return AnnotationEditor.#Fe(t,e,this.parentRotation)}pageTranslationToScreen(t,e){return AnnotationEditor.#Fe(t,e,360-this.parentRotation)}#Le(t){switch(t){case 90:{const[t,e]=this.pageDimensions;return[0,-t/e,e/t,0]}case 180:return[-1,0,0,-1];case 270:{const[t,e]=this.pageDimensions;return[0,t/e,-e/t,0]}default:return[1,0,0,1]}}get parentScale(){return this._uiManager.viewParameters.realScale}get parentRotation(){return(this._uiManager.viewParameters.rotation+this.pageRotation)%360}get parentDimensions(){const{parentScale:t,pageDimensions:[e,i]}=this;return[e*t,i*t]}setDims(t,e){const[i,s]=this.parentDimensions,{style:n}=this.div;n.width=`${(100*t/i).toFixed(2)}%`;this.#ge||(n.height=`${(100*e/s).toFixed(2)}%`)}fixDims(){const{style:t}=this.div,{height:e,width:i}=t,s=i.endsWith("%"),n=!this.#ge&&e.endsWith("%");if(s&&n)return;const[a,r]=this.parentDimensions;s||(t.width=`${(100*parseFloat(i)/a).toFixed(2)}%`);this.#ge||n||(t.height=`${(100*parseFloat(e)/r).toFixed(2)}%`)}getInitialTranslation(){return[0,0]}#Oe(){if(this.#me)return;this.#me=document.createElement("div");this.#me.classList.add("resizers");const t=this._willKeepAspectRatio?["topLeft","topRight","bottomRight","bottomLeft"]:["topLeft","topMiddle","topRight","middleRight","bottomRight","bottomMiddle","bottomLeft","middleLeft"],e=this._uiManager._signal;for(const i of t){const t=document.createElement("div");this.#me.append(t);t.classList.add("resizer",i);t.setAttribute("data-resizer-name",i);t.addEventListener("pointerdown",this.#Ne.bind(this,i),{signal:e});t.addEventListener("contextmenu",noContextMenu,{signal:e});t.tabIndex=-1}this.div.prepend(this.#me)}#Ne(t,e){e.preventDefault();const{isMac:i}=util_FeatureTest.platform;if(0!==e.button||e.ctrlKey&&i)return;this.#o?.toggle(!1);const s=this._isDraggable;this._isDraggable=!1;this.#fe=[e.screenX,e.screenY];const n=new AbortController,a=this._uiManager.combinedSignal(n);this.parent.togglePointerEvents(!1);window.addEventListener("pointermove",this.#Be.bind(this,t),{passive:!0,capture:!0,signal:a});window.addEventListener("touchmove",stopEvent,{passive:!1,signal:a});window.addEventListener("contextmenu",noContextMenu,{signal:a});this.#be={savedX:this.x,savedY:this.y,savedWidth:this.width,savedHeight:this.height};const r=this.parent.div.style.cursor,o=this.div.style.cursor;this.div.style.cursor=this.parent.div.style.cursor=window.getComputedStyle(e.target).cursor;const pointerUpCallback=()=>{n.abort();this.parent.togglePointerEvents(!0);this.#o?.toggle(!0);this._isDraggable=s;this.parent.div.style.cursor=r;this.div.style.cursor=o;this.#He()};window.addEventListener("pointerup",pointerUpCallback,{signal:a});window.addEventListener("blur",pointerUpCallback,{signal:a})}#ze(t,e,i,s){this.width=i;this.height=s;this.x=t;this.y=e;const[n,a]=this.parentDimensions;this.setDims(n*i,a*s);this.fixAndSetPosition();this._onResized()}_onResized(){}#He(){if(!this.#be)return;const{savedX:t,savedY:e,savedWidth:i,savedHeight:s}=this.#be;this.#be=null;const n=this.x,a=this.y,r=this.width,o=this.height;n===t&&a===e&&r===i&&o===s||this.addCommands({cmd:this.#ze.bind(this,n,a,r,o),undo:this.#ze.bind(this,t,e,i,s),mustExec:!0})}static _round(t){return Math.round(1e4*t)/1e4}#Be(t,e){const[i,s]=this.parentDimensions,n=this.x,a=this.y,r=this.width,o=this.height,l=AnnotationEditor.MIN_SIZE/i,h=AnnotationEditor.MIN_SIZE/s,d=this.#Le(this.rotation),transf=(t,e)=>[d[0]*t+d[2]*e,d[1]*t+d[3]*e],c=this.#Le(360-this.rotation);let u,p,g=!1,m=!1;switch(t){case"topLeft":g=!0;u=(t,e)=>[0,0];p=(t,e)=>[t,e];break;case"topMiddle":u=(t,e)=>[t/2,0];p=(t,e)=>[t/2,e];break;case"topRight":g=!0;u=(t,e)=>[t,0];p=(t,e)=>[0,e];break;case"middleRight":m=!0;u=(t,e)=>[t,e/2];p=(t,e)=>[0,e/2];break;case"bottomRight":g=!0;u=(t,e)=>[t,e];p=(t,e)=>[0,0];break;case"bottomMiddle":u=(t,e)=>[t/2,e];p=(t,e)=>[t/2,0];break;case"bottomLeft":g=!0;u=(t,e)=>[0,e];p=(t,e)=>[t,0];break;case"middleLeft":m=!0;u=(t,e)=>[0,e/2];p=(t,e)=>[t,e/2]}const f=u(r,o),b=p(r,o);let A=transf(...b);const w=AnnotationEditor._round(n+A[0]),v=AnnotationEditor._round(a+A[1]);let y,x,_=1,E=1;if(e.fromKeyboard)({deltaX:y,deltaY:x}=e);else{const{screenX:t,screenY:i}=e,[s,n]=this.#fe;[y,x]=this.screenToPageTranslation(t-s,i-n);this.#fe[0]=t;this.#fe[1]=i}[y,x]=(S=y/i,C=x/s,[c[0]*S+c[2]*C,c[1]*S+c[3]*C]);var S,C;if(g){const t=Math.hypot(r,o);_=E=Math.max(Math.min(Math.hypot(b[0]-f[0]-y,b[1]-f[1]-x)/t,1/r,1/o),l/r,h/o)}else m?_=Math.max(l,Math.min(1,Math.abs(b[0]-f[0]-y)))/r:E=Math.max(h,Math.min(1,Math.abs(b[1]-f[1]-x)))/o;const T=AnnotationEditor._round(r*_),M=AnnotationEditor._round(o*E);A=transf(...p(T,M));const P=w-A[0],D=v-A[1];this.#ye||=[this.x,this.y,this.width,this.height];this.width=T;this.height=M;this.x=P;this.y=D;this.setDims(i*T,s*M);this.fixAndSetPosition();this._onResizing()}_onResizing(){}altTextFinish(){this.#o?.finish()}async addEditToolbar(){if(this._editToolbar||this.#_e)return this._editToolbar;this._editToolbar=new EditorToolbar(this);this.div.append(this._editToolbar.render());this.#o&&await this._editToolbar.addAltText(this.#o);return this._editToolbar}removeEditToolbar(){if(this._editToolbar){this._editToolbar.remove();this._editToolbar=null;this.#o?.destroy()}}addContainer(t){const e=this._editToolbar?.div;e?e.before(t):this.div.append(t)}getClientDimensions(){return this.div.getBoundingClientRect()}async addAltTextButton(){if(!this.#o){AltText.initialize(AnnotationEditor._l10n);this.#o=new AltText(this);if(this.#he){this.#o.data=this.#he;this.#he=null}await this.addEditToolbar()}}get altTextData(){return this.#o?.data}set altTextData(t){this.#o&&(this.#o.data=t)}get guessedAltText(){return this.#o?.guessedText}async setGuessedAltText(t){await(this.#o?.setGuessedText(t))}serializeAltText(t){return this.#o?.serialize(t)}hasAltText(){return!!this.#o&&!this.#o.isEmpty()}hasAltTextData(){return this.#o?.hasData()??!1}render(){this.div=document.createElement("div");this.div.setAttribute("data-editor-rotation",(360-this.rotation)%360);this.div.className=this.name;this.div.setAttribute("id",this.id);this.div.tabIndex=this.#ce?-1:0;this._isVisible||this.div.classList.add("hidden");this.setInForeground();this.#Ue();const[t,e]=this.parentDimensions;if(this.parentRotation%180!=0){this.div.style.maxWidth=`${(100*e/t).toFixed(2)}%`;this.div.style.maxHeight=`${(100*t/e).toFixed(2)}%`}const[i,s]=this.getInitialTranslation();this.translate(i,s);bindEvents(this,this.div,["pointerdown"]);this.isResizable&&this._uiManager._supportsPinchToZoom&&(this.#Pe||=new TouchManager({container:this.div,isPinchingDisabled:()=>!this.isSelected,onPinchStart:this.#Ge.bind(this),onPinching:this.#$e.bind(this),onPinchEnd:this.#Ve.bind(this),signal:this._uiManager._signal}));this._uiManager._editorUndoBar?.hide();return this.div}#Ge(){this.#be={savedX:this.x,savedY:this.y,savedWidth:this.width,savedHeight:this.height};this.#o?.toggle(!1);this.parent.togglePointerEvents(!1)}#$e(t,e,i){let s=i/e*.7+1-.7;if(1===s)return;const n=this.#Le(this.rotation),transf=(t,e)=>[n[0]*t+n[2]*e,n[1]*t+n[3]*e],[a,r]=this.parentDimensions,o=this.x,l=this.y,h=this.width,d=this.height,c=AnnotationEditor.MIN_SIZE/a,u=AnnotationEditor.MIN_SIZE/r;s=Math.max(Math.min(s,1/h,1/d),c/h,u/d);const p=AnnotationEditor._round(h*s),g=AnnotationEditor._round(d*s);if(p===h&&g===d)return;this.#ye||=[o,l,h,d];const m=transf(h/2,d/2),f=AnnotationEditor._round(o+m[0]),b=AnnotationEditor._round(l+m[1]),A=transf(p/2,g/2);this.x=f-A[0];this.y=b-A[1];this.width=p;this.height=g;this.setDims(a*p,r*g);this.fixAndSetPosition();this._onResizing()}#Ve(){this.#o?.toggle(!0);this.parent.togglePointerEvents(!0);this.#He()}pointerdown(t){const{isMac:e}=util_FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)t.preventDefault();else{this.#ve=!0;this._isDraggable?this.#je(t):this.#We(t)}}get isSelected(){return this._uiManager.isSelected(this)}#We(t){const{isMac:e}=util_FeatureTest.platform;t.ctrlKey&&!e||t.shiftKey||t.metaKey&&e?this.parent.toggleSelected(this):this.parent.setSelected(this)}#je(t){const{isSelected:e}=this;this._uiManager.setUpDragSession();let i=!1;const s=new AbortController,n=this._uiManager.combinedSignal(s),a={capture:!0,passive:!1,signal:n},cancelDrag=t=>{s.abort();this.#ue=null;this.#ve=!1;this._uiManager.endDragSession()||this.#We(t);i&&this._onStopDragging()};if(e){this.#Ce=t.clientX;this.#Te=t.clientY;this.#ue=t.pointerId;this.#pe=t.pointerType;window.addEventListener("pointermove",(t=>{if(!i){i=!0;this._onStartDragging()}const{clientX:e,clientY:s,pointerId:n}=t;if(n!==this.#ue){stopEvent(t);return}const[a,r]=this.screenToPageTranslation(e-this.#Ce,s-this.#Te);this.#Ce=e;this.#Te=s;this._uiManager.dragSelectedEditors(a,r)}),a);window.addEventListener("touchmove",stopEvent,a);window.addEventListener("pointerdown",(t=>{t.pointerType===this.#pe&&(this.#Pe||t.isPrimary)&&cancelDrag(t);stopEvent(t)}),a)}const pointerUpCallback=t=>{this.#ue&&this.#ue!==t.pointerId?stopEvent(t):cancelDrag(t)};window.addEventListener("pointerup",pointerUpCallback,{signal:n});window.addEventListener("blur",pointerUpCallback,{signal:n})}_onStartDragging(){}_onStopDragging(){}moveInDOM(){this.#Se&&clearTimeout(this.#Se);this.#Se=setTimeout((()=>{this.#Se=null;this.parent?.moveEditorInDOM(this)}),0)}_setParentAndPosition(t,e,i){t.changeParent(this);this.x=e;this.y=i;this.fixAndSetPosition();this._onTranslated()}getRect(t,e,i=this.rotation){const s=this.parentScale,[n,a]=this.pageDimensions,[r,o]=this.pageTranslation,l=t/s,h=e/s,d=this.x*n,c=this.y*a,u=this.width*n,p=this.height*a;switch(i){case 0:return[d+l+r,a-c-h-p+o,d+l+u+r,a-c-h+o];case 90:return[d+h+r,a-c+l+o,d+h+p+r,a-c+l+u+o];case 180:return[d-l-u+r,a-c+h+o,d-l+r,a-c+h+p+o];case 270:return[d-h-p+r,a-c-l-u+o,d-h+r,a-c-l+o];default:throw new Error("Invalid rotation")}}getRectInCurrentCoords(t,e){const[i,s,n,a]=t,r=n-i,o=a-s;switch(this.rotation){case 0:return[i,e-a,r,o];case 90:return[i,e-s,o,r];case 180:return[n,e-s,r,o];case 270:return[n,e-a,o,r];default:throw new Error("Invalid rotation")}}onceAdded(t){}isEmpty(){return!1}enableEditMode(){this.#_e=!0}disableEditMode(){this.#_e=!1}isInEditMode(){return this.#_e}shouldGetKeyboardEvents(){return this.#Ee}needsToBeRebuilt(){return this.div&&!this.isAttachedToDOM}get isOnScreen(){const{top:t,left:e,bottom:i,right:s}=this.getClientDimensions(),{innerHeight:n,innerWidth:a}=window;return e0&&t0}#Ue(){if(this.#Ae||!this.div)return;this.#Ae=new AbortController;const t=this._uiManager.combinedSignal(this.#Ae);this.div.addEventListener("focusin",this.focusin.bind(this),{signal:t});this.div.addEventListener("focusout",this.focusout.bind(this),{signal:t})}rebuild(){this.#Ue()}rotate(t){}resize(){}serializeDeleted(){return{id:this.annotationElementId,deleted:!0,pageIndex:this.pageIndex,popupRef:this._initialData?.popupRef||""}}serialize(t=!1,e=null){unreachable("An editor must be serializable")}static async deserialize(t,e,i){const s=new this.prototype.constructor({parent:e,id:e.getNextId(),uiManager:i});s.rotation=t.rotation;s.#he=t.accessibilityData;const[n,a]=s.pageDimensions,[r,o,l,h]=s.getRectInCurrentCoords(t.rect,a);s.x=r/n;s.y=o/a;s.width=l/n;s.height=h/a;return s}get hasBeenModified(){return!!this.annotationElementId&&(this.deleted||null!==this.serialize())}remove(){this.#Ae?.abort();this.#Ae=null;this.isEmpty()||this.commit();this.parent?this.parent.remove(this):this._uiManager.removeEditor(this);if(this.#Se){clearTimeout(this.#Se);this.#Se=null}this.#Re();this.removeEditToolbar();if(this.#Me){for(const t of this.#Me.values())clearTimeout(t);this.#Me=null}this.parent=null;this.#Pe?.destroy();this.#Pe=null}get isResizable(){return!1}makeResizable(){if(this.isResizable){this.#Oe();this.#me.classList.remove("hidden");bindEvents(this,this.div,["keydown"])}}get toolbarPosition(){return null}keydown(t){if(!this.isResizable||t.target!==this.div||"Enter"!==t.key)return;this._uiManager.setSelected(this);this.#be={savedX:this.x,savedY:this.y,savedWidth:this.width,savedHeight:this.height};const e=this.#me.children;if(!this.#de){this.#de=Array.from(e);const t=this.#qe.bind(this),i=this.#Xe.bind(this),s=this._uiManager._signal;for(const e of this.#de){const n=e.getAttribute("data-resizer-name");e.setAttribute("role","spinbutton");e.addEventListener("keydown",t,{signal:s});e.addEventListener("blur",i,{signal:s});e.addEventListener("focus",this.#Ke.bind(this,n),{signal:s});e.setAttribute("data-l10n-id",AnnotationEditor._l10nResizer[n])}}const i=this.#de[0];let s=0;for(const t of e){if(t===i)break;s++}const n=(360-this.rotation+this.parentRotation)%360/90*(this.#de.length/4);if(n!==s){if(ns)for(let t=0;t{this.div?.classList.contains("selectedEditor")&&this._editToolbar?.show()}))}unselect(){this.#me?.classList.add("hidden");this.div?.classList.remove("selectedEditor");this.div?.contains(document.activeElement)&&this._uiManager.currentLayer.div.focus({preventScroll:!0});this._editToolbar?.hide();this.#o?.toggleAltTextBadge(!0)}updateParams(t,e){}disableEditing(){}enableEditing(){}enterInEditMode(){}getImageForAltText(){return null}get contentDiv(){return this.div}get isEditing(){return this.#xe}set isEditing(t){this.#xe=t;if(this.parent)if(t){this.parent.setSelected(this);this.parent.setActiveEditor(this)}else this.parent.setActiveEditor(null)}setAspectRatio(t,e){this.#ge=!0;const i=t/e,{style:s}=this.div;s.aspectRatio=i;s.height="auto"}static get MIN_SIZE(){return 16}static canCreateNewEmptyEditor(){return!0}get telemetryInitialData(){return{action:"added"}}get telemetryFinalData(){return null}_reportTelemetry(t,e=!1){if(e){this.#Me||=new Map;const{action:e}=t;let i=this.#Me.get(e);i&&clearTimeout(i);i=setTimeout((()=>{this._reportTelemetry(t);this.#Me.delete(e);0===this.#Me.size&&(this.#Me=null)}),AnnotationEditor._telemetryTimeout);this.#Me.set(e,i)}else{t.type||=this.editorType;this._uiManager._eventBus.dispatch("reporttelemetry",{source:this,details:{type:"editing",data:t}})}}show(t=this._isVisible){this.div.classList.toggle("hidden",!t);this._isVisible=t}enable(){this.div&&(this.div.tabIndex=0);this.#ce=!1}disable(){this.div&&(this.div.tabIndex=-1);this.#ce=!0}renderAnnotationElement(t){let e=t.container.querySelector(".annotationContent");if(e){if("CANVAS"===e.nodeName){const t=e;e=document.createElement("div");e.classList.add("annotationContent",this.editorType);t.before(e)}}else{e=document.createElement("div");e.classList.add("annotationContent",this.editorType);t.container.prepend(e)}return e}resetAnnotationElement(t){const{firstChild:e}=t.container;"DIV"===e?.nodeName&&e.classList.contains("annotationContent")&&e.remove()}}class FakeEditor extends AnnotationEditor{constructor(t){super(t);this.annotationElementId=t.annotationElementId;this.deleted=!0}serialize(){return this.serializeDeleted()}}const st=3285377520,nt=4294901760,at=65535;class MurmurHash3_64{constructor(t){this.h1=t?4294967295&t:st;this.h2=t?4294967295&t:st}update(t){let e,i;if("string"==typeof t){e=new Uint8Array(2*t.length);i=0;for(let s=0,n=t.length;s>>8;e[i++]=255&n}}}else{if(!ArrayBuffer.isView(t))throw new Error("Invalid data format, must be a string or TypedArray.");e=t.slice();i=e.byteLength}const s=i>>2,n=i-4*s,a=new Uint32Array(e.buffer,0,s);let r=0,o=0,l=this.h1,h=this.h2;const d=3432918353,c=461845907,u=11601,p=13715;for(let t=0;t>>17;r=r*c&nt|r*p&at;l^=r;l=l<<13|l>>>19;l=5*l+3864292196}else{o=a[t];o=o*d&nt|o*u&at;o=o<<15|o>>>17;o=o*c&nt|o*p&at;h^=o;h=h<<13|h>>>19;h=5*h+3864292196}r=0;switch(n){case 3:r^=e[4*s+2]<<16;case 2:r^=e[4*s+1]<<8;case 1:r^=e[4*s];r=r*d&nt|r*u&at;r=r<<15|r>>>17;r=r*c&nt|r*p&at;1&s?l^=r:h^=r}this.h1=l;this.h2=h}hexdigest(){let t=this.h1,e=this.h2;t^=e>>>1;t=3981806797*t&nt|36045*t&at;e=4283543511*e&nt|(2950163797*(e<<16|t>>>16)&nt)>>>16;t^=e>>>1;t=444984403*t&nt|60499*t&at;e=3301882366*e&nt|(3120437893*(e<<16|t>>>16)&nt)>>>16;t^=e>>>1;return(t>>>0).toString(16).padStart(8,"0")+(e>>>0).toString(16).padStart(8,"0")}}const rt=Object.freeze({map:null,hash:"",transfer:void 0});class AnnotationStorage{#Qe=!1;#Je=null;#Ze=new Map;constructor(){this.onSetModified=null;this.onResetModified=null;this.onAnnotationEditor=null}getValue(t,e){const i=this.#Ze.get(t);return void 0===i?e:Object.assign(e,i)}getRawValue(t){return this.#Ze.get(t)}remove(t){this.#Ze.delete(t);0===this.#Ze.size&&this.resetModified();if("function"==typeof this.onAnnotationEditor){for(const t of this.#Ze.values())if(t instanceof AnnotationEditor)return;this.onAnnotationEditor(null)}}setValue(t,e){const i=this.#Ze.get(t);let s=!1;if(void 0!==i){for(const[t,n]of Object.entries(e))if(i[t]!==n){s=!0;i[t]=n}}else{s=!0;this.#Ze.set(t,e)}s&&this.#ti();e instanceof AnnotationEditor&&"function"==typeof this.onAnnotationEditor&&this.onAnnotationEditor(e.constructor._type)}has(t){return this.#Ze.has(t)}getAll(){return this.#Ze.size>0?objectFromMap(this.#Ze):null}setAll(t){for(const[e,i]of Object.entries(t))this.setValue(e,i)}get size(){return this.#Ze.size}#ti(){if(!this.#Qe){this.#Qe=!0;"function"==typeof this.onSetModified&&this.onSetModified()}}resetModified(){if(this.#Qe){this.#Qe=!1;"function"==typeof this.onResetModified&&this.onResetModified()}}get print(){return new PrintAnnotationStorage(this)}get serializable(){if(0===this.#Ze.size)return rt;const t=new Map,e=new MurmurHash3_64,i=[],s=Object.create(null);let n=!1;for(const[i,a]of this.#Ze){const r=a instanceof AnnotationEditor?a.serialize(!1,s):a;if(r){t.set(i,r);e.update(`${i}:${JSON.stringify(r)}`);n||=!!r.bitmap}}if(n)for(const e of t.values())e.bitmap&&i.push(e.bitmap);return t.size>0?{map:t,hash:e.hexdigest(),transfer:i}:rt}get editorStats(){let t=null;const e=new Map;for(const i of this.#Ze.values()){if(!(i instanceof AnnotationEditor))continue;const s=i.telemetryFinalData;if(!s)continue;const{type:n}=s;e.has(n)||e.set(n,Object.getPrototypeOf(i).constructor);t||=Object.create(null);const a=t[n]||=new Map;for(const[t,e]of Object.entries(s)){if("type"===t)continue;let i=a.get(t);if(!i){i=new Map;a.set(t,i)}const s=i.get(e)??0;i.set(e,s+1)}}for(const[i,s]of e)t[i]=s.computeTelemetryFinalData(t[i]);return t}resetModifiedIds(){this.#Je=null}get modifiedIds(){if(this.#Je)return this.#Je;const t=[];for(const e of this.#Ze.values())e instanceof AnnotationEditor&&e.annotationElementId&&e.serialize()&&t.push(e.annotationElementId);return this.#Je={ids:new Set(t),hash:t.join(",")}}}class PrintAnnotationStorage extends AnnotationStorage{#ei;constructor(t){super();const{map:e,hash:i,transfer:s}=t.serializable,n=structuredClone(e,s?{transfer:s}:null);this.#ei={map:n,hash:i,transfer:s}}get print(){unreachable("Should not call PrintAnnotationStorage.print")}get serializable(){return this.#ei}get modifiedIds(){return shadow(this,"modifiedIds",{ids:new Set,hash:""})}}class FontLoader{#ii=new Set;constructor({ownerDocument:t=globalThis.document,styleElement:e=null}){this._document=t;this.nativeFontFaces=new Set;this.styleElement=null;this.loadingRequests=[];this.loadTestFontId=0}addNativeFontFace(t){this.nativeFontFaces.add(t);this._document.fonts.add(t)}removeNativeFontFace(t){this.nativeFontFaces.delete(t);this._document.fonts.delete(t)}insertRule(t){if(!this.styleElement){this.styleElement=this._document.createElement("style");this._document.documentElement.getElementsByTagName("head")[0].append(this.styleElement)}const e=this.styleElement.sheet;e.insertRule(t,e.cssRules.length)}clear(){for(const t of this.nativeFontFaces)this._document.fonts.delete(t);this.nativeFontFaces.clear();this.#ii.clear();if(this.styleElement){this.styleElement.remove();this.styleElement=null}}async loadSystemFont({systemFontInfo:t,_inspectFont:e}){if(t&&!this.#ii.has(t.loadedName)){assert(!this.disableFontFace,"loadSystemFont shouldn't be called when `disableFontFace` is set.");if(this.isFontLoadingAPISupported){const{loadedName:i,src:s,style:n}=t,a=new FontFace(i,s,n);this.addNativeFontFace(a);try{await a.load();this.#ii.add(i);e?.(t)}catch{warn(`Cannot load system font: ${t.baseFontName}, installing it could help to improve PDF rendering.`);this.removeNativeFontFace(a)}}else unreachable("Not implemented: loadSystemFont without the Font Loading API.")}}async bind(t){if(t.attached||t.missingFile&&!t.systemFontInfo)return;t.attached=!0;if(t.systemFontInfo){await this.loadSystemFont(t);return}if(this.isFontLoadingAPISupported){const e=t.createNativeFontFace();if(e){this.addNativeFontFace(e);try{await e.loaded}catch(i){warn(`Failed to load font '${e.family}': '${i}'.`);t.disableFontFace=!0;throw i}}return}const e=t.createFontFaceRule();if(e){this.insertRule(e);if(this.isSyncFontLoadingSupported)return;await new Promise((e=>{const i=this._queueLoadingCallback(e);this._prepareFontLoadEvent(t,i)}))}}get isFontLoadingAPISupported(){return shadow(this,"isFontLoadingAPISupported",!!this._document?.fonts)}get isSyncFontLoadingSupported(){let t=!1;(e||"undefined"!=typeof navigator&&"string"==typeof navigator?.userAgent&&/Mozilla\/5.0.*?rv:\d+.*? Gecko/.test(navigator.userAgent))&&(t=!0);return shadow(this,"isSyncFontLoadingSupported",t)}_queueLoadingCallback(t){const{loadingRequests:e}=this,i={done:!1,complete:function completeRequest(){assert(!i.done,"completeRequest() cannot be called twice.");i.done=!0;for(;e.length>0&&e[0].done;){const t=e.shift();setTimeout(t.callback,0)}},callback:t};e.push(i);return i}get _loadTestFont(){return shadow(this,"_loadTestFont",atob("T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQAABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwAAAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbmFtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAAAADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6AABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAAMQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAAAAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAAAAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQAAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMAAQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAAEAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgcA/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQAAAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAgABAAAAAAAAAAAD6AAAAAAAAA=="))}_prepareFontLoadEvent(t,e){function int32(t,e){return t.charCodeAt(e)<<24|t.charCodeAt(e+1)<<16|t.charCodeAt(e+2)<<8|255&t.charCodeAt(e+3)}function spliceString(t,e,i,s){return t.substring(0,e)+s+t.substring(e+i)}let i,s;const n=this._document.createElement("canvas");n.width=1;n.height=1;const a=n.getContext("2d");let r=0;const o=`lt${Date.now()}${this.loadTestFontId++}`;let l=this._loadTestFont;l=spliceString(l,976,o.length,o);const h=1482184792;let d=int32(l,16);for(i=0,s=o.length-3;i>24&255,t>>16&255,t>>8&255,255&t)}(d));const c=`@font-face {font-family:"${o}";src:${`url(data:font/opentype;base64,${btoa(l)});`}}`;this.insertRule(c);const u=this._document.createElement("div");u.style.visibility="hidden";u.style.width=u.style.height="10px";u.style.position="absolute";u.style.top=u.style.left="0px";for(const e of[t.loadedName,o]){const t=this._document.createElement("span");t.textContent="Hi";t.style.fontFamily=e;u.append(t)}this._document.body.append(u);!function isFontReady(t,e){if(++r>30){warn("Load test font never loaded.");e();return}a.font="30px "+t;a.fillText(".",0,20);a.getImageData(0,0,1,1).data[3]>0?e():setTimeout(isFontReady.bind(null,t,e))}(o,(()=>{u.remove();e.complete()}))}}class FontFaceObject{constructor(t,{disableFontFace:e=!1,fontExtraProperties:i=!1,inspectFont:s=null}){this.compiledGlyphs=Object.create(null);for(const e in t)this[e]=t[e];this.disableFontFace=!0===e;this.fontExtraProperties=!0===i;this._inspectFont=s}createNativeFontFace(){if(!this.data||this.disableFontFace)return null;let t;if(this.cssFontInfo){const e={weight:this.cssFontInfo.fontWeight};this.cssFontInfo.italicAngle&&(e.style=`oblique ${this.cssFontInfo.italicAngle}deg`);t=new FontFace(this.cssFontInfo.fontFamily,this.data,e)}else t=new FontFace(this.loadedName,this.data,{});this._inspectFont?.(this);return t}createFontFaceRule(){if(!this.data||this.disableFontFace)return null;const t=`url(data:${this.mimetype};base64,${function toBase64Util(t){return Uint8Array.prototype.toBase64?t.toBase64():btoa(bytesToString(t))}(this.data)});`;let e;if(this.cssFontInfo){let i=`font-weight: ${this.cssFontInfo.fontWeight};`;this.cssFontInfo.italicAngle&&(i+=`font-style: oblique ${this.cssFontInfo.italicAngle}deg;`);e=`@font-face {font-family:"${this.cssFontInfo.fontFamily}";${i}src:${t}}`}else e=`@font-face {font-family:"${this.loadedName}";src:${t}}`;this._inspectFont?.(this,t);return e}getPathGenerator(t,e){if(void 0!==this.compiledGlyphs[e])return this.compiledGlyphs[e];const i=this.loadedName+"_path_"+e;let s;try{s=t.get(i)}catch(t){warn(`getPathGenerator - ignoring character: "${t}".`)}const n=new Path2D(s||"");this.fontExtraProperties||t.delete(i);return this.compiledGlyphs[e]=n}}const ot=1,lt=2,ht=1,dt=2,ct=3,ut=4,pt=5,gt=6,mt=7,ft=8;function onFn(){}function wrapReason(t){if(t instanceof AbortException||t instanceof InvalidPDFException||t instanceof MissingPDFException||t instanceof PasswordException||t instanceof UnexpectedResponseException||t instanceof UnknownErrorException)return t;t instanceof Error||"object"==typeof t&&null!==t||unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');switch(t.name){case"AbortException":return new AbortException(t.message);case"InvalidPDFException":return new InvalidPDFException(t.message);case"MissingPDFException":return new MissingPDFException(t.message);case"PasswordException":return new PasswordException(t.message,t.code);case"UnexpectedResponseException":return new UnexpectedResponseException(t.message,t.status);case"UnknownErrorException":return new UnknownErrorException(t.message,t.details)}return new UnknownErrorException(t.message,t.toString())}class MessageHandler{#si=new AbortController;constructor(t,e,i){this.sourceName=t;this.targetName=e;this.comObj=i;this.callbackId=1;this.streamId=1;this.streamSinks=Object.create(null);this.streamControllers=Object.create(null);this.callbackCapabilities=Object.create(null);this.actionHandler=Object.create(null);i.addEventListener("message",this.#ni.bind(this),{signal:this.#si.signal})}#ni({data:t}){if(t.targetName!==this.sourceName)return;if(t.stream){this.#ai(t);return}if(t.callback){const e=t.callbackId,i=this.callbackCapabilities[e];if(!i)throw new Error(`Cannot resolve callback ${e}`);delete this.callbackCapabilities[e];if(t.callback===ot)i.resolve(t.data);else{if(t.callback!==lt)throw new Error("Unexpected callback case");i.reject(wrapReason(t.reason))}return}const e=this.actionHandler[t.action];if(!e)throw new Error(`Unknown action from worker: ${t.action}`);if(t.callbackId){const i=this.sourceName,s=t.sourceName,n=this.comObj;Promise.try(e,t.data).then((function(e){n.postMessage({sourceName:i,targetName:s,callback:ot,callbackId:t.callbackId,data:e})}),(function(e){n.postMessage({sourceName:i,targetName:s,callback:lt,callbackId:t.callbackId,reason:wrapReason(e)})}))}else t.streamId?this.#ri(t):e(t.data)}on(t,e){const i=this.actionHandler;if(i[t])throw new Error(`There is already an actionName called "${t}"`);i[t]=e}send(t,e,i){this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,data:e},i)}sendWithPromise(t,e,i){const s=this.callbackId++,n=Promise.withResolvers();this.callbackCapabilities[s]=n;try{this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,callbackId:s,data:e},i)}catch(t){n.reject(t)}return n.promise}sendWithStream(t,e,i,s){const n=this.streamId++,a=this.sourceName,r=this.targetName,o=this.comObj;return new ReadableStream({start:i=>{const l=Promise.withResolvers();this.streamControllers[n]={controller:i,startCall:l,pullCall:null,cancelCall:null,isClosed:!1};o.postMessage({sourceName:a,targetName:r,action:t,streamId:n,data:e,desiredSize:i.desiredSize},s);return l.promise},pull:t=>{const e=Promise.withResolvers();this.streamControllers[n].pullCall=e;o.postMessage({sourceName:a,targetName:r,stream:gt,streamId:n,desiredSize:t.desiredSize});return e.promise},cancel:t=>{assert(t instanceof Error,"cancel must have a valid reason");const e=Promise.withResolvers();this.streamControllers[n].cancelCall=e;this.streamControllers[n].isClosed=!0;o.postMessage({sourceName:a,targetName:r,stream:ht,streamId:n,reason:wrapReason(t)});return e.promise}},i)}#ri(t){const e=t.streamId,i=this.sourceName,s=t.sourceName,n=this.comObj,a=this,r=this.actionHandler[t.action],o={enqueue(t,a=1,r){if(this.isCancelled)return;const o=this.desiredSize;this.desiredSize-=a;if(o>0&&this.desiredSize<=0){this.sinkCapability=Promise.withResolvers();this.ready=this.sinkCapability.promise}n.postMessage({sourceName:i,targetName:s,stream:ut,streamId:e,chunk:t},r)},close(){if(!this.isCancelled){this.isCancelled=!0;n.postMessage({sourceName:i,targetName:s,stream:ct,streamId:e});delete a.streamSinks[e]}},error(t){assert(t instanceof Error,"error must have a valid reason");if(!this.isCancelled){this.isCancelled=!0;n.postMessage({sourceName:i,targetName:s,stream:pt,streamId:e,reason:wrapReason(t)})}},sinkCapability:Promise.withResolvers(),onPull:null,onCancel:null,isCancelled:!1,desiredSize:t.desiredSize,ready:null};o.sinkCapability.resolve();o.ready=o.sinkCapability.promise;this.streamSinks[e]=o;Promise.try(r,t.data,o).then((function(){n.postMessage({sourceName:i,targetName:s,stream:ft,streamId:e,success:!0})}),(function(t){n.postMessage({sourceName:i,targetName:s,stream:ft,streamId:e,reason:wrapReason(t)})}))}#ai(t){const e=t.streamId,i=this.sourceName,s=t.sourceName,n=this.comObj,a=this.streamControllers[e],r=this.streamSinks[e];switch(t.stream){case ft:t.success?a.startCall.resolve():a.startCall.reject(wrapReason(t.reason));break;case mt:t.success?a.pullCall.resolve():a.pullCall.reject(wrapReason(t.reason));break;case gt:if(!r){n.postMessage({sourceName:i,targetName:s,stream:mt,streamId:e,success:!0});break}r.desiredSize<=0&&t.desiredSize>0&&r.sinkCapability.resolve();r.desiredSize=t.desiredSize;Promise.try(r.onPull||onFn).then((function(){n.postMessage({sourceName:i,targetName:s,stream:mt,streamId:e,success:!0})}),(function(t){n.postMessage({sourceName:i,targetName:s,stream:mt,streamId:e,reason:wrapReason(t)})}));break;case ut:assert(a,"enqueue should have stream controller");if(a.isClosed)break;a.controller.enqueue(t.chunk);break;case ct:assert(a,"close should have stream controller");if(a.isClosed)break;a.isClosed=!0;a.controller.close();this.#oi(a,e);break;case pt:assert(a,"error should have stream controller");a.controller.error(wrapReason(t.reason));this.#oi(a,e);break;case dt:t.success?a.cancelCall.resolve():a.cancelCall.reject(wrapReason(t.reason));this.#oi(a,e);break;case ht:if(!r)break;const o=wrapReason(t.reason);Promise.try(r.onCancel||onFn,o).then((function(){n.postMessage({sourceName:i,targetName:s,stream:dt,streamId:e,success:!0})}),(function(t){n.postMessage({sourceName:i,targetName:s,stream:dt,streamId:e,reason:wrapReason(t)})}));r.sinkCapability.reject(o);r.isCancelled=!0;delete this.streamSinks[e];break;default:throw new Error("Unexpected stream case")}}async#oi(t,e){await Promise.allSettled([t.startCall?.promise,t.pullCall?.promise,t.cancelCall?.promise]);delete this.streamControllers[e]}destroy(){this.#si?.abort();this.#si=null}}class BaseCanvasFactory{#li=!1;constructor({enableHWA:t=!1}){this.#li=t}create(t,e){if(t<=0||e<=0)throw new Error("Invalid canvas size");const i=this._createCanvas(t,e);return{canvas:i,context:i.getContext("2d",{willReadFrequently:!this.#li})}}reset(t,e,i){if(!t.canvas)throw new Error("Canvas is not specified");if(e<=0||i<=0)throw new Error("Invalid canvas size");t.canvas.width=e;t.canvas.height=i}destroy(t){if(!t.canvas)throw new Error("Canvas is not specified");t.canvas.width=0;t.canvas.height=0;t.canvas=null;t.context=null}_createCanvas(t,e){unreachable("Abstract method `_createCanvas` called.")}}class BaseCMapReaderFactory{constructor({baseUrl:t=null,isCompressed:e=!0}){this.baseUrl=t;this.isCompressed=e}async fetch({name:t}){if(!this.baseUrl)throw new Error("Ensure that the `cMapUrl` and `cMapPacked` API parameters are provided.");if(!t)throw new Error("CMap name must be specified.");const e=this.baseUrl+t+(this.isCompressed?".bcmap":"");return this._fetch(e).then((t=>({cMapData:t,isCompressed:this.isCompressed}))).catch((t=>{throw new Error(`Unable to load ${this.isCompressed?"binary ":""}CMap at: ${e}`)}))}async _fetch(t){unreachable("Abstract method `_fetch` called.")}}class DOMCMapReaderFactory extends BaseCMapReaderFactory{async _fetch(t){const e=await fetchData(t,this.isCompressed?"arraybuffer":"text");return e instanceof ArrayBuffer?new Uint8Array(e):stringToBytes(e)}}class BaseFilterFactory{addFilter(t){return"none"}addHCMFilter(t,e){return"none"}addAlphaFilter(t){return"none"}addLuminosityFilter(t){return"none"}addHighlightHCMFilter(t,e,i,s,n){return"none"}destroy(t=!1){}}class BaseStandardFontDataFactory{constructor({baseUrl:t=null}){this.baseUrl=t}async fetch({filename:t}){if(!this.baseUrl)throw new Error("Ensure that the `standardFontDataUrl` API parameter is provided.");if(!t)throw new Error("Font filename must be specified.");const e=`${this.baseUrl}${t}`;return this._fetch(e).catch((t=>{throw new Error(`Unable to load font data at: ${e}`)}))}async _fetch(t){unreachable("Abstract method `_fetch` called.")}}class DOMStandardFontDataFactory extends BaseStandardFontDataFactory{async _fetch(t){const e=await fetchData(t,"arraybuffer");return new Uint8Array(e)}}e&&warn("Please use the `legacy` build in Node.js environments.");async function node_utils_fetchData(t){const e=process.getBuiltinModule("fs"),i=await e.promises.readFile(t);return new Uint8Array(i)}const bt="Fill",At="Stroke",wt="Shading";function applyBoundingBox(t,e){if(!e)return;const i=e[2]-e[0],s=e[3]-e[1],n=new Path2D;n.rect(e[0],e[1],i,s);t.clip(n)}class BaseShadingPattern{getPattern(){unreachable("Abstract method `getPattern` called.")}}class RadialAxialShadingPattern extends BaseShadingPattern{constructor(t){super();this._type=t[1];this._bbox=t[2];this._colorStops=t[3];this._p0=t[4];this._p1=t[5];this._r0=t[6];this._r1=t[7];this.matrix=null}_createGradient(t){let e;"axial"===this._type?e=t.createLinearGradient(this._p0[0],this._p0[1],this._p1[0],this._p1[1]):"radial"===this._type&&(e=t.createRadialGradient(this._p0[0],this._p0[1],this._r0,this._p1[0],this._p1[1],this._r1));for(const t of this._colorStops)e.addColorStop(t[0],t[1]);return e}getPattern(t,e,i,s){let n;if(s===At||s===bt){const a=e.current.getClippedPathBoundingBox(s,getCurrentTransform(t))||[0,0,0,0],r=Math.ceil(a[2]-a[0])||1,o=Math.ceil(a[3]-a[1])||1,l=e.cachedCanvases.getCanvas("pattern",r,o),h=l.context;h.clearRect(0,0,h.canvas.width,h.canvas.height);h.beginPath();h.rect(0,0,h.canvas.width,h.canvas.height);h.translate(-a[0],-a[1]);i=Util.transform(i,[1,0,0,1,a[0],a[1]]);h.transform(...e.baseTransform);this.matrix&&h.transform(...this.matrix);applyBoundingBox(h,this._bbox);h.fillStyle=this._createGradient(h);h.fill();n=t.createPattern(l.canvas,"no-repeat");const d=new DOMMatrix(i);n.setTransform(d)}else{applyBoundingBox(t,this._bbox);n=this._createGradient(t)}return n}}function drawTriangle(t,e,i,s,n,a,r,o){const l=e.coords,h=e.colors,d=t.data,c=4*t.width;let u;if(l[i+1]>l[s+1]){u=i;i=s;s=u;u=a;a=r;r=u}if(l[s+1]>l[n+1]){u=s;s=n;n=u;u=r;r=o;o=u}if(l[i+1]>l[s+1]){u=i;i=s;s=u;u=a;a=r;r=u}const p=(l[i]+e.offsetX)*e.scaleX,g=(l[i+1]+e.offsetY)*e.scaleY,m=(l[s]+e.offsetX)*e.scaleX,f=(l[s+1]+e.offsetY)*e.scaleY,b=(l[n]+e.offsetX)*e.scaleX,A=(l[n+1]+e.offsetY)*e.scaleY;if(g>=A)return;const w=h[a],v=h[a+1],y=h[a+2],x=h[r],_=h[r+1],E=h[r+2],S=h[o],C=h[o+1],T=h[o+2],M=Math.round(g),P=Math.round(A);let D,k,R,I,F,L,O,N;for(let t=M;t<=P;t++){if(tA?1:f===A?0:(f-t)/(f-A);D=m-(m-b)*e;k=x-(x-S)*e;R=_-(_-C)*e;I=E-(E-T)*e}let e;e=tA?1:(g-t)/(g-A);F=p-(p-b)*e;L=w-(w-S)*e;O=v-(v-C)*e;N=y-(y-T)*e;const i=Math.round(Math.min(D,F)),s=Math.round(Math.max(D,F));let n=c*t+4*i;for(let t=i;t<=s;t++){e=(D-t)/(D-F);e<0?e=0:e>1&&(e=1);d[n++]=k-(k-L)*e|0;d[n++]=R-(R-O)*e|0;d[n++]=I-(I-N)*e|0;d[n++]=255}}}function drawFigure(t,e,i){const s=e.coords,n=e.colors;let a,r;switch(e.type){case"lattice":const o=e.verticesPerRow,l=Math.floor(s.length/o)-1,h=o-1;for(a=0;a=Math.ceil(p*b)?w=o:y=!0;E>=Math.ceil(g*A)?v=l:x=!0;const S=this.getSizeAndScale(w,this.ctx.canvas.width,b),C=this.getSizeAndScale(v,this.ctx.canvas.height,A),T=t.cachedCanvases.getCanvas("pattern",S.size,C.size),M=T.context,P=r.createCanvasGraphics(M);P.groupLevel=t.groupLevel;this.setFillAndStrokeStyleToContext(P,s,a);M.translate(-S.scale*h,-C.scale*d);P.transform(S.scale,0,0,C.scale,0,0);M.save();this.clipBbox(P,h,d,c,u);P.baseTransform=getCurrentTransform(P.ctx);P.executeOperatorList(i);P.endDrawing();M.restore();if(y||x){const e=T.canvas;y&&(w=o);x&&(v=l);const i=this.getSizeAndScale(w,this.ctx.canvas.width,b),s=this.getSizeAndScale(v,this.ctx.canvas.height,A),n=i.size,a=s.size,r=t.cachedCanvases.getCanvas("pattern-workaround",n,a),c=r.context,u=y?Math.floor(p/o):0,m=x?Math.floor(g/l):0;for(let t=0;t<=u;t++)for(let i=0;i<=m;i++)c.drawImage(e,n*t,a*i,n,a,0,0,n,a);return{canvas:r.canvas,scaleX:i.scale,scaleY:s.scale,offsetX:h,offsetY:d}}return{canvas:T.canvas,scaleX:S.scale,scaleY:C.scale,offsetX:h,offsetY:d}}getSizeAndScale(t,e,i){const s=Math.max(TilingPattern.MAX_PATTERN_SIZE,e);let n=Math.ceil(t*i);n>=s?n=s:i=n/t;return{scale:i,size:n}}clipBbox(t,e,i,s,n){const a=s-e,r=n-i;t.ctx.rect(e,i,a,r);t.current.updateRectMinMax(getCurrentTransform(t.ctx),[e,i,s,n]);t.clip();t.endPath()}setFillAndStrokeStyleToContext(t,e,i){const s=t.ctx,n=t.current;switch(e){case vt:const t=this.ctx;s.fillStyle=t.fillStyle;s.strokeStyle=t.strokeStyle;n.fillColor=t.fillStyle;n.strokeColor=t.strokeStyle;break;case yt:const a=Util.makeHexColor(i[0],i[1],i[2]);s.fillStyle=a;s.strokeStyle=a;n.fillColor=a;n.strokeColor=a;break;default:throw new FormatError(`Unsupported paint type: ${e}`)}}getPattern(t,e,i,s){let n=i;if(s!==wt){n=Util.transform(n,e.baseTransform);this.matrix&&(n=Util.transform(n,this.matrix))}const a=this.createPatternCanvas(e);let r=new DOMMatrix(n);r=r.translate(a.offsetX,a.offsetY);r=r.scale(1/a.scaleX,1/a.scaleY);const o=t.createPattern(a.canvas,"repeat");o.setTransform(r);return o}}function convertBlackAndWhiteToRGBA({src:t,srcPos:e=0,dest:i,width:s,height:n,nonBlackColor:a=4294967295,inverseDecode:r=!1}){const o=util_FeatureTest.isLittleEndian?4278190080:255,[l,h]=r?[a,o]:[o,a],d=s>>3,c=7&s,u=t.length;i=new Uint32Array(i.buffer);let p=0;for(let s=0;s>2),m=i.length,f=s+7>>3,b=4294967295,A=util_FeatureTest.isLittleEndian?4278190080:255;for(u=0;uf?s:8*t-7,r=-8&a;let o=0,c=0;for(;n>=1}}for(;l=a){g=n;m=s*g}l=0;for(p=m;p--;){c[l++]=d[h++];c[l++]=d[h++];c[l++]=d[h++];c[l++]=255}t.putImageData(o,0,u*xt)}}}function putBinaryImageMask(t,e){if(e.bitmap){t.drawImage(e.bitmap,0,0);return}const i=e.height,s=e.width,n=i%xt,a=(i-n)/xt,r=0===n?a:a+1,o=t.createImageData(s,xt);let l=0;const h=e.data,d=o.data;for(let e=0;e10&&"function"==typeof i,h=l?Date.now()+15:0;let d=0;const c=this.commonObjs,u=this.objs;let p;for(;;){if(void 0!==s&&r===s.nextBreakPoint){s.breakIt(r,i);return r}p=a[r];if(p!==X.dependency)this[p].apply(this,n[r]);else for(const t of n[r]){const e=t.startsWith("g_")?c:u;if(!e.has(t)){e.get(t,i);return r}}r++;if(r===o)return r;if(l&&++d>10){if(Date.now()>h){i();return r}d=0}}}#hi(){for(;this.stateStack.length||this.inSMaskMode;)this.restore();this.current.activeSMask=null;this.ctx.restore();if(this.transparentCanvas){this.ctx=this.compositeCtx;this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.drawImage(this.transparentCanvas,0,0);this.ctx.restore();this.transparentCanvas=null}}endDrawing(){this.#hi();this.cachedCanvases.clear();this.cachedPatterns.clear();for(const t of this._cachedBitmapsMap.values()){for(const e of t.values())"undefined"!=typeof HTMLCanvasElement&&e instanceof HTMLCanvasElement&&(e.width=e.height=0);t.clear()}this._cachedBitmapsMap.clear();this.#di()}#di(){if(this.pageColors){const t=this.filterFactory.addHCMFilter(this.pageColors.foreground,this.pageColors.background);if("none"!==t){const e=this.ctx.filter;this.ctx.filter=t;this.ctx.drawImage(this.ctx.canvas,0,0);this.ctx.filter=e}}}_scaleImage(t,e){const i=t.width??t.displayWidth,s=t.height??t.displayHeight;let n,a,r=Math.max(Math.hypot(e[0],e[1]),1),o=Math.max(Math.hypot(e[2],e[3]),1),l=i,h=s,d="prescale1";for(;r>2&&l>1||o>2&&h>1;){let e=l,i=h;if(r>2&&l>1){e=l>=16384?Math.floor(l/2)-1||1:Math.ceil(l/2);r/=l/e}if(o>2&&h>1){i=h>=16384?Math.floor(h/2)-1||1:Math.ceil(h)/2;o/=h/i}n=this.cachedCanvases.getCanvas(d,e,i);a=n.context;a.clearRect(0,0,e,i);a.drawImage(t,0,0,l,h,0,0,e,i);t=n.canvas;l=e;h=i;d="prescale1"===d?"prescale2":"prescale1"}return{img:t,paintWidth:l,paintHeight:h}}_createMaskCanvas(t){const e=this.ctx,{width:i,height:s}=t,n=this.current.fillColor,a=this.current.patternFill,r=getCurrentTransform(e);let o,l,h,d;if((t.bitmap||t.data)&&t.count>1){const e=t.bitmap||t.data.buffer;l=JSON.stringify(a?r:[r.slice(0,4),n]);o=this._cachedBitmapsMap.get(e);if(!o){o=new Map;this._cachedBitmapsMap.set(e,o)}const i=o.get(l);if(i&&!a){return{canvas:i,offsetX:Math.round(Math.min(r[0],r[2])+r[4]),offsetY:Math.round(Math.min(r[1],r[3])+r[5])}}h=i}if(!h){d=this.cachedCanvases.getCanvas("maskCanvas",i,s);putBinaryImageMask(d.context,t)}let c=Util.transform(r,[1/i,0,0,-1/s,0,0]);c=Util.transform(c,[1,0,0,1,0,-s]);const[u,p,g,m]=Util.getAxialAlignedBoundingBox([0,0,i,s],c),f=Math.round(g-u)||1,b=Math.round(m-p)||1,A=this.cachedCanvases.getCanvas("fillCanvas",f,b),w=A.context,v=u,y=p;w.translate(-v,-y);w.transform(...c);if(!h){h=this._scaleImage(d.canvas,getCurrentTransformInverse(w));h=h.img;o&&a&&o.set(l,h)}w.imageSmoothingEnabled=getImageSmoothingEnabled(getCurrentTransform(w),t.interpolate);drawImageAtIntegerCoords(w,h,0,0,h.width,h.height,0,0,i,s);w.globalCompositeOperation="source-in";const x=Util.transform(getCurrentTransformInverse(w),[1,0,0,1,-v,-y]);w.fillStyle=a?n.getPattern(e,this,x,bt):n;w.fillRect(0,0,i,s);if(o&&!a){this.cachedCanvases.delete("fillCanvas");o.set(l,A.canvas)}return{canvas:A.canvas,offsetX:Math.round(v),offsetY:Math.round(y)}}setLineWidth(t){t!==this.current.lineWidth&&(this._cachedScaleForStroking[0]=-1);this.current.lineWidth=t;this.ctx.lineWidth=t}setLineCap(t){this.ctx.lineCap=_t[t]}setLineJoin(t){this.ctx.lineJoin=Et[t]}setMiterLimit(t){this.ctx.miterLimit=t}setDash(t,e){const i=this.ctx;if(void 0!==i.setLineDash){i.setLineDash(t);i.lineDashOffset=e}}setRenderingIntent(t){}setFlatness(t){}setGState(t){for(const[e,i]of t)switch(e){case"LW":this.setLineWidth(i);break;case"LC":this.setLineCap(i);break;case"LJ":this.setLineJoin(i);break;case"ML":this.setMiterLimit(i);break;case"D":this.setDash(i[0],i[1]);break;case"RI":this.setRenderingIntent(i);break;case"FL":this.setFlatness(i);break;case"Font":this.setFont(i[0],i[1]);break;case"CA":this.current.strokeAlpha=i;break;case"ca":this.current.fillAlpha=i;this.ctx.globalAlpha=i;break;case"BM":this.ctx.globalCompositeOperation=i;break;case"SMask":this.current.activeSMask=i?this.tempSMask:null;this.tempSMask=null;this.checkSMaskState();break;case"TR":this.ctx.filter=this.current.transferMaps=this.filterFactory.addFilter(i)}}get inSMaskMode(){return!!this.suspendedCtx}checkSMaskState(){const t=this.inSMaskMode;this.current.activeSMask&&!t?this.beginSMaskMode():!this.current.activeSMask&&t&&this.endSMaskMode()}beginSMaskMode(){if(this.inSMaskMode)throw new Error("beginSMaskMode called while already in smask mode");const t=this.ctx.canvas.width,e=this.ctx.canvas.height,i="smaskGroupAt"+this.groupLevel,s=this.cachedCanvases.getCanvas(i,t,e);this.suspendedCtx=this.ctx;this.ctx=s.context;const n=this.ctx;n.setTransform(...getCurrentTransform(this.suspendedCtx));copyCtxState(this.suspendedCtx,n);!function mirrorContextOperations(t,e){if(t._removeMirroring)throw new Error("Context is already forwarding operations.");t.__originalSave=t.save;t.__originalRestore=t.restore;t.__originalRotate=t.rotate;t.__originalScale=t.scale;t.__originalTranslate=t.translate;t.__originalTransform=t.transform;t.__originalSetTransform=t.setTransform;t.__originalResetTransform=t.resetTransform;t.__originalClip=t.clip;t.__originalMoveTo=t.moveTo;t.__originalLineTo=t.lineTo;t.__originalBezierCurveTo=t.bezierCurveTo;t.__originalRect=t.rect;t.__originalClosePath=t.closePath;t.__originalBeginPath=t.beginPath;t._removeMirroring=()=>{t.save=t.__originalSave;t.restore=t.__originalRestore;t.rotate=t.__originalRotate;t.scale=t.__originalScale;t.translate=t.__originalTranslate;t.transform=t.__originalTransform;t.setTransform=t.__originalSetTransform;t.resetTransform=t.__originalResetTransform;t.clip=t.__originalClip;t.moveTo=t.__originalMoveTo;t.lineTo=t.__originalLineTo;t.bezierCurveTo=t.__originalBezierCurveTo;t.rect=t.__originalRect;t.closePath=t.__originalClosePath;t.beginPath=t.__originalBeginPath;delete t._removeMirroring};t.save=function ctxSave(){e.save();this.__originalSave()};t.restore=function ctxRestore(){e.restore();this.__originalRestore()};t.translate=function ctxTranslate(t,i){e.translate(t,i);this.__originalTranslate(t,i)};t.scale=function ctxScale(t,i){e.scale(t,i);this.__originalScale(t,i)};t.transform=function ctxTransform(t,i,s,n,a,r){e.transform(t,i,s,n,a,r);this.__originalTransform(t,i,s,n,a,r)};t.setTransform=function ctxSetTransform(t,i,s,n,a,r){e.setTransform(t,i,s,n,a,r);this.__originalSetTransform(t,i,s,n,a,r)};t.resetTransform=function ctxResetTransform(){e.resetTransform();this.__originalResetTransform()};t.rotate=function ctxRotate(t){e.rotate(t);this.__originalRotate(t)};t.clip=function ctxRotate(t){e.clip(t);this.__originalClip(t)};t.moveTo=function(t,i){e.moveTo(t,i);this.__originalMoveTo(t,i)};t.lineTo=function(t,i){e.lineTo(t,i);this.__originalLineTo(t,i)};t.bezierCurveTo=function(t,i,s,n,a,r){e.bezierCurveTo(t,i,s,n,a,r);this.__originalBezierCurveTo(t,i,s,n,a,r)};t.rect=function(t,i,s,n){e.rect(t,i,s,n);this.__originalRect(t,i,s,n)};t.closePath=function(){e.closePath();this.__originalClosePath()};t.beginPath=function(){e.beginPath();this.__originalBeginPath()}}(n,this.suspendedCtx);this.setGState([["BM","source-over"],["ca",1],["CA",1]])}endSMaskMode(){if(!this.inSMaskMode)throw new Error("endSMaskMode called while not in smask mode");this.ctx._removeMirroring();copyCtxState(this.ctx,this.suspendedCtx);this.ctx=this.suspendedCtx;this.suspendedCtx=null}compose(t){if(!this.current.activeSMask)return;if(t){t[0]=Math.floor(t[0]);t[1]=Math.floor(t[1]);t[2]=Math.ceil(t[2]);t[3]=Math.ceil(t[3])}else t=[0,0,this.ctx.canvas.width,this.ctx.canvas.height];const e=this.current.activeSMask,i=this.suspendedCtx;this.composeSMask(i,e,this.ctx,t);this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.clearRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height);this.ctx.restore()}composeSMask(t,e,i,s){const n=s[0],a=s[1],r=s[2]-n,o=s[3]-a;if(0!==r&&0!==o){this.genericComposeSMask(e.context,i,r,o,e.subtype,e.backdrop,e.transferMap,n,a,e.offsetX,e.offsetY);t.save();t.globalAlpha=1;t.globalCompositeOperation="source-over";t.setTransform(1,0,0,1,0,0);t.drawImage(i.canvas,0,0);t.restore()}}genericComposeSMask(t,e,i,s,n,a,r,o,l,h,d){let c=t.canvas,u=o-h,p=l-d;if(a){const e=Util.makeHexColor(...a);if(u<0||p<0||u+i>c.width||p+s>c.height){const t=this.cachedCanvases.getCanvas("maskExtension",i,s),n=t.context;n.drawImage(c,-u,-p);n.globalCompositeOperation="destination-atop";n.fillStyle=e;n.fillRect(0,0,i,s);n.globalCompositeOperation="source-over";c=t.canvas;u=p=0}else{t.save();t.globalAlpha=1;t.setTransform(1,0,0,1,0,0);const n=new Path2D;n.rect(u,p,i,s);t.clip(n);t.globalCompositeOperation="destination-atop";t.fillStyle=e;t.fillRect(u,p,i,s);t.restore()}}e.save();e.globalAlpha=1;e.setTransform(1,0,0,1,0,0);"Alpha"===n&&r?e.filter=this.filterFactory.addAlphaFilter(r):"Luminosity"===n&&(e.filter=this.filterFactory.addLuminosityFilter(r));const g=new Path2D;g.rect(o,l,i,s);e.clip(g);e.globalCompositeOperation="destination-in";e.drawImage(c,u,p,i,s,o,l,i,s);e.restore()}save(){if(this.inSMaskMode){copyCtxState(this.ctx,this.suspendedCtx);this.suspendedCtx.save()}else this.ctx.save();const t=this.current;this.stateStack.push(t);this.current=t.clone()}restore(){0===this.stateStack.length&&this.inSMaskMode&&this.endSMaskMode();if(0!==this.stateStack.length){this.current=this.stateStack.pop();if(this.inSMaskMode){this.suspendedCtx.restore();copyCtxState(this.suspendedCtx,this.ctx)}else this.ctx.restore();this.checkSMaskState();this.pendingClip=null;this._cachedScaleForStroking[0]=-1;this._cachedGetSinglePixelWidth=null}}transform(t,e,i,s,n,a){this.ctx.transform(t,e,i,s,n,a);this._cachedScaleForStroking[0]=-1;this._cachedGetSinglePixelWidth=null}constructPath(t,e,i){const s=this.ctx,n=this.current;let a,r,o=n.x,l=n.y;const h=getCurrentTransform(s),d=0===h[0]&&0===h[3]||0===h[1]&&0===h[2],c=d?i.slice(0):null;for(let i=0,u=0,p=t.length;i100&&(h=100);this.current.fontSizeScale=e/h;this.ctx.font=`${l} ${o} ${h}px ${r}`}setTextRenderingMode(t){this.current.textRenderingMode=t}setTextRise(t){this.current.textRise=t}moveText(t,e){this.current.x=this.current.lineX+=t;this.current.y=this.current.lineY+=e}setLeadingMoveText(t,e){this.setLeading(-e);this.moveText(t,e)}setTextMatrix(t,e,i,s,n,a){this.current.textMatrix=[t,e,i,s,n,a];this.current.textMatrixScale=Math.hypot(t,e);this.current.x=this.current.lineX=0;this.current.y=this.current.lineY=0}nextLine(){this.moveText(0,this.current.leading)}#ci(t,e,i){const s=new Path2D;s.addPath(t,new DOMMatrix(i).invertSelf().multiplySelf(e));return s}paintChar(t,e,i,s,n){const a=this.ctx,r=this.current,o=r.font,l=r.textRenderingMode,h=r.fontSize/r.fontSizeScale,d=l&y,c=!!(l&x),u=r.patternFill&&!o.missingFile,p=r.patternStroke&&!o.missingFile;let g;(o.disableFontFace||c||u||p)&&(g=o.getPathGenerator(this.commonObjs,t));if(o.disableFontFace||u||p){a.save();a.translate(e,i);a.scale(h,-h);if(d===b||d===w)if(s){const t=a.getTransform();a.setTransform(...s);a.fill(this.#ci(g,t,s))}else a.fill(g);if(d===A||d===w)if(n){const t=a.getTransform();a.setTransform(...n);a.stroke(this.#ci(g,t,n))}else{a.lineWidth/=h;a.stroke(g)}a.restore()}else{d!==b&&d!==w||a.fillText(t,e,i);d!==A&&d!==w||a.strokeText(t,e,i)}if(c){(this.pendingTextPaths||=[]).push({transform:getCurrentTransform(a),x:e,y:i,fontSize:h,path:g})}}get isFontSubpixelAAEnabled(){const{context:t}=this.cachedCanvases.getCanvas("isFontSubpixelAAEnabled",10,10);t.scale(1.5,1);t.fillText("I",0,10);const e=t.getImageData(0,0,10,10).data;let i=!1;for(let t=3;t0&&e[t]<255){i=!0;break}return shadow(this,"isFontSubpixelAAEnabled",i)}showText(t){const e=this.current,i=e.font;if(i.isType3Font)return this.showType3Text(t);const s=e.fontSize;if(0===s)return;const n=this.ctx,a=e.fontSizeScale,r=e.charSpacing,o=e.wordSpacing,l=e.fontDirection,h=e.textHScale*l,d=t.length,c=i.vertical,u=c?1:-1,p=i.defaultVMetrics,g=s*e.fontMatrix[0],m=e.textRenderingMode===b&&!i.disableFontFace&&!e.patternFill;n.save();n.transform(...e.textMatrix);n.translate(e.x,e.y+e.textRise);l>0?n.scale(h,-1):n.scale(h,1);let f,v;if(e.patternFill){n.save();const t=e.fillColor.getPattern(n,this,getCurrentTransformInverse(n),bt);f=getCurrentTransform(n);n.restore();n.fillStyle=t}if(e.patternStroke){n.save();const t=e.strokeColor.getPattern(n,this,getCurrentTransformInverse(n),At);v=getCurrentTransform(n);n.restore();n.strokeStyle=t}let x=e.lineWidth;const _=e.textMatrixScale;if(0===_||0===x){const t=e.textRenderingMode&y;t!==A&&t!==w||(x=this.getSinglePixelWidth())}else x/=_;if(1!==a){n.scale(a,a);x/=a}n.lineWidth=x;if(i.isInvalidPDFjsFont){const i=[];let s=0;for(const e of t){i.push(e.unicode);s+=e.width}n.fillText(i.join(""),0,0);e.x+=s*g*h;n.restore();this.compose();return}let E,S=0;for(E=0;E0){const t=1e3*n.measureText(b).width/s*a;if(xnew CanvasGraphics(t,this.commonObjs,this.objs,this.canvasFactory,this.filterFactory,{optionalContentConfig:this.optionalContentConfig,markedContentStack:this.markedContentStack})};e=new TilingPattern(t,i,this.ctx,n,s)}else e=this._getPattern(t[1],t[2]);return e}setStrokeColorN(){this.current.strokeColor=this.getColorN_Pattern(arguments);this.current.patternStroke=!0}setFillColorN(){this.current.fillColor=this.getColorN_Pattern(arguments);this.current.patternFill=!0}setStrokeRGBColor(t,e,i){this.ctx.strokeStyle=this.current.strokeColor=Util.makeHexColor(t,e,i);this.current.patternStroke=!1}setStrokeTransparent(){this.ctx.strokeStyle=this.current.strokeColor="transparent";this.current.patternStroke=!1}setFillRGBColor(t,e,i){this.ctx.fillStyle=this.current.fillColor=Util.makeHexColor(t,e,i);this.current.patternFill=!1}setFillTransparent(){this.ctx.fillStyle=this.current.fillColor="transparent";this.current.patternFill=!1}_getPattern(t,e=null){let i;if(this.cachedPatterns.has(t))i=this.cachedPatterns.get(t);else{i=function getShadingPattern(t){switch(t[0]){case"RadialAxial":return new RadialAxialShadingPattern(t);case"Mesh":return new MeshShadingPattern(t);case"Dummy":return new DummyShadingPattern}throw new Error(`Unknown IR type: ${t[0]}`)}(this.getObject(t));this.cachedPatterns.set(t,i)}e&&(i.matrix=e);return i}shadingFill(t){if(!this.contentVisible)return;const e=this.ctx;this.save();const i=this._getPattern(t);e.fillStyle=i.getPattern(e,this,getCurrentTransformInverse(e),wt);const s=getCurrentTransformInverse(e);if(s){const{width:t,height:i}=e.canvas,[n,a,r,o]=Util.getAxialAlignedBoundingBox([0,0,t,i],s);this.ctx.fillRect(n,a,r-n,o-a)}else this.ctx.fillRect(-1e10,-1e10,2e10,2e10);this.compose(this.current.getClippedPathBoundingBox());this.restore()}beginInlineImage(){unreachable("Should not call beginInlineImage")}beginImageData(){unreachable("Should not call beginImageData")}paintFormXObjectBegin(t,e){if(this.contentVisible){this.save();this.baseTransformStack.push(this.baseTransform);t&&this.transform(...t);this.baseTransform=getCurrentTransform(this.ctx);if(e){const t=e[2]-e[0],i=e[3]-e[1];this.ctx.rect(e[0],e[1],t,i);this.current.updateRectMinMax(getCurrentTransform(this.ctx),e);this.clip();this.endPath()}}}paintFormXObjectEnd(){if(this.contentVisible){this.restore();this.baseTransform=this.baseTransformStack.pop()}}beginGroup(t){if(!this.contentVisible)return;this.save();if(this.inSMaskMode){this.endSMaskMode();this.current.activeSMask=null}const e=this.ctx;t.isolated||info("TODO: Support non-isolated groups.");t.knockout&&warn("Knockout groups not supported.");const i=getCurrentTransform(e);t.matrix&&e.transform(...t.matrix);if(!t.bbox)throw new Error("Bounding box is required.");let s=Util.getAxialAlignedBoundingBox(t.bbox,getCurrentTransform(e));const n=[0,0,e.canvas.width,e.canvas.height];s=Util.intersect(s,n)||[0,0,0,0];const a=Math.floor(s[0]),r=Math.floor(s[1]),o=Math.max(Math.ceil(s[2])-a,1),l=Math.max(Math.ceil(s[3])-r,1);this.current.startNewPathAndClipBox([0,0,o,l]);let h="groupAt"+this.groupLevel;t.smask&&(h+="_smask_"+this.smaskCounter++%2);const d=this.cachedCanvases.getCanvas(h,o,l),c=d.context;c.translate(-a,-r);c.transform(...i);if(t.smask)this.smaskStack.push({canvas:d.canvas,context:c,offsetX:a,offsetY:r,subtype:t.smask.subtype,backdrop:t.smask.backdrop,transferMap:t.smask.transferMap||null,startTransformInverse:null});else{e.setTransform(1,0,0,1,0,0);e.translate(a,r);e.save()}copyCtxState(e,c);this.ctx=c;this.setGState([["BM","source-over"],["ca",1],["CA",1]]);this.groupStack.push(e);this.groupLevel++}endGroup(t){if(!this.contentVisible)return;this.groupLevel--;const e=this.ctx,i=this.groupStack.pop();this.ctx=i;this.ctx.imageSmoothingEnabled=!1;if(t.smask){this.tempSMask=this.smaskStack.pop();this.restore()}else{this.ctx.restore();const t=getCurrentTransform(this.ctx);this.restore();this.ctx.save();this.ctx.setTransform(...t);const i=Util.getAxialAlignedBoundingBox([0,0,e.canvas.width,e.canvas.height],t);this.ctx.drawImage(e.canvas,0,0);this.ctx.restore();this.compose(i)}}beginAnnotation(t,e,i,s,n){this.#hi();resetCtxToDefault(this.ctx);this.ctx.save();this.save();this.baseTransform&&this.ctx.setTransform(...this.baseTransform);if(e){const s=e[2]-e[0],a=e[3]-e[1];if(n&&this.annotationCanvasMap){(i=i.slice())[4]-=e[0];i[5]-=e[1];(e=e.slice())[0]=e[1]=0;e[2]=s;e[3]=a;const[n,r]=Util.singularValueDecompose2dScale(getCurrentTransform(this.ctx)),{viewportScale:o}=this,l=Math.ceil(s*this.outputScaleX*o),h=Math.ceil(a*this.outputScaleY*o);this.annotationCanvas=this.canvasFactory.create(l,h);const{canvas:d,context:c}=this.annotationCanvas;this.annotationCanvasMap.set(t,d);this.annotationCanvas.savedCtx=this.ctx;this.ctx=c;this.ctx.save();this.ctx.setTransform(n,0,0,-r,0,a*r);resetCtxToDefault(this.ctx)}else{resetCtxToDefault(this.ctx);this.endPath();this.ctx.rect(e[0],e[1],s,a);this.ctx.clip();this.ctx.beginPath()}}this.current=new CanvasExtraState(this.ctx.canvas.width,this.ctx.canvas.height);this.transform(...i);this.transform(...s)}endAnnotation(){if(this.annotationCanvas){this.ctx.restore();this.#di();this.ctx=this.annotationCanvas.savedCtx;delete this.annotationCanvas.savedCtx;delete this.annotationCanvas}}paintImageMaskXObject(t){if(!this.contentVisible)return;const e=t.count;(t=this.getObject(t.data,t)).count=e;const i=this.ctx,s=this.processingType3;if(s){void 0===s.compiled&&(s.compiled=function compileType3Glyph(t){const{width:e,height:i}=t;if(e>1e3||i>1e3)return null;const s=new Uint8Array([0,2,4,0,1,0,5,4,8,10,0,8,0,2,1,0]),n=e+1;let a,r,o,l=new Uint8Array(n*(i+1));const h=e+7&-8;let d=new Uint8Array(h*i),c=0;for(const e of t.data){let t=128;for(;t>0;){d[c++]=e&t?0:255;t>>=1}}let u=0;c=0;if(0!==d[c]){l[0]=1;++u}for(r=1;r>2)+(d[c+1]?4:0)+(d[c-h+1]?8:0);if(s[t]){l[o+r]=s[t];++u}c++}if(d[c-h]!==d[c]){l[o+r]=d[c]?2:4;++u}if(u>1e3)return null}c=h*(i-1);o=a*n;if(0!==d[c]){l[o]=8;++u}for(r=1;r1e3)return null;const p=new Int32Array([0,n,-1,0,-n,0,0,0,1]),g=new Path2D;for(a=0;u&&a<=i;a++){let t=a*n;const i=t+e;for(;t>4;l[t]&=r>>2|r<<2}g.lineTo(t%n,t/n|0);l[t]||--u}while(s!==t);--a}d=null;l=null;return function(t){t.save();t.scale(1/e,-1/i);t.translate(0,-i);t.fill(g);t.beginPath();t.restore()}}(t));if(s.compiled){s.compiled(i);return}}const n=this._createMaskCanvas(t),a=n.canvas;i.save();i.setTransform(1,0,0,1,0,0);i.drawImage(a,n.offsetX,n.offsetY);i.restore();this.compose()}paintImageMaskXObjectRepeat(t,e,i=0,s=0,n,a){if(!this.contentVisible)return;t=this.getObject(t.data,t);const r=this.ctx;r.save();const o=getCurrentTransform(r);r.transform(e,i,s,n,0,0);const l=this._createMaskCanvas(t);r.setTransform(1,0,0,1,l.offsetX-o[4],l.offsetY-o[5]);for(let t=0,h=a.length;te?h/e:1;r=l>e?l/e:1}}this._cachedScaleForStroking[0]=a;this._cachedScaleForStroking[1]=r}return this._cachedScaleForStroking}rescaleAndStroke(t){const{ctx:e}=this,{lineWidth:i}=this.current,[s,n]=this.getScaleForStroking();e.lineWidth=i||1;if(1===s&&1===n){e.stroke();return}const a=e.getLineDash();t&&e.save();e.scale(s,n);if(a.length>0){const t=Math.max(s,n);e.setLineDash(a.map((e=>e/t)));e.lineDashOffset/=t}e.stroke();t&&e.restore()}isContentVisible(){for(let t=this.markedContentStack.length-1;t>=0;t--)if(!this.markedContentStack[t].visible)return!1;return!0}}for(const t in X)void 0!==CanvasGraphics.prototype[t]&&(CanvasGraphics.prototype[X[t]]=CanvasGraphics.prototype[t]);class GlobalWorkerOptions{static#ui=null;static#pi="";static get workerPort(){return this.#ui}static set workerPort(t){if(!("undefined"!=typeof Worker&&t instanceof Worker)&&null!==t)throw new Error("Invalid `workerPort` type.");this.#ui=t}static get workerSrc(){return this.#pi}static set workerSrc(t){if("string"!=typeof t)throw new Error("Invalid `workerSrc` type.");this.#pi=t}}class Metadata{#gi;#mi;constructor({parsedData:t,rawData:e}){this.#gi=t;this.#mi=e}getRaw(){return this.#mi}get(t){return this.#gi.get(t)??null}getAll(){return objectFromMap(this.#gi)}has(t){return this.#gi.has(t)}}const Tt=Symbol("INTERNAL");class OptionalContentGroup{#fi=!1;#bi=!1;#Ai=!1;#wi=!0;constructor(t,{name:e,intent:i,usage:s,rbGroups:n}){this.#fi=!!(t&r);this.#bi=!!(t&o);this.name=e;this.intent=i;this.usage=s;this.rbGroups=n}get visible(){if(this.#Ai)return this.#wi;if(!this.#wi)return!1;const{print:t,view:e}=this.usage;return this.#fi?"OFF"!==e?.viewState:!this.#bi||"OFF"!==t?.printState}_setVisible(t,e,i=!1){t!==Tt&&unreachable("Internal method `_setVisible` called.");this.#Ai=i;this.#wi=e}}class OptionalContentConfig{#vi=null;#yi=new Map;#xi=null;#_i=null;constructor(t,e=r){this.renderingIntent=e;this.name=null;this.creator=null;if(null!==t){this.name=t.name;this.creator=t.creator;this.#_i=t.order;for(const i of t.groups)this.#yi.set(i.id,new OptionalContentGroup(e,i));if("OFF"===t.baseState)for(const t of this.#yi.values())t._setVisible(Tt,!1);for(const e of t.on)this.#yi.get(e)._setVisible(Tt,!0);for(const e of t.off)this.#yi.get(e)._setVisible(Tt,!1);this.#xi=this.getHash()}}#Ei(t){const e=t.length;if(e<2)return!0;const i=t[0];for(let s=1;s0?objectFromMap(this.#yi):null}getGroup(t){return this.#yi.get(t)||null}getHash(){if(null!==this.#vi)return this.#vi;const t=new MurmurHash3_64;for(const[e,i]of this.#yi)t.update(`${e}:${i.visible}`);return this.#vi=t.hexdigest()}}class PDFDataTransportStream{constructor(t,{disableRange:e=!1,disableStream:i=!1}){assert(t,'PDFDataTransportStream - missing required "pdfDataRangeTransport" argument.');const{length:s,initialData:n,progressiveDone:a,contentDispositionFilename:r}=t;this._queuedChunks=[];this._progressiveDone=a;this._contentDispositionFilename=r;if(n?.length>0){const t=n instanceof Uint8Array&&n.byteLength===n.buffer.byteLength?n.buffer:new Uint8Array(n).buffer;this._queuedChunks.push(t)}this._pdfDataRangeTransport=t;this._isStreamingSupported=!i;this._isRangeSupported=!e;this._contentLength=s;this._fullRequestReader=null;this._rangeReaders=[];t.addRangeListener(((t,e)=>{this._onReceiveData({begin:t,chunk:e})}));t.addProgressListener(((t,e)=>{this._onProgress({loaded:t,total:e})}));t.addProgressiveReadListener((t=>{this._onReceiveData({chunk:t})}));t.addProgressiveDoneListener((()=>{this._onProgressiveDone()}));t.transportReady()}_onReceiveData({begin:t,chunk:e}){const i=e instanceof Uint8Array&&e.byteLength===e.buffer.byteLength?e.buffer:new Uint8Array(e).buffer;if(void 0===t)this._fullRequestReader?this._fullRequestReader._enqueue(i):this._queuedChunks.push(i);else{assert(this._rangeReaders.some((function(e){if(e._begin!==t)return!1;e._enqueue(i);return!0})),"_onReceiveData - no `PDFDataTransportStreamRangeReader` instance found.")}}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}_onProgress(t){void 0===t.total?this._rangeReaders[0]?.onProgress?.({loaded:t.loaded}):this._fullRequestReader?.onProgress?.({loaded:t.loaded,total:t.total})}_onProgressiveDone(){this._fullRequestReader?.progressiveDone();this._progressiveDone=!0}_removeRangeReader(t){const e=this._rangeReaders.indexOf(t);e>=0&&this._rangeReaders.splice(e,1)}getFullReader(){assert(!this._fullRequestReader,"PDFDataTransportStream.getFullReader can only be called once.");const t=this._queuedChunks;this._queuedChunks=null;return new PDFDataTransportStreamReader(this,t,this._progressiveDone,this._contentDispositionFilename)}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const i=new PDFDataTransportStreamRangeReader(this,t,e);this._pdfDataRangeTransport.requestDataRange(t,e);this._rangeReaders.push(i);return i}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeReaders.slice(0))e.cancel(t);this._pdfDataRangeTransport.abort()}}class PDFDataTransportStreamReader{constructor(t,e,i=!1,s=null){this._stream=t;this._done=i||!1;this._filename=isPdfFile(s)?s:null;this._queuedChunks=e||[];this._loaded=0;for(const t of this._queuedChunks)this._loaded+=t.byteLength;this._requests=[];this._headersReady=Promise.resolve();t._fullRequestReader=this;this.onProgress=null}_enqueue(t){if(!this._done){if(this._requests.length>0){this._requests.shift().resolve({value:t,done:!1})}else this._queuedChunks.push(t);this._loaded+=t.byteLength}}get headersReady(){return this._headersReady}get filename(){return this._filename}get isRangeSupported(){return this._stream._isRangeSupported}get isStreamingSupported(){return this._stream._isStreamingSupported}get contentLength(){return this._stream._contentLength}async read(){if(this._queuedChunks.length>0){return{value:this._queuedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}progressiveDone(){this._done||(this._done=!0)}}class PDFDataTransportStreamRangeReader{constructor(t,e,i){this._stream=t;this._begin=e;this._end=i;this._queuedChunk=null;this._requests=[];this._done=!1;this.onProgress=null}_enqueue(t){if(!this._done){if(0===this._requests.length)this._queuedChunk=t;else{this._requests.shift().resolve({value:t,done:!1});for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}this._done=!0;this._stream._removeRangeReader(this)}}get isStreamingSupported(){return!1}async read(){if(this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._stream._removeRangeReader(this)}}function createHeaders(t,e){const i=new Headers;if(!t||!e||"object"!=typeof e)return i;for(const t in e){const s=e[t];void 0!==s&&i.append(t,s)}return i}function getResponseOrigin(t){try{return new URL(t).origin}catch{}return null}function validateRangeRequestCapabilities({responseHeaders:t,isHttp:e,rangeChunkSize:i,disableRange:s}){const n={allowRangeRequests:!1,suggestedLength:void 0},a=parseInt(t.get("Content-Length"),10);if(!Number.isInteger(a))return n;n.suggestedLength=a;if(a<=2*i)return n;if(s||!e)return n;if("bytes"!==t.get("Accept-Ranges"))return n;if("identity"!==(t.get("Content-Encoding")||"identity"))return n;n.allowRangeRequests=!0;return n}function extractFilenameFromHeader(t){const e=t.get("Content-Disposition");if(e){let t=function getFilenameFromContentDispositionHeader(t){let e=!0,i=toParamRegExp("filename\\*","i").exec(t);if(i){i=i[1];let t=rfc2616unquote(i);t=unescape(t);t=rfc5987decode(t);t=rfc2047decode(t);return fixupEncoding(t)}i=function rfc2231getparam(t){const e=[];let i;const s=toParamRegExp("filename\\*((?!0\\d)\\d+)(\\*?)","ig");for(;null!==(i=s.exec(t));){let[,t,s,n]=i;t=parseInt(t,10);if(t in e){if(0===t)break}else e[t]=[s,n]}const n=[];for(let t=0;t{t._responseOrigin=getResponseOrigin(e.url);if(!validateResponseStatus(e.status))throw createResponseStatusError(e.status,s);this._reader=e.body.getReader();this._headersCapability.resolve();const i=e.headers,{allowRangeRequests:n,suggestedLength:a}=validateRangeRequestCapabilities({responseHeaders:i,isHttp:t.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});this._isRangeSupported=n;this._contentLength=a||this._contentLength;this._filename=extractFilenameFromHeader(i);!this._isStreamingSupported&&this._isRangeSupported&&this.cancel(new AbortException("Streaming is disabled."))})).catch(this._headersCapability.reject);this.onProgress=null}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._headersCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:getArrayBuffer(t),done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}class PDFFetchStreamRangeReader{constructor(t,e,i){this._stream=t;this._reader=null;this._loaded=0;const s=t.source;this._withCredentials=s.withCredentials||!1;this._readCapability=Promise.withResolvers();this._isStreamingSupported=!s.disableStream;this._abortController=new AbortController;const n=new Headers(t.headers);n.append("Range",`bytes=${e}-${i-1}`);const a=s.url;fetch(a,createFetchOptions(n,this._withCredentials,this._abortController)).then((e=>{const i=getResponseOrigin(e.url);if(i!==t._responseOrigin)throw new Error(`Expected range response-origin "${i}" to match "${t._responseOrigin}".`);if(!validateResponseStatus(e.status))throw createResponseStatusError(e.status,a);this._readCapability.resolve();this._reader=e.body.getReader()})).catch(this._readCapability.reject);this.onProgress=null}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded});return{value:getArrayBuffer(t),done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}class NetworkManager{_responseOrigin=null;constructor({url:t,httpHeaders:e,withCredentials:i}){this.url=t;this.isHttp=/^https?:/i.test(t);this.headers=createHeaders(this.isHttp,e);this.withCredentials=i||!1;this.currXhrId=0;this.pendingRequests=Object.create(null)}request(t){const e=new XMLHttpRequest,i=this.currXhrId++,s=this.pendingRequests[i]={xhr:e};e.open("GET",this.url);e.withCredentials=this.withCredentials;for(const[t,i]of this.headers)e.setRequestHeader(t,i);if(this.isHttp&&"begin"in t&&"end"in t){e.setRequestHeader("Range",`bytes=${t.begin}-${t.end-1}`);s.expectedStatus=206}else s.expectedStatus=200;e.responseType="arraybuffer";assert(t.onError,"Expected `onError` callback to be provided.");e.onerror=()=>{t.onError(e.status)};e.onreadystatechange=this.onStateChange.bind(this,i);e.onprogress=this.onProgress.bind(this,i);s.onHeadersReceived=t.onHeadersReceived;s.onDone=t.onDone;s.onError=t.onError;s.onProgress=t.onProgress;e.send(null);return i}onProgress(t,e){const i=this.pendingRequests[t];i&&i.onProgress?.(e)}onStateChange(t,e){const i=this.pendingRequests[t];if(!i)return;const s=i.xhr;if(s.readyState>=2&&i.onHeadersReceived){i.onHeadersReceived();delete i.onHeadersReceived}if(4!==s.readyState)return;if(!(t in this.pendingRequests))return;delete this.pendingRequests[t];if(0===s.status&&this.isHttp){i.onError(s.status);return}const n=s.status||200;if(!(200===n&&206===i.expectedStatus)&&n!==i.expectedStatus){i.onError(s.status);return}const a=function network_getArrayBuffer(t){const e=t.response;return"string"!=typeof e?e:stringToBytes(e).buffer}(s);if(206===n){const t=s.getResponseHeader("Content-Range"),e=/bytes (\d+)-(\d+)\/(\d+)/.exec(t);if(e)i.onDone({begin:parseInt(e[1],10),chunk:a});else{warn('Missing or invalid "Content-Range" header.');i.onError(0)}}else a?i.onDone({begin:0,chunk:a}):i.onError(s.status)}getRequestXhr(t){return this.pendingRequests[t].xhr}isPendingRequest(t){return t in this.pendingRequests}abortRequest(t){const e=this.pendingRequests[t].xhr;delete this.pendingRequests[t];e.abort()}}class PDFNetworkStream{constructor(t){this._source=t;this._manager=new NetworkManager(t);this._rangeChunkSize=t.rangeChunkSize;this._fullRequestReader=null;this._rangeRequestReaders=[]}_onRangeRequestReaderClosed(t){const e=this._rangeRequestReaders.indexOf(t);e>=0&&this._rangeRequestReaders.splice(e,1)}getFullReader(){assert(!this._fullRequestReader,"PDFNetworkStream.getFullReader can only be called once.");this._fullRequestReader=new PDFNetworkStreamFullRequestReader(this._manager,this._source);return this._fullRequestReader}getRangeReader(t,e){const i=new PDFNetworkStreamRangeRequestReader(this._manager,t,e);i.onClosed=this._onRangeRequestReaderClosed.bind(this);this._rangeRequestReaders.push(i);return i}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}}class PDFNetworkStreamFullRequestReader{constructor(t,e){this._manager=t;this._url=e.url;this._fullRequestId=t.request({onHeadersReceived:this._onHeadersReceived.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)});this._headersCapability=Promise.withResolvers();this._disableRange=e.disableRange||!1;this._contentLength=e.length;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!1;this._isRangeSupported=!1;this._cachedChunks=[];this._requests=[];this._done=!1;this._storedError=void 0;this._filename=null;this.onProgress=null}_onHeadersReceived(){const t=this._fullRequestId,e=this._manager.getRequestXhr(t);this._manager._responseOrigin=getResponseOrigin(e.responseURL);const i=e.getAllResponseHeaders(),s=new Headers(i?i.trimStart().replace(/[^\S ]+$/,"").split(/[\r\n]+/).map((t=>{const[e,...i]=t.split(": ");return[e,i.join(": ")]})):[]),{allowRangeRequests:n,suggestedLength:a}=validateRangeRequestCapabilities({responseHeaders:s,isHttp:this._manager.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});n&&(this._isRangeSupported=!0);this._contentLength=a||this._contentLength;this._filename=extractFilenameFromHeader(s);this._isRangeSupported&&this._manager.abortRequest(t);this._headersCapability.resolve()}_onDone(t){if(t)if(this._requests.length>0){this._requests.shift().resolve({value:t.chunk,done:!1})}else this._cachedChunks.push(t.chunk);this._done=!0;if(!(this._cachedChunks.length>0)){for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}}_onError(t){this._storedError=createResponseStatusError(t,this._url);this._headersCapability.reject(this._storedError);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._cachedChunks.length=0}_onProgress(t){this.onProgress?.({loaded:t.loaded,total:t.lengthComputable?t.total:this._contentLength})}get filename(){return this._filename}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}get contentLength(){return this._contentLength}get headersReady(){return this._headersCapability.promise}async read(){await this._headersCapability.promise;if(this._storedError)throw this._storedError;if(this._cachedChunks.length>0){return{value:this._cachedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;this._headersCapability.reject(t);for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._fullRequestId)&&this._manager.abortRequest(this._fullRequestId);this._fullRequestReader=null}}class PDFNetworkStreamRangeRequestReader{constructor(t,e,i){this._manager=t;this._url=t.url;this._requestId=t.request({begin:e,end:i,onHeadersReceived:this._onHeadersReceived.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)});this._requests=[];this._queuedChunk=null;this._done=!1;this._storedError=void 0;this.onProgress=null;this.onClosed=null}_onHeadersReceived(){const t=getResponseOrigin(this._manager.getRequestXhr(this._requestId)?.responseURL);if(t!==this._manager._responseOrigin){this._storedError=new Error(`Expected range response-origin "${t}" to match "${this._manager._responseOrigin}".`);this._onError(0)}}_close(){this.onClosed?.(this)}_onDone(t){const e=t.chunk;if(this._requests.length>0){this._requests.shift().resolve({value:e,done:!1})}else this._queuedChunk=e;this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._close()}_onError(t){this._storedError??=createResponseStatusError(t,this._url);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._queuedChunk=null}_onProgress(t){this.isStreamingSupported||this.onProgress?.({loaded:t.loaded})}get isStreamingSupported(){return!1}async read(){if(this._storedError)throw this._storedError;if(null!==this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._requestId)&&this._manager.abortRequest(this._requestId);this._close()}}const Mt=/^[a-z][a-z0-9\-+.]+:/i;class PDFNodeStream{constructor(t){this.source=t;this.url=function parseUrlOrPath(t){if(Mt.test(t))return new URL(t);const e=process.getBuiltinModule("url");return new URL(e.pathToFileURL(t))}(t.url);assert("file:"===this.url.protocol,"PDFNodeStream only supports file:// URLs.");this._fullRequestReader=null;this._rangeRequestReaders=[]}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}getFullReader(){assert(!this._fullRequestReader,"PDFNodeStream.getFullReader can only be called once.");this._fullRequestReader=new PDFNodeStreamFsFullReader(this);return this._fullRequestReader}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const i=new PDFNodeStreamFsRangeReader(this,t,e);this._rangeRequestReaders.push(i);return i}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}}class PDFNodeStreamFsFullReader{constructor(t){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;const e=t.source;this._contentLength=e.length;this._loaded=0;this._filename=null;this._disableRange=e.disableRange||!1;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!e.disableStream;this._isRangeSupported=!e.disableRange;this._readableStream=null;this._readCapability=Promise.withResolvers();this._headersCapability=Promise.withResolvers();const i=process.getBuiltinModule("fs");i.promises.lstat(this._url).then((t=>{this._contentLength=t.size;this._setReadableStream(i.createReadStream(this._url));this._headersCapability.resolve()}),(t=>{"ENOENT"===t.code&&(t=new MissingPDFException(`Missing PDF "${this._url}".`));this._storedError=t;this._headersCapability.reject(t)}))}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=Promise.withResolvers();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));!this._isStreamingSupported&&this._isRangeSupported&&this._error(new AbortException("streaming is disabled"));this._storedError&&this._readableStream.destroy(this._storedError)}}class PDFNodeStreamFsRangeReader{constructor(t,e,i){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;this._loaded=0;this._readableStream=null;this._readCapability=Promise.withResolvers();const s=t.source;this._isStreamingSupported=!s.disableStream;const n=process.getBuiltinModule("fs");this._setReadableStream(n.createReadStream(this._url,{start:e,end:i-1}))}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=Promise.withResolvers();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));this._storedError&&this._readableStream.destroy(this._storedError)}}const Pt=30;class TextLayer{#Si=Promise.withResolvers();#pt=null;#Ci=!1;#Ti=!!globalThis.FontInspector?.enabled;#Mi=null;#Pi=null;#Di=0;#ki=0;#Ri=null;#Ii=null;#Fi=0;#Li=0;#Oi=Object.create(null);#Ni=[];#Bi=null;#Hi=[];#zi=new WeakMap;#Ui=null;static#Gi=new Map;static#$i=new Map;static#Vi=new WeakMap;static#ji=null;static#Wi=new Set;constructor({textContentSource:t,container:e,viewport:i}){if(t instanceof ReadableStream)this.#Bi=t;else{if("object"!=typeof t)throw new Error('No "textContentSource" parameter specified.');this.#Bi=new ReadableStream({start(e){e.enqueue(t);e.close()}})}this.#pt=this.#Ii=e;this.#Li=i.scale*(globalThis.devicePixelRatio||1);this.#Fi=i.rotation;this.#Pi={div:null,properties:null,ctx:null};const{pageWidth:s,pageHeight:n,pageX:a,pageY:r}=i.rawDims;this.#Ui=[1,0,0,-1,-a,r+n];this.#ki=s;this.#Di=n;TextLayer.#qi();setLayerDimensions(e,i);this.#Si.promise.finally((()=>{TextLayer.#Wi.delete(this);this.#Pi=null;this.#Oi=null})).catch((()=>{}))}static get fontFamilyMap(){const{isWindows:t,isFirefox:e}=util_FeatureTest.platform;return shadow(this,"fontFamilyMap",new Map([["sans-serif",(t&&e?"Calibri, ":"")+"sans-serif"],["monospace",(t&&e?"Lucida Console, ":"")+"monospace"]]))}render(){const pump=()=>{this.#Ri.read().then((({value:t,done:e})=>{if(e)this.#Si.resolve();else{this.#Mi??=t.lang;Object.assign(this.#Oi,t.styles);this.#Xi(t.items);pump()}}),this.#Si.reject)};this.#Ri=this.#Bi.getReader();TextLayer.#Wi.add(this);pump();return this.#Si.promise}update({viewport:t,onBefore:e=null}){const i=t.scale*(globalThis.devicePixelRatio||1),s=t.rotation;if(s!==this.#Fi){e?.();this.#Fi=s;setLayerDimensions(this.#Ii,{rotation:s})}if(i!==this.#Li){e?.();this.#Li=i;const t={div:null,properties:null,ctx:TextLayer.#Ki(this.#Mi)};for(const e of this.#Hi){t.properties=this.#zi.get(e);t.div=e;this.#Yi(t)}}}cancel(){const t=new AbortException("TextLayer task cancelled.");this.#Ri?.cancel(t).catch((()=>{}));this.#Ri=null;this.#Si.reject(t)}get textDivs(){return this.#Hi}get textContentItemsStr(){return this.#Ni}#Xi(t){if(this.#Ci)return;this.#Pi.ctx??=TextLayer.#Ki(this.#Mi);const e=this.#Hi,i=this.#Ni;for(const s of t){if(e.length>1e5){warn("Ignoring additional textDivs for performance reasons.");this.#Ci=!0;return}if(void 0!==s.str){i.push(s.str);this.#Qi(s)}else if("beginMarkedContentProps"===s.type||"beginMarkedContent"===s.type){const t=this.#pt;this.#pt=document.createElement("span");this.#pt.classList.add("markedContent");null!==s.id&&this.#pt.setAttribute("id",`${s.id}`);t.append(this.#pt)}else"endMarkedContent"===s.type&&(this.#pt=this.#pt.parentNode)}}#Qi(t){const e=document.createElement("span"),i={angle:0,canvasWidth:0,hasText:""!==t.str,hasEOL:t.hasEOL,fontSize:0};this.#Hi.push(e);const s=Util.transform(this.#Ui,t.transform);let n=Math.atan2(s[1],s[0]);const a=this.#Oi[t.fontName];a.vertical&&(n+=Math.PI/2);let r=this.#Ti&&a.fontSubstitution||a.fontFamily;r=TextLayer.fontFamilyMap.get(r)||r;const o=Math.hypot(s[2],s[3]),l=o*TextLayer.#Ji(r,this.#Mi);let h,d;if(0===n){h=s[4];d=s[5]-l}else{h=s[4]+l*Math.sin(n);d=s[5]-l*Math.cos(n)}const c="calc(var(--scale-factor)*",u=e.style;if(this.#pt===this.#Ii){u.left=`${(100*h/this.#ki).toFixed(2)}%`;u.top=`${(100*d/this.#Di).toFixed(2)}%`}else{u.left=`${c}${h.toFixed(2)}px)`;u.top=`${c}${d.toFixed(2)}px)`}u.fontSize=`${c}${(TextLayer.#ji*o).toFixed(2)}px)`;u.fontFamily=r;i.fontSize=o;e.setAttribute("role","presentation");e.textContent=t.str;e.dir=t.dir;this.#Ti&&(e.dataset.fontName=a.fontSubstitutionLoadedName||t.fontName);0!==n&&(i.angle=n*(180/Math.PI));let p=!1;if(t.str.length>1)p=!0;else if(" "!==t.str&&t.transform[0]!==t.transform[3]){const e=Math.abs(t.transform[0]),i=Math.abs(t.transform[3]);e!==i&&Math.max(e,i)/Math.min(e,i)>1.5&&(p=!0)}p&&(i.canvasWidth=a.vertical?t.height:t.width);this.#zi.set(e,i);this.#Pi.div=e;this.#Pi.properties=i;this.#Yi(this.#Pi);i.hasText&&this.#pt.append(e);if(i.hasEOL){const t=document.createElement("br");t.setAttribute("role","presentation");this.#pt.append(t)}}#Yi(t){const{div:e,properties:i,ctx:s}=t,{style:n}=e;let a="";TextLayer.#ji>1&&(a=`scale(${1/TextLayer.#ji})`);if(0!==i.canvasWidth&&i.hasText){const{fontFamily:t}=n,{canvasWidth:r,fontSize:o}=i;TextLayer.#Zi(s,o*this.#Li,t);const{width:l}=s.measureText(e.textContent);l>0&&(a=`scaleX(${r*this.#Li/l}) ${a}`)}0!==i.angle&&(a=`rotate(${i.angle}deg) ${a}`);a.length>0&&(n.transform=a)}static cleanup(){if(!(this.#Wi.size>0)){this.#Gi.clear();for(const{canvas:t}of this.#$i.values())t.remove();this.#$i.clear()}}static#Ki(t=null){let e=this.#$i.get(t||="");if(!e){const i=document.createElement("canvas");i.className="hiddenCanvasElement";i.lang=t;document.body.append(i);e=i.getContext("2d",{alpha:!1,willReadFrequently:!0});this.#$i.set(t,e);this.#Vi.set(e,{size:0,family:""})}return e}static#Zi(t,e,i){const s=this.#Vi.get(t);if(e!==s.size||i!==s.family){t.font=`${e}px ${i}`;s.size=e;s.family=i}}static#qi(){if(null!==this.#ji)return;const t=document.createElement("div");t.style.opacity=0;t.style.lineHeight=1;t.style.fontSize="1px";t.style.position="absolute";t.textContent="X";document.body.append(t);this.#ji=t.getBoundingClientRect().height;t.remove()}static#Ji(t,e){const i=this.#Gi.get(t);if(i)return i;const s=this.#Ki(e);s.canvas.width=s.canvas.height=Pt;this.#Zi(s,Pt,t);const n=s.measureText("");let a=n.fontBoundingBoxAscent,r=Math.abs(n.fontBoundingBoxDescent);if(a){const e=a/(a+r);this.#Gi.set(t,e);s.canvas.width=s.canvas.height=0;return e}s.strokeStyle="red";s.clearRect(0,0,Pt,Pt);s.strokeText("g",0,0);let o=s.getImageData(0,0,Pt,Pt).data;r=0;for(let t=o.length-1-3;t>=0;t-=4)if(o[t]>0){r=Math.ceil(t/4/Pt);break}s.clearRect(0,0,Pt,Pt);s.strokeText("A",0,Pt);o=s.getImageData(0,0,Pt,Pt).data;a=0;for(let t=0,e=o.length;t0){a=Pt-Math.floor(t/4/Pt);break}s.canvas.width=s.canvas.height=0;const l=a?a/(a+r):.8;this.#Gi.set(t,l);return l}}class XfaText{static textContent(t){const e=[],i={items:e,styles:Object.create(null)};!function walk(t){if(!t)return;let i=null;const s=t.name;if("#text"===s)i=t.value;else{if(!XfaText.shouldBuildText(s))return;t?.attributes?.textContent?i=t.attributes.textContent:t.value&&(i=t.value)}null!==i&&e.push({str:i});if(t.children)for(const e of t.children)walk(e)}(t);return i}static shouldBuildText(t){return!("textarea"===t||"input"===t||"option"===t||"select"===t)}}const Dt=65536,kt=e?class NodeCanvasFactory extends BaseCanvasFactory{_createCanvas(t,e){return process.getBuiltinModule("module").createRequire(import.meta.url)("@napi-rs/canvas").createCanvas(t,e)}}:class DOMCanvasFactory extends BaseCanvasFactory{constructor({ownerDocument:t=globalThis.document,enableHWA:e=!1}){super({enableHWA:e});this._document=t}_createCanvas(t,e){const i=this._document.createElement("canvas");i.width=t;i.height=e;return i}},Rt=e?class NodeCMapReaderFactory extends BaseCMapReaderFactory{async _fetch(t){return node_utils_fetchData(t)}}:DOMCMapReaderFactory,It=e?class NodeFilterFactory extends BaseFilterFactory{}:class DOMFilterFactory extends BaseFilterFactory{#ts;#es;#is;#ss;#ns;#as;#w=0;constructor({docId:t,ownerDocument:e=globalThis.document}){super();this.#ss=t;this.#ns=e}get#y(){return this.#es||=new Map}get#rs(){return this.#as||=new Map}get#os(){if(!this.#is){const t=this.#ns.createElement("div"),{style:e}=t;e.visibility="hidden";e.contain="strict";e.width=e.height=0;e.position="absolute";e.top=e.left=0;e.zIndex=-1;const i=this.#ns.createElementNS(it,"svg");i.setAttribute("width",0);i.setAttribute("height",0);this.#is=this.#ns.createElementNS(it,"defs");t.append(i);i.append(this.#is);this.#ns.body.append(t)}return this.#is}#ls(t){if(1===t.length){const e=t[0],i=new Array(256);for(let t=0;t<256;t++)i[t]=e[t]/255;const s=i.join(",");return[s,s,s]}const[e,i,s]=t,n=new Array(256),a=new Array(256),r=new Array(256);for(let t=0;t<256;t++){n[t]=e[t]/255;a[t]=i[t]/255;r[t]=s[t]/255}return[n.join(","),a.join(","),r.join(",")]}#hs(t){if(void 0===this.#ts){this.#ts="";const t=this.#ns.URL;t!==this.#ns.baseURI&&(isDataScheme(t)?warn('#createUrl: ignore "data:"-URL for performance reasons.'):this.#ts=t.split("#",1)[0])}return`url(${this.#ts}#${t})`}addFilter(t){if(!t)return"none";let e=this.#y.get(t);if(e)return e;const[i,s,n]=this.#ls(t),a=1===t.length?i:`${i}${s}${n}`;e=this.#y.get(a);if(e){this.#y.set(t,e);return e}const r=`g_${this.#ss}_transfer_map_${this.#w++}`,o=this.#hs(r);this.#y.set(t,o);this.#y.set(a,o);const l=this.#ds(r);this.#cs(i,s,n,l);return o}addHCMFilter(t,e){const i=`${t}-${e}`,s="base";let n=this.#rs.get(s);if(n?.key===i)return n.url;if(n){n.filter?.remove();n.key=i;n.url="none";n.filter=null}else{n={key:i,url:"none",filter:null};this.#rs.set(s,n)}if(!t||!e)return n.url;const a=this.#us(t);t=Util.makeHexColor(...a);const r=this.#us(e);e=Util.makeHexColor(...r);this.#os.style.color="";if("#000000"===t&&"#ffffff"===e||t===e)return n.url;const o=new Array(256);for(let t=0;t<=255;t++){const e=t/255;o[t]=e<=.03928?e/12.92:((e+.055)/1.055)**2.4}const l=o.join(","),h=`g_${this.#ss}_hcm_filter`,d=n.filter=this.#ds(h);this.#cs(l,l,l,d);this.#ps(d);const getSteps=(t,e)=>{const i=a[t]/255,s=r[t]/255,n=new Array(e+1);for(let t=0;t<=e;t++)n[t]=i+t/e*(s-i);return n.join(",")};this.#cs(getSteps(0,5),getSteps(1,5),getSteps(2,5),d);n.url=this.#hs(h);return n.url}addAlphaFilter(t){let e=this.#y.get(t);if(e)return e;const[i]=this.#ls([t]),s=`alpha_${i}`;e=this.#y.get(s);if(e){this.#y.set(t,e);return e}const n=`g_${this.#ss}_alpha_map_${this.#w++}`,a=this.#hs(n);this.#y.set(t,a);this.#y.set(s,a);const r=this.#ds(n);this.#gs(i,r);return a}addLuminosityFilter(t){let e,i,s=this.#y.get(t||"luminosity");if(s)return s;if(t){[e]=this.#ls([t]);i=`luminosity_${e}`}else i="luminosity";s=this.#y.get(i);if(s){this.#y.set(t,s);return s}const n=`g_${this.#ss}_luminosity_map_${this.#w++}`,a=this.#hs(n);this.#y.set(t,a);this.#y.set(i,a);const r=this.#ds(n);this.#ms(r);t&&this.#gs(e,r);return a}addHighlightHCMFilter(t,e,i,s,n){const a=`${e}-${i}-${s}-${n}`;let r=this.#rs.get(t);if(r?.key===a)return r.url;if(r){r.filter?.remove();r.key=a;r.url="none";r.filter=null}else{r={key:a,url:"none",filter:null};this.#rs.set(t,r)}if(!e||!i)return r.url;const[o,l]=[e,i].map(this.#us.bind(this));let h=Math.round(.2126*o[0]+.7152*o[1]+.0722*o[2]),d=Math.round(.2126*l[0]+.7152*l[1]+.0722*l[2]),[c,u]=[s,n].map(this.#us.bind(this));d{const s=new Array(256),n=(d-h)/i,a=t/255,r=(e-t)/(255*i);let o=0;for(let t=0;t<=i;t++){const e=Math.round(h+t*n),i=a+t*r;for(let t=o;t<=e;t++)s[t]=i;o=e+1}for(let t=o;t<256;t++)s[t]=s[o-1];return s.join(",")},p=`g_${this.#ss}_hcm_${t}_filter`,g=r.filter=this.#ds(p);this.#ps(g);this.#cs(getSteps(c[0],u[0],5),getSteps(c[1],u[1],5),getSteps(c[2],u[2],5),g);r.url=this.#hs(p);return r.url}destroy(t=!1){if(!t||!this.#as?.size){this.#is?.parentNode.parentNode.remove();this.#is=null;this.#es?.clear();this.#es=null;this.#as?.clear();this.#as=null;this.#w=0}}#ms(t){const e=this.#ns.createElementNS(it,"feColorMatrix");e.setAttribute("type","matrix");e.setAttribute("values","0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.59 0.11 0 0");t.append(e)}#ps(t){const e=this.#ns.createElementNS(it,"feColorMatrix");e.setAttribute("type","matrix");e.setAttribute("values","0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0");t.append(e)}#ds(t){const e=this.#ns.createElementNS(it,"filter");e.setAttribute("color-interpolation-filters","sRGB");e.setAttribute("id",t);this.#os.append(e);return e}#fs(t,e,i){const s=this.#ns.createElementNS(it,e);s.setAttribute("type","discrete");s.setAttribute("tableValues",i);t.append(s)}#cs(t,e,i,s){const n=this.#ns.createElementNS(it,"feComponentTransfer");s.append(n);this.#fs(n,"feFuncR",t);this.#fs(n,"feFuncG",e);this.#fs(n,"feFuncB",i)}#gs(t,e){const i=this.#ns.createElementNS(it,"feComponentTransfer");e.append(i);this.#fs(i,"feFuncA",t)}#us(t){this.#os.style.color=t;return getRGB(getComputedStyle(this.#os).getPropertyValue("color"))}},Ft=e?class NodeStandardFontDataFactory extends BaseStandardFontDataFactory{async _fetch(t){return node_utils_fetchData(t)}}:DOMStandardFontDataFactory;function getDocument(t={}){"string"==typeof t||t instanceof URL?t={url:t}:(t instanceof ArrayBuffer||ArrayBuffer.isView(t))&&(t={data:t});const i=new PDFDocumentLoadingTask,{docId:s}=i,n=t.url?function getUrlProp(t){if(t instanceof URL)return t.href;try{return new URL(t,window.location).href}catch{if(e&&"string"==typeof t)return t}throw new Error("Invalid PDF url data: either string or URL-object is expected in the url property.")}(t.url):null,a=t.data?function getDataProp(t){if(e&&"undefined"!=typeof Buffer&&t instanceof Buffer)throw new Error("Please provide binary data as `Uint8Array`, rather than `Buffer`.");if(t instanceof Uint8Array&&t.byteLength===t.buffer.byteLength)return t;if("string"==typeof t)return stringToBytes(t);if(t instanceof ArrayBuffer||ArrayBuffer.isView(t)||"object"==typeof t&&!isNaN(t?.length))return new Uint8Array(t);throw new Error("Invalid PDF binary data: either TypedArray, string, or array-like object is expected in the data property.")}(t.data):null,r=t.httpHeaders||null,o=!0===t.withCredentials,l=t.password??null,h=t.range instanceof PDFDataRangeTransport?t.range:null,d=Number.isInteger(t.rangeChunkSize)&&t.rangeChunkSize>0?t.rangeChunkSize:Dt;let c=t.worker instanceof PDFWorker?t.worker:null;const u=t.verbosity,p="string"!=typeof t.docBaseUrl||isDataScheme(t.docBaseUrl)?null:t.docBaseUrl,g="string"==typeof t.cMapUrl?t.cMapUrl:null,m=!1!==t.cMapPacked,f=t.CMapReaderFactory||Rt,b="string"==typeof t.standardFontDataUrl?t.standardFontDataUrl:null,A=t.StandardFontDataFactory||Ft,w=!0!==t.stopAtErrors,v=Number.isInteger(t.maxImageSize)&&t.maxImageSize>-1?t.maxImageSize:-1,y=!1!==t.isEvalSupported,x="boolean"==typeof t.isOffscreenCanvasSupported?t.isOffscreenCanvasSupported:!e,_="boolean"==typeof t.isImageDecoderSupported?t.isImageDecoderSupported:!e&&(util_FeatureTest.platform.isFirefox||!globalThis.chrome),E=Number.isInteger(t.canvasMaxAreaInBytes)?t.canvasMaxAreaInBytes:-1,S="boolean"==typeof t.disableFontFace?t.disableFontFace:e,C=!0===t.fontExtraProperties,T=!0===t.enableXfa,M=t.ownerDocument||globalThis.document,P=!0===t.disableRange,D=!0===t.disableStream,k=!0===t.disableAutoFetch,R=!0===t.pdfBug,I=t.CanvasFactory||kt,F=t.FilterFactory||It,L=!0===t.enableHWA,O=h?h.length:t.length??NaN,N="boolean"==typeof t.useSystemFonts?t.useSystemFonts:!e&&!S,B="boolean"==typeof t.useWorkerFetch?t.useWorkerFetch:f===DOMCMapReaderFactory&&A===DOMStandardFontDataFactory&&g&&b&&isValidFetchUrl(g,document.baseURI)&&isValidFetchUrl(b,document.baseURI);setVerbosityLevel(u);const H={canvasFactory:new I({ownerDocument:M,enableHWA:L}),filterFactory:new F({docId:s,ownerDocument:M}),cMapReaderFactory:B?null:new f({baseUrl:g,isCompressed:m}),standardFontDataFactory:B?null:new A({baseUrl:b})};if(!c){const t={verbosity:u,port:GlobalWorkerOptions.workerPort};c=t.port?PDFWorker.fromPort(t):new PDFWorker(t);i._worker=c}const z={docId:s,apiVersion:"4.10.38",data:a,password:l,disableAutoFetch:k,rangeChunkSize:d,length:O,docBaseUrl:p,enableXfa:T,evaluatorOptions:{maxImageSize:v,disableFontFace:S,ignoreErrors:w,isEvalSupported:y,isOffscreenCanvasSupported:x,isImageDecoderSupported:_,canvasMaxAreaInBytes:E,fontExtraProperties:C,useSystemFonts:N,cMapUrl:B?g:null,standardFontDataUrl:B?b:null}},U={disableFontFace:S,fontExtraProperties:C,ownerDocument:M,pdfBug:R,styleElement:null,loadingParams:{disableAutoFetch:k,enableXfa:T}};c.promise.then((function(){if(i.destroyed)throw new Error("Loading aborted");if(c.destroyed)throw new Error("Worker was destroyed");const t=c.messageHandler.sendWithPromise("GetDocRequest",z,a?[a.buffer]:null);let l;if(h)l=new PDFDataTransportStream(h,{disableRange:P,disableStream:D});else if(!a){if(!n)throw new Error("getDocument - no `url` parameter provided.");let t;if(e)if(isValidFetchUrl(n)){if("undefined"==typeof fetch||"undefined"==typeof Response||!("body"in Response.prototype))throw new Error("getDocument - the Fetch API was disabled in Node.js, see `--no-experimental-fetch`.");t=PDFFetchStream}else t=PDFNodeStream;else t=isValidFetchUrl(n)?PDFFetchStream:PDFNetworkStream;l=new t({url:n,length:O,httpHeaders:r,withCredentials:o,rangeChunkSize:d,disableRange:P,disableStream:D})}return t.then((t=>{if(i.destroyed)throw new Error("Loading aborted");if(c.destroyed)throw new Error("Worker was destroyed");const e=new MessageHandler(s,t,c.port),n=new WorkerTransport(e,i,l,U,H);i._transport=n;e.send("Ready",null)}))})).catch(i._capability.reject);return i}function isRefProxy(t){return"object"==typeof t&&Number.isInteger(t?.num)&&t.num>=0&&Number.isInteger(t?.gen)&&t.gen>=0}class PDFDocumentLoadingTask{static#ss=0;constructor(){this._capability=Promise.withResolvers();this._transport=null;this._worker=null;this.docId="d"+PDFDocumentLoadingTask.#ss++;this.destroyed=!1;this.onPassword=null;this.onProgress=null}get promise(){return this._capability.promise}async destroy(){this.destroyed=!0;try{this._worker?.port&&(this._worker._pendingDestroy=!0);await(this._transport?.destroy())}catch(t){this._worker?.port&&delete this._worker._pendingDestroy;throw t}this._transport=null;this._worker?.destroy();this._worker=null}}class PDFDataRangeTransport{constructor(t,e,i=!1,s=null){this.length=t;this.initialData=e;this.progressiveDone=i;this.contentDispositionFilename=s;this._rangeListeners=[];this._progressListeners=[];this._progressiveReadListeners=[];this._progressiveDoneListeners=[];this._readyCapability=Promise.withResolvers()}addRangeListener(t){this._rangeListeners.push(t)}addProgressListener(t){this._progressListeners.push(t)}addProgressiveReadListener(t){this._progressiveReadListeners.push(t)}addProgressiveDoneListener(t){this._progressiveDoneListeners.push(t)}onDataRange(t,e){for(const i of this._rangeListeners)i(t,e)}onDataProgress(t,e){this._readyCapability.promise.then((()=>{for(const i of this._progressListeners)i(t,e)}))}onDataProgressiveRead(t){this._readyCapability.promise.then((()=>{for(const e of this._progressiveReadListeners)e(t)}))}onDataProgressiveDone(){this._readyCapability.promise.then((()=>{for(const t of this._progressiveDoneListeners)t()}))}transportReady(){this._readyCapability.resolve()}requestDataRange(t,e){unreachable("Abstract method PDFDataRangeTransport.requestDataRange")}abort(){}}class PDFDocumentProxy{constructor(t,e){this._pdfInfo=t;this._transport=e}get annotationStorage(){return this._transport.annotationStorage}get canvasFactory(){return this._transport.canvasFactory}get filterFactory(){return this._transport.filterFactory}get numPages(){return this._pdfInfo.numPages}get fingerprints(){return this._pdfInfo.fingerprints}get isPureXfa(){return shadow(this,"isPureXfa",!!this._transport._htmlForXfa)}get allXfaHtml(){return this._transport._htmlForXfa}getPage(t){return this._transport.getPage(t)}getPageIndex(t){return this._transport.getPageIndex(t)}getDestinations(){return this._transport.getDestinations()}getDestination(t){return this._transport.getDestination(t)}getPageLabels(){return this._transport.getPageLabels()}getPageLayout(){return this._transport.getPageLayout()}getPageMode(){return this._transport.getPageMode()}getViewerPreferences(){return this._transport.getViewerPreferences()}getOpenAction(){return this._transport.getOpenAction()}getAttachments(){return this._transport.getAttachments()}getJSActions(){return this._transport.getDocJSActions()}getOutline(){return this._transport.getOutline()}getOptionalContentConfig({intent:t="display"}={}){const{renderingIntent:e}=this._transport.getRenderingIntent(t);return this._transport.getOptionalContentConfig(e)}getPermissions(){return this._transport.getPermissions()}getMetadata(){return this._transport.getMetadata()}getMarkInfo(){return this._transport.getMarkInfo()}getData(){return this._transport.getData()}saveDocument(){return this._transport.saveDocument()}getDownloadInfo(){return this._transport.downloadInfoCapability.promise}cleanup(t=!1){return this._transport.startCleanup(t||this.isPureXfa)}destroy(){return this.loadingTask.destroy()}cachedPageNumber(t){return this._transport.cachedPageNumber(t)}get loadingParams(){return this._transport.loadingParams}get loadingTask(){return this._transport.loadingTask}getFieldObjects(){return this._transport.getFieldObjects()}hasJSActions(){return this._transport.hasJSActions()}getCalculationOrderIds(){return this._transport.getCalculationOrderIds()}}class PDFPageProxy{#bs=null;#As=!1;constructor(t,e,i,s=!1){this._pageIndex=t;this._pageInfo=e;this._transport=i;this._stats=s?new StatTimer:null;this._pdfBug=s;this.commonObjs=i.commonObjs;this.objs=new PDFObjects;this._maybeCleanupAfterRender=!1;this._intentStates=new Map;this.destroyed=!1}get pageNumber(){return this._pageIndex+1}get rotate(){return this._pageInfo.rotate}get ref(){return this._pageInfo.ref}get userUnit(){return this._pageInfo.userUnit}get view(){return this._pageInfo.view}getViewport({scale:t,rotation:e=this.rotate,offsetX:i=0,offsetY:s=0,dontFlip:n=!1}={}){return new PageViewport({viewBox:this.view,userUnit:this.userUnit,scale:t,rotation:e,offsetX:i,offsetY:s,dontFlip:n})}getAnnotations({intent:t="display"}={}){const{renderingIntent:e}=this._transport.getRenderingIntent(t);return this._transport.getAnnotations(this._pageIndex,e)}getJSActions(){return this._transport.getPageJSActions(this._pageIndex)}get filterFactory(){return this._transport.filterFactory}get isPureXfa(){return shadow(this,"isPureXfa",!!this._transport._htmlForXfa)}async getXfa(){return this._transport._htmlForXfa?.children[this._pageIndex]||null}render({canvasContext:t,viewport:e,intent:i="display",annotationMode:s=p.ENABLE,transform:n=null,background:a=null,optionalContentConfigPromise:r=null,annotationCanvasMap:l=null,pageColors:h=null,printAnnotationStorage:d=null,isEditing:c=!1}){this._stats?.time("Overall");const u=this._transport.getRenderingIntent(i,s,d,c),{renderingIntent:g,cacheKey:m}=u;this.#As=!1;this.#ws();r||=this._transport.getOptionalContentConfig(g);let f=this._intentStates.get(m);if(!f){f=Object.create(null);this._intentStates.set(m,f)}if(f.streamReaderCancelTimeout){clearTimeout(f.streamReaderCancelTimeout);f.streamReaderCancelTimeout=null}const b=!!(g&o);if(!f.displayReadyCapability){f.displayReadyCapability=Promise.withResolvers();f.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(u)}const complete=t=>{f.renderTasks.delete(A);(this._maybeCleanupAfterRender||b)&&(this.#As=!0);this.#vs(!b);if(t){A.capability.reject(t);this._abortOperatorList({intentState:f,reason:t instanceof Error?t:new Error(t)})}else A.capability.resolve();if(this._stats){this._stats.timeEnd("Rendering");this._stats.timeEnd("Overall");globalThis.Stats?.enabled&&globalThis.Stats.add(this.pageNumber,this._stats)}},A=new InternalRenderTask({callback:complete,params:{canvasContext:t,viewport:e,transform:n,background:a},objs:this.objs,commonObjs:this.commonObjs,annotationCanvasMap:l,operatorList:f.operatorList,pageIndex:this._pageIndex,canvasFactory:this._transport.canvasFactory,filterFactory:this._transport.filterFactory,useRequestAnimationFrame:!b,pdfBug:this._pdfBug,pageColors:h});(f.renderTasks||=new Set).add(A);const w=A.task;Promise.all([f.displayReadyCapability.promise,r]).then((([t,e])=>{if(this.destroyed)complete();else{this._stats?.time("Rendering");if(!(e.renderingIntent&g))throw new Error("Must use the same `intent`-argument when calling the `PDFPageProxy.render` and `PDFDocumentProxy.getOptionalContentConfig` methods.");A.initializeGraphics({transparency:t,optionalContentConfig:e});A.operatorListChanged()}})).catch(complete);return w}getOperatorList({intent:t="display",annotationMode:e=p.ENABLE,printAnnotationStorage:i=null,isEditing:s=!1}={}){const n=this._transport.getRenderingIntent(t,e,i,s,!0);let a,r=this._intentStates.get(n.cacheKey);if(!r){r=Object.create(null);this._intentStates.set(n.cacheKey,r)}if(!r.opListReadCapability){a=Object.create(null);a.operatorListChanged=function operatorListChanged(){if(r.operatorList.lastChunk){r.opListReadCapability.resolve(r.operatorList);r.renderTasks.delete(a)}};r.opListReadCapability=Promise.withResolvers();(r.renderTasks||=new Set).add(a);r.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(n)}return r.opListReadCapability.promise}streamTextContent({includeMarkedContent:t=!1,disableNormalization:e=!1}={}){return this._transport.messageHandler.sendWithStream("GetTextContent",{pageIndex:this._pageIndex,includeMarkedContent:!0===t,disableNormalization:!0===e},{highWaterMark:100,size:t=>t.items.length})}getTextContent(t={}){if(this._transport._htmlForXfa)return this.getXfa().then((t=>XfaText.textContent(t)));const e=this.streamTextContent(t);return new Promise((function(t,i){const s=e.getReader(),n={items:[],styles:Object.create(null),lang:null};!function pump(){s.read().then((function({value:e,done:i}){if(i)t(n);else{n.lang??=e.lang;Object.assign(n.styles,e.styles);n.items.push(...e.items);pump()}}),i)}()}))}getStructTree(){return this._transport.getStructTree(this._pageIndex)}_destroy(){this.destroyed=!0;const t=[];for(const e of this._intentStates.values()){this._abortOperatorList({intentState:e,reason:new Error("Page was destroyed."),force:!0});if(!e.opListReadCapability)for(const i of e.renderTasks){t.push(i.completed);i.cancel()}}this.objs.clear();this.#As=!1;this.#ws();return Promise.all(t)}cleanup(t=!1){this.#As=!0;const e=this.#vs(!1);t&&e&&(this._stats&&=new StatTimer);return e}#vs(t=!1){this.#ws();if(!this.#As||this.destroyed)return!1;if(t){this.#bs=setTimeout((()=>{this.#bs=null;this.#vs(!1)}),5e3);return!1}for(const{renderTasks:t,operatorList:e}of this._intentStates.values())if(t.size>0||!e.lastChunk)return!1;this._intentStates.clear();this.objs.clear();this.#As=!1;return!0}#ws(){if(this.#bs){clearTimeout(this.#bs);this.#bs=null}}_startRenderPage(t,e){const i=this._intentStates.get(e);if(i){this._stats?.timeEnd("Page Request");i.displayReadyCapability?.resolve(t)}}_renderPageChunk(t,e){for(let i=0,s=t.length;i{r.read().then((({value:t,done:e})=>{if(e)o.streamReader=null;else if(!this._transport.destroyed){this._renderPageChunk(t,o);pump()}}),(t=>{o.streamReader=null;if(!this._transport.destroyed){if(o.operatorList){o.operatorList.lastChunk=!0;for(const t of o.renderTasks)t.operatorListChanged();this.#vs(!0)}if(o.displayReadyCapability)o.displayReadyCapability.reject(t);else{if(!o.opListReadCapability)throw t;o.opListReadCapability.reject(t)}}}))};pump()}_abortOperatorList({intentState:t,reason:e,force:i=!1}){if(t.streamReader){if(t.streamReaderCancelTimeout){clearTimeout(t.streamReaderCancelTimeout);t.streamReaderCancelTimeout=null}if(!i){if(t.renderTasks.size>0)return;if(e instanceof RenderingCancelledException){let i=100;e.extraDelay>0&&e.extraDelay<1e3&&(i+=e.extraDelay);t.streamReaderCancelTimeout=setTimeout((()=>{t.streamReaderCancelTimeout=null;this._abortOperatorList({intentState:t,reason:e,force:!0})}),i);return}}t.streamReader.cancel(new AbortException(e.message)).catch((()=>{}));t.streamReader=null;if(!this._transport.destroyed){for(const[e,i]of this._intentStates)if(i===t){this._intentStates.delete(e);break}this.cleanup()}}}get stats(){return this._stats}}class LoopbackPort{#ys=new Map;#xs=Promise.resolve();postMessage(t,e){const i={data:structuredClone(t,e?{transfer:e}:null)};this.#xs.then((()=>{for(const[t]of this.#ys)t.call(this,i)}))}addEventListener(t,e,i=null){let s=null;if(i?.signal instanceof AbortSignal){const{signal:n}=i;if(n.aborted){warn("LoopbackPort - cannot use an `aborted` signal.");return}const onAbort=()=>this.removeEventListener(t,e);s=()=>n.removeEventListener("abort",onAbort);n.addEventListener("abort",onAbort)}this.#ys.set(e,s)}removeEventListener(t,e){const i=this.#ys.get(e);i?.();this.#ys.delete(e)}terminate(){for(const[,t]of this.#ys)t?.();this.#ys.clear()}}class PDFWorker{static#_s=0;static#Es=!1;static#Ss;static{if(e){this.#Es=!0;GlobalWorkerOptions.workerSrc||="./pdf.worker.mjs"}this._isSameOrigin=(t,e)=>{let i;try{i=new URL(t);if(!i.origin||"null"===i.origin)return!1}catch{return!1}const s=new URL(e,i);return i.origin===s.origin};this._createCDNWrapper=t=>{const e=`await import("${t}");`;return URL.createObjectURL(new Blob([e],{type:"text/javascript"}))}}constructor({name:t=null,port:e=null,verbosity:i=getVerbosityLevel()}={}){this.name=t;this.destroyed=!1;this.verbosity=i;this._readyCapability=Promise.withResolvers();this._port=null;this._webWorker=null;this._messageHandler=null;if(e){if(PDFWorker.#Ss?.has(e))throw new Error("Cannot use more than one PDFWorker per port.");(PDFWorker.#Ss||=new WeakMap).set(e,this);this._initializeFromPort(e)}else this._initialize()}get promise(){return this._readyCapability.promise}#Cs(){this._readyCapability.resolve();this._messageHandler.send("configure",{verbosity:this.verbosity})}get port(){return this._port}get messageHandler(){return this._messageHandler}_initializeFromPort(t){this._port=t;this._messageHandler=new MessageHandler("main","worker",t);this._messageHandler.on("ready",(function(){}));this.#Cs()}_initialize(){if(PDFWorker.#Es||PDFWorker.#Ts){this._setupFakeWorker();return}let{workerSrc:t}=PDFWorker;try{PDFWorker._isSameOrigin(window.location.href,t)||(t=PDFWorker._createCDNWrapper(new URL(t,window.location).href));const e=new Worker(t,{type:"module"}),i=new MessageHandler("main","worker",e),terminateEarly=()=>{s.abort();i.destroy();e.terminate();this.destroyed?this._readyCapability.reject(new Error("Worker was destroyed")):this._setupFakeWorker()},s=new AbortController;e.addEventListener("error",(()=>{this._webWorker||terminateEarly()}),{signal:s.signal});i.on("test",(t=>{s.abort();if(!this.destroyed&&t){this._messageHandler=i;this._port=e;this._webWorker=e;this.#Cs()}else terminateEarly()}));i.on("ready",(t=>{s.abort();if(this.destroyed)terminateEarly();else try{sendTest()}catch{this._setupFakeWorker()}}));const sendTest=()=>{const t=new Uint8Array;i.send("test",t,[t.buffer])};sendTest();return}catch{info("The worker has been disabled.")}this._setupFakeWorker()}_setupFakeWorker(){if(!PDFWorker.#Es){warn("Setting up fake worker.");PDFWorker.#Es=!0}PDFWorker._setupFakeWorkerGlobal.then((t=>{if(this.destroyed){this._readyCapability.reject(new Error("Worker was destroyed"));return}const e=new LoopbackPort;this._port=e;const i="fake"+PDFWorker.#_s++,s=new MessageHandler(i+"_worker",i,e);t.setup(s,e);this._messageHandler=new MessageHandler(i,i+"_worker",e);this.#Cs()})).catch((t=>{this._readyCapability.reject(new Error(`Setting up fake worker failed: "${t.message}".`))}))}destroy(){this.destroyed=!0;this._webWorker?.terminate();this._webWorker=null;PDFWorker.#Ss?.delete(this._port);this._port=null;this._messageHandler?.destroy();this._messageHandler=null}static fromPort(t){if(!t?.port)throw new Error("PDFWorker.fromPort - invalid method signature.");const e=this.#Ss?.get(t.port);if(e){if(e._pendingDestroy)throw new Error("PDFWorker.fromPort - the worker is being destroyed.\nPlease remember to await `PDFDocumentLoadingTask.destroy()`-calls.");return e}return new PDFWorker(t)}static get workerSrc(){if(GlobalWorkerOptions.workerSrc)return GlobalWorkerOptions.workerSrc;throw new Error('No "GlobalWorkerOptions.workerSrc" specified.')}static get#Ts(){try{return globalThis.pdfjsWorker?.WorkerMessageHandler||null}catch{return null}}static get _setupFakeWorkerGlobal(){return shadow(this,"_setupFakeWorkerGlobal",(async()=>{if(this.#Ts)return this.#Ts;return(await import(this.workerSrc)).WorkerMessageHandler})())}}class WorkerTransport{#Ms=new Map;#Ps=new Map;#Ds=new Map;#ks=new Map;#Rs=null;constructor(t,e,i,s,n){this.messageHandler=t;this.loadingTask=e;this.commonObjs=new PDFObjects;this.fontLoader=new FontLoader({ownerDocument:s.ownerDocument,styleElement:s.styleElement});this.loadingParams=s.loadingParams;this._params=s;this.canvasFactory=n.canvasFactory;this.filterFactory=n.filterFactory;this.cMapReaderFactory=n.cMapReaderFactory;this.standardFontDataFactory=n.standardFontDataFactory;this.destroyed=!1;this.destroyCapability=null;this._networkStream=i;this._fullReader=null;this._lastProgress=null;this.downloadInfoCapability=Promise.withResolvers();this.setupMessageHandler()}#Is(t,e=null){const i=this.#Ms.get(t);if(i)return i;const s=this.messageHandler.sendWithPromise(t,e);this.#Ms.set(t,s);return s}get annotationStorage(){return shadow(this,"annotationStorage",new AnnotationStorage)}getRenderingIntent(t,e=p.ENABLE,i=null,s=!1,n=!1){let g=r,m=rt;switch(t){case"any":g=a;break;case"display":break;case"print":g=o;break;default:warn(`getRenderingIntent - invalid intent: ${t}`)}const f=g&o&&i instanceof PrintAnnotationStorage?i:this.annotationStorage;switch(e){case p.DISABLE:g+=d;break;case p.ENABLE:break;case p.ENABLE_FORMS:g+=l;break;case p.ENABLE_STORAGE:g+=h;m=f.serializable;break;default:warn(`getRenderingIntent - invalid annotationMode: ${e}`)}s&&(g+=c);n&&(g+=u);const{ids:b,hash:A}=f.modifiedIds;return{renderingIntent:g,cacheKey:[g,m.hash,A].join("_"),annotationStorageSerializable:m,modifiedIds:b}}destroy(){if(this.destroyCapability)return this.destroyCapability.promise;this.destroyed=!0;this.destroyCapability=Promise.withResolvers();this.#Rs?.reject(new Error("Worker was destroyed during onPassword callback"));const t=[];for(const e of this.#Ps.values())t.push(e._destroy());this.#Ps.clear();this.#Ds.clear();this.#ks.clear();this.hasOwnProperty("annotationStorage")&&this.annotationStorage.resetModified();const e=this.messageHandler.sendWithPromise("Terminate",null);t.push(e);Promise.all(t).then((()=>{this.commonObjs.clear();this.fontLoader.clear();this.#Ms.clear();this.filterFactory.destroy();TextLayer.cleanup();this._networkStream?.cancelAllRequests(new AbortException("Worker was terminated."));this.messageHandler?.destroy();this.messageHandler=null;this.destroyCapability.resolve()}),this.destroyCapability.reject);return this.destroyCapability.promise}setupMessageHandler(){const{messageHandler:t,loadingTask:e}=this;t.on("GetReader",((t,e)=>{assert(this._networkStream,"GetReader - no `IPDFStream` instance available.");this._fullReader=this._networkStream.getFullReader();this._fullReader.onProgress=t=>{this._lastProgress={loaded:t.loaded,total:t.total}};e.onPull=()=>{this._fullReader.read().then((function({value:t,done:i}){if(i)e.close();else{assert(t instanceof ArrayBuffer,"GetReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{this._fullReader.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}));t.on("ReaderHeadersReady",(async t=>{await this._fullReader.headersReady;const{isStreamingSupported:i,isRangeSupported:s,contentLength:n}=this._fullReader;if(!i||!s){this._lastProgress&&e.onProgress?.(this._lastProgress);this._fullReader.onProgress=t=>{e.onProgress?.({loaded:t.loaded,total:t.total})}}return{isStreamingSupported:i,isRangeSupported:s,contentLength:n}}));t.on("GetRangeReader",((t,e)=>{assert(this._networkStream,"GetRangeReader - no `IPDFStream` instance available.");const i=this._networkStream.getRangeReader(t.begin,t.end);if(i){e.onPull=()=>{i.read().then((function({value:t,done:i}){if(i)e.close();else{assert(t instanceof ArrayBuffer,"GetRangeReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{i.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}else e.close()}));t.on("GetDoc",(({pdfInfo:t})=>{this._numPages=t.numPages;this._htmlForXfa=t.htmlForXfa;delete t.htmlForXfa;e._capability.resolve(new PDFDocumentProxy(t,this))}));t.on("DocException",(t=>{e._capability.reject(wrapReason(t))}));t.on("PasswordRequest",(t=>{this.#Rs=Promise.withResolvers();try{if(!e.onPassword)throw wrapReason(t);const updatePassword=t=>{t instanceof Error?this.#Rs.reject(t):this.#Rs.resolve({password:t})};e.onPassword(updatePassword,t.code)}catch(t){this.#Rs.reject(t)}return this.#Rs.promise}));t.on("DataLoaded",(t=>{e.onProgress?.({loaded:t.length,total:t.length});this.downloadInfoCapability.resolve(t)}));t.on("StartRenderPage",(t=>{if(this.destroyed)return;this.#Ps.get(t.pageIndex)._startRenderPage(t.transparency,t.cacheKey)}));t.on("commonobj",(([e,i,s])=>{if(this.destroyed)return null;if(this.commonObjs.has(e))return null;switch(i){case"Font":const{disableFontFace:n,fontExtraProperties:a,pdfBug:r}=this._params;if("error"in s){const t=s.error;warn(`Error during font loading: ${t}`);this.commonObjs.resolve(e,t);break}const o=r&&globalThis.FontInspector?.enabled?(t,e)=>globalThis.FontInspector.fontAdded(t,e):null,l=new FontFaceObject(s,{disableFontFace:n,fontExtraProperties:a,inspectFont:o});this.fontLoader.bind(l).catch((()=>t.sendWithPromise("FontFallback",{id:e}))).finally((()=>{!a&&l.data&&(l.data=null);this.commonObjs.resolve(e,l)}));break;case"CopyLocalImage":const{imageRef:h}=s;assert(h,"The imageRef must be defined.");for(const t of this.#Ps.values())for(const[,i]of t.objs)if(i?.ref===h){if(!i.dataLen)return null;this.commonObjs.resolve(e,structuredClone(i));return i.dataLen}break;case"FontPath":case"Image":case"Pattern":this.commonObjs.resolve(e,s);break;default:throw new Error(`Got unknown common object type ${i}`)}return null}));t.on("obj",(([t,e,i,s])=>{if(this.destroyed)return;const n=this.#Ps.get(e);if(!n.objs.has(t))if(0!==n._intentStates.size)switch(i){case"Image":n.objs.resolve(t,s);s?.dataLen>1e7&&(n._maybeCleanupAfterRender=!0);break;case"Pattern":n.objs.resolve(t,s);break;default:throw new Error(`Got unknown object type ${i}`)}else s?.bitmap?.close()}));t.on("DocProgress",(t=>{this.destroyed||e.onProgress?.({loaded:t.loaded,total:t.total})}));t.on("FetchBuiltInCMap",(async t=>{if(this.destroyed)throw new Error("Worker was destroyed.");if(!this.cMapReaderFactory)throw new Error("CMapReaderFactory not initialized, see the `useWorkerFetch` parameter.");return this.cMapReaderFactory.fetch(t)}));t.on("FetchStandardFontData",(async t=>{if(this.destroyed)throw new Error("Worker was destroyed.");if(!this.standardFontDataFactory)throw new Error("StandardFontDataFactory not initialized, see the `useWorkerFetch` parameter.");return this.standardFontDataFactory.fetch(t)}))}getData(){return this.messageHandler.sendWithPromise("GetData",null)}saveDocument(){this.annotationStorage.size<=0&&warn("saveDocument called while `annotationStorage` is empty, please use the getData-method instead.");const{map:t,transfer:e}=this.annotationStorage.serializable;return this.messageHandler.sendWithPromise("SaveDocument",{isPureXfa:!!this._htmlForXfa,numPages:this._numPages,annotationStorage:t,filename:this._fullReader?.filename??null},e).finally((()=>{this.annotationStorage.resetModified()}))}getPage(t){if(!Number.isInteger(t)||t<=0||t>this._numPages)return Promise.reject(new Error("Invalid page request."));const e=t-1,i=this.#Ds.get(e);if(i)return i;const s=this.messageHandler.sendWithPromise("GetPage",{pageIndex:e}).then((i=>{if(this.destroyed)throw new Error("Transport destroyed");i.refStr&&this.#ks.set(i.refStr,t);const s=new PDFPageProxy(e,i,this,this._params.pdfBug);this.#Ps.set(e,s);return s}));this.#Ds.set(e,s);return s}getPageIndex(t){return isRefProxy(t)?this.messageHandler.sendWithPromise("GetPageIndex",{num:t.num,gen:t.gen}):Promise.reject(new Error("Invalid pageIndex request."))}getAnnotations(t,e){return this.messageHandler.sendWithPromise("GetAnnotations",{pageIndex:t,intent:e})}getFieldObjects(){return this.#Is("GetFieldObjects")}hasJSActions(){return this.#Is("HasJSActions")}getCalculationOrderIds(){return this.messageHandler.sendWithPromise("GetCalculationOrderIds",null)}getDestinations(){return this.messageHandler.sendWithPromise("GetDestinations",null)}getDestination(t){return"string"!=typeof t?Promise.reject(new Error("Invalid destination request.")):this.messageHandler.sendWithPromise("GetDestination",{id:t})}getPageLabels(){return this.messageHandler.sendWithPromise("GetPageLabels",null)}getPageLayout(){return this.messageHandler.sendWithPromise("GetPageLayout",null)}getPageMode(){return this.messageHandler.sendWithPromise("GetPageMode",null)}getViewerPreferences(){return this.messageHandler.sendWithPromise("GetViewerPreferences",null)}getOpenAction(){return this.messageHandler.sendWithPromise("GetOpenAction",null)}getAttachments(){return this.messageHandler.sendWithPromise("GetAttachments",null)}getDocJSActions(){return this.#Is("GetDocJSActions")}getPageJSActions(t){return this.messageHandler.sendWithPromise("GetPageJSActions",{pageIndex:t})}getStructTree(t){return this.messageHandler.sendWithPromise("GetStructTree",{pageIndex:t})}getOutline(){return this.messageHandler.sendWithPromise("GetOutline",null)}getOptionalContentConfig(t){return this.#Is("GetOptionalContentConfig").then((e=>new OptionalContentConfig(e,t)))}getPermissions(){return this.messageHandler.sendWithPromise("GetPermissions",null)}getMetadata(){const t="GetMetadata",e=this.#Ms.get(t);if(e)return e;const i=this.messageHandler.sendWithPromise(t,null).then((t=>({info:t[0],metadata:t[1]?new Metadata(t[1]):null,contentDispositionFilename:this._fullReader?.filename??null,contentLength:this._fullReader?.contentLength??null})));this.#Ms.set(t,i);return i}getMarkInfo(){return this.messageHandler.sendWithPromise("GetMarkInfo",null)}async startCleanup(t=!1){if(!this.destroyed){await this.messageHandler.sendWithPromise("Cleanup",null);for(const t of this.#Ps.values()){if(!t.cleanup())throw new Error(`startCleanup: Page ${t.pageNumber} is currently rendering.`)}this.commonObjs.clear();t||this.fontLoader.clear();this.#Ms.clear();this.filterFactory.destroy(!0);TextLayer.cleanup()}}cachedPageNumber(t){if(!isRefProxy(t))return null;const e=0===t.gen?`${t.num}R`:`${t.num}R${t.gen}`;return this.#ks.get(e)??null}}const Lt=Symbol("INITIAL_DATA");class PDFObjects{#Fs=Object.create(null);#Ls(t){return this.#Fs[t]||={...Promise.withResolvers(),data:Lt}}get(t,e=null){if(e){const i=this.#Ls(t);i.promise.then((()=>e(i.data)));return null}const i=this.#Fs[t];if(!i||i.data===Lt)throw new Error(`Requesting object that isn't resolved yet ${t}.`);return i.data}has(t){const e=this.#Fs[t];return!!e&&e.data!==Lt}delete(t){const e=this.#Fs[t];if(!e||e.data===Lt)return!1;delete this.#Fs[t];return!0}resolve(t,e=null){const i=this.#Ls(t);i.data=e;i.resolve()}clear(){for(const t in this.#Fs){const{data:e}=this.#Fs[t];e?.bitmap?.close()}this.#Fs=Object.create(null)}*[Symbol.iterator](){for(const t in this.#Fs){const{data:e}=this.#Fs[t];e!==Lt&&(yield[t,e])}}}class RenderTask{#Os=null;constructor(t){this.#Os=t;this.onContinue=null}get promise(){return this.#Os.capability.promise}cancel(t=0){this.#Os.cancel(null,t)}get separateAnnots(){const{separateAnnots:t}=this.#Os.operatorList;if(!t)return!1;const{annotationCanvasMap:e}=this.#Os;return t.form||t.canvas&&e?.size>0}}class InternalRenderTask{#Ns=null;static#Bs=new WeakSet;constructor({callback:t,params:e,objs:i,commonObjs:s,annotationCanvasMap:n,operatorList:a,pageIndex:r,canvasFactory:o,filterFactory:l,useRequestAnimationFrame:h=!1,pdfBug:d=!1,pageColors:c=null}){this.callback=t;this.params=e;this.objs=i;this.commonObjs=s;this.annotationCanvasMap=n;this.operatorListIdx=null;this.operatorList=a;this._pageIndex=r;this.canvasFactory=o;this.filterFactory=l;this._pdfBug=d;this.pageColors=c;this.running=!1;this.graphicsReadyCallback=null;this.graphicsReady=!1;this._useRequestAnimationFrame=!0===h&&"undefined"!=typeof window;this.cancelled=!1;this.capability=Promise.withResolvers();this.task=new RenderTask(this);this._cancelBound=this.cancel.bind(this);this._continueBound=this._continue.bind(this);this._scheduleNextBound=this._scheduleNext.bind(this);this._nextBound=this._next.bind(this);this._canvas=e.canvasContext.canvas}get completed(){return this.capability.promise.catch((function(){}))}initializeGraphics({transparency:t=!1,optionalContentConfig:e}){if(this.cancelled)return;if(this._canvas){if(InternalRenderTask.#Bs.has(this._canvas))throw new Error("Cannot use the same canvas during multiple render() operations. Use different canvas or ensure previous operations were cancelled or completed.");InternalRenderTask.#Bs.add(this._canvas)}if(this._pdfBug&&globalThis.StepperManager?.enabled){this.stepper=globalThis.StepperManager.create(this._pageIndex);this.stepper.init(this.operatorList);this.stepper.nextBreakPoint=this.stepper.getNextBreakPoint()}const{canvasContext:i,viewport:s,transform:n,background:a}=this.params;this.gfx=new CanvasGraphics(i,this.commonObjs,this.objs,this.canvasFactory,this.filterFactory,{optionalContentConfig:e},this.annotationCanvasMap,this.pageColors);this.gfx.beginDrawing({transform:n,viewport:s,transparency:t,background:a});this.operatorListIdx=0;this.graphicsReady=!0;this.graphicsReadyCallback?.()}cancel(t=null,e=0){this.running=!1;this.cancelled=!0;this.gfx?.endDrawing();if(this.#Ns){window.cancelAnimationFrame(this.#Ns);this.#Ns=null}InternalRenderTask.#Bs.delete(this._canvas);this.callback(t||new RenderingCancelledException(`Rendering cancelled, page ${this._pageIndex+1}`,e))}operatorListChanged(){if(this.graphicsReady){this.stepper?.updateOperatorList(this.operatorList);this.running||this._continue()}else this.graphicsReadyCallback||=this._continueBound}_continue(){this.running=!0;this.cancelled||(this.task.onContinue?this.task.onContinue(this._scheduleNextBound):this._scheduleNext())}_scheduleNext(){this._useRequestAnimationFrame?this.#Ns=window.requestAnimationFrame((()=>{this.#Ns=null;this._nextBound().catch(this._cancelBound)})):Promise.resolve().then(this._nextBound).catch(this._cancelBound)}async _next(){if(!this.cancelled){this.operatorListIdx=this.gfx.executeOperatorList(this.operatorList,this.operatorListIdx,this._continueBound,this.stepper);if(this.operatorListIdx===this.operatorList.argsArray.length){this.running=!1;if(this.operatorList.lastChunk){this.gfx.endDrawing();InternalRenderTask.#Bs.delete(this._canvas);this.callback()}}}}}const Ot="4.10.38",Nt="f9bea397f";function makeColorComp(t){return Math.floor(255*Math.max(0,Math.min(1,t))).toString(16).padStart(2,"0")}function scaleAndClamp(t){return Math.max(0,Math.min(255,255*t))}class ColorConverters{static CMYK_G([t,e,i,s]){return["G",1-Math.min(1,.3*t+.59*i+.11*e+s)]}static G_CMYK([t]){return["CMYK",0,0,0,1-t]}static G_RGB([t]){return["RGB",t,t,t]}static G_rgb([t]){return[t=scaleAndClamp(t),t,t]}static G_HTML([t]){const e=makeColorComp(t);return`#${e}${e}${e}`}static RGB_G([t,e,i]){return["G",.3*t+.59*e+.11*i]}static RGB_rgb(t){return t.map(scaleAndClamp)}static RGB_HTML(t){return`#${t.map(makeColorComp).join("")}`}static T_HTML(){return"#00000000"}static T_rgb(){return[null]}static CMYK_RGB([t,e,i,s]){return["RGB",1-Math.min(1,t+s),1-Math.min(1,i+s),1-Math.min(1,e+s)]}static CMYK_rgb([t,e,i,s]){return[scaleAndClamp(1-Math.min(1,t+s)),scaleAndClamp(1-Math.min(1,i+s)),scaleAndClamp(1-Math.min(1,e+s))]}static CMYK_HTML(t){const e=this.CMYK_RGB(t).slice(1);return this.RGB_HTML(e)}static RGB_CMYK([t,e,i]){const s=1-t,n=1-e,a=1-i;return["CMYK",s,n,a,Math.min(s,n,a)]}}class BaseSVGFactory{create(t,e,i=!1){if(t<=0||e<=0)throw new Error("Invalid SVG dimensions");const s=this._createSVG("svg:svg");s.setAttribute("version","1.1");if(!i){s.setAttribute("width",`${t}px`);s.setAttribute("height",`${e}px`)}s.setAttribute("preserveAspectRatio","none");s.setAttribute("viewBox",`0 0 ${t} ${e}`);return s}createElement(t){if("string"!=typeof t)throw new Error("Invalid SVG element type");return this._createSVG(t)}_createSVG(t){unreachable("Abstract method `_createSVG` called.")}}class DOMSVGFactory extends BaseSVGFactory{_createSVG(t){return document.createElementNS(it,t)}}class XfaLayer{static setupStorage(t,e,i,s,n){const a=s.getValue(e,{value:null});switch(i.name){case"textarea":null!==a.value&&(t.textContent=a.value);if("print"===n)break;t.addEventListener("input",(t=>{s.setValue(e,{value:t.target.value})}));break;case"input":if("radio"===i.attributes.type||"checkbox"===i.attributes.type){a.value===i.attributes.xfaOn?t.setAttribute("checked",!0):a.value===i.attributes.xfaOff&&t.removeAttribute("checked");if("print"===n)break;t.addEventListener("change",(t=>{s.setValue(e,{value:t.target.checked?t.target.getAttribute("xfaOn"):t.target.getAttribute("xfaOff")})}))}else{null!==a.value&&t.setAttribute("value",a.value);if("print"===n)break;t.addEventListener("input",(t=>{s.setValue(e,{value:t.target.value})}))}break;case"select":if(null!==a.value){t.setAttribute("value",a.value);for(const t of i.children)t.attributes.value===a.value?t.attributes.selected=!0:t.attributes.hasOwnProperty("selected")&&delete t.attributes.selected}t.addEventListener("input",(t=>{const i=t.target.options,n=-1===i.selectedIndex?"":i[i.selectedIndex].value;s.setValue(e,{value:n})}))}}static setAttributes({html:t,element:e,storage:i=null,intent:s,linkService:n}){const{attributes:a}=e,r=t instanceof HTMLAnchorElement;"radio"===a.type&&(a.name=`${a.name}-${s}`);for(const[e,i]of Object.entries(a))if(null!=i)switch(e){case"class":i.length&&t.setAttribute(e,i.join(" "));break;case"dataId":break;case"id":t.setAttribute("data-element-id",i);break;case"style":Object.assign(t.style,i);break;case"textContent":t.textContent=i;break;default:(!r||"href"!==e&&"newWindow"!==e)&&t.setAttribute(e,i)}r&&n.addLinkAttributes(t,a.href,a.newWindow);i&&a.dataId&&this.setupStorage(t,a.dataId,e,i)}static render(t){const e=t.annotationStorage,i=t.linkService,s=t.xfaHtml,n=t.intent||"display",a=document.createElement(s.name);s.attributes&&this.setAttributes({html:a,element:s,intent:n,linkService:i});const r="richText"!==n,o=t.div;o.append(a);if(t.viewport){const e=`matrix(${t.viewport.transform.join(",")})`;o.style.transform=e}r&&o.setAttribute("class","xfaLayer xfaFont");const l=[];if(0===s.children.length){if(s.value){const t=document.createTextNode(s.value);a.append(t);r&&XfaText.shouldBuildText(s.name)&&l.push(t)}return{textDivs:l}}const h=[[s,-1,a]];for(;h.length>0;){const[t,s,a]=h.at(-1);if(s+1===t.children.length){h.pop();continue}const o=t.children[++h.at(-1)[1]];if(null===o)continue;const{name:d}=o;if("#text"===d){const t=document.createTextNode(o.value);l.push(t);a.append(t);continue}const c=o?.attributes?.xmlns?document.createElementNS(o.attributes.xmlns,d):document.createElement(d);a.append(c);o.attributes&&this.setAttributes({html:c,element:o,storage:e,intent:n,linkService:i});if(o.children?.length>0)h.push([o,-1,c]);else if(o.value){const t=document.createTextNode(o.value);r&&XfaText.shouldBuildText(d)&&l.push(t);c.append(t)}}for(const t of o.querySelectorAll(".xfaNonInteractive input, .xfaNonInteractive textarea"))t.setAttribute("readOnly",!0);return{textDivs:l}}static update(t){const e=`matrix(${t.viewport.transform.join(",")})`;t.div.style.transform=e;t.div.hidden=!1}}const Bt=1e3,Ht=new WeakSet;function getRectDims(t){return{width:t[2]-t[0],height:t[3]-t[1]}}class AnnotationElementFactory{static create(t){switch(t.data.annotationType){case S:return new LinkAnnotationElement(t);case E:return new TextAnnotationElement(t);case U:switch(t.data.fieldType){case"Tx":return new TextWidgetAnnotationElement(t);case"Btn":return t.data.radioButton?new RadioButtonWidgetAnnotationElement(t):t.data.checkBox?new CheckboxWidgetAnnotationElement(t):new PushButtonWidgetAnnotationElement(t);case"Ch":return new ChoiceWidgetAnnotationElement(t);case"Sig":return new SignatureWidgetAnnotationElement(t)}return new WidgetAnnotationElement(t);case H:return new PopupAnnotationElement(t);case C:return new FreeTextAnnotationElement(t);case T:return new LineAnnotationElement(t);case M:return new SquareAnnotationElement(t);case P:return new CircleAnnotationElement(t);case k:return new PolylineAnnotationElement(t);case N:return new CaretAnnotationElement(t);case B:return new InkAnnotationElement(t);case D:return new PolygonAnnotationElement(t);case R:return new HighlightAnnotationElement(t);case I:return new UnderlineAnnotationElement(t);case F:return new SquigglyAnnotationElement(t);case L:return new StrikeOutAnnotationElement(t);case O:return new StampAnnotationElement(t);case z:return new FileAttachmentAnnotationElement(t);default:return new AnnotationElement(t)}}}class AnnotationElement{#Hs=null;#zs=!1;#Us=null;constructor(t,{isRenderable:e=!1,ignoreBorder:i=!1,createQuadrilaterals:s=!1}={}){this.isRenderable=e;this.data=t.data;this.layer=t.layer;this.linkService=t.linkService;this.downloadManager=t.downloadManager;this.imageResourcesPath=t.imageResourcesPath;this.renderForms=t.renderForms;this.svgFactory=t.svgFactory;this.annotationStorage=t.annotationStorage;this.enableScripting=t.enableScripting;this.hasJSActions=t.hasJSActions;this._fieldObjects=t.fieldObjects;this.parent=t.parent;e&&(this.container=this._createContainer(i));s&&this._createQuadrilaterals()}static _hasPopupData({titleObj:t,contentsObj:e,richText:i}){return!!(t?.str||e?.str||i?.str)}get _isEditable(){return this.data.isEditable}get hasPopupData(){return AnnotationElement._hasPopupData(this.data)}updateEdited(t){if(!this.container)return;this.#Hs||={rect:this.data.rect.slice(0)};const{rect:e}=t;e&&this.#Gs(e);this.#Us?.popup.updateEdited(t)}resetEdited(){if(this.#Hs){this.#Gs(this.#Hs.rect);this.#Us?.popup.resetEdited();this.#Hs=null}}#Gs(t){const{container:{style:e},data:{rect:i,rotation:s},parent:{viewport:{rawDims:{pageWidth:n,pageHeight:a,pageX:r,pageY:o}}}}=this;i?.splice(0,4,...t);const{width:l,height:h}=getRectDims(t);e.left=100*(t[0]-r)/n+"%";e.top=100*(a-t[3]+o)/a+"%";if(0===s){e.width=100*l/n+"%";e.height=100*h/a+"%"}else this.setRotation(s)}_createContainer(t){const{data:e,parent:{page:i,viewport:s}}=this,n=document.createElement("section");n.setAttribute("data-annotation-id",e.id);this instanceof WidgetAnnotationElement||(n.tabIndex=Bt);const{style:a}=n;a.zIndex=this.parent.zIndex++;e.alternativeText&&(n.title=e.alternativeText);e.noRotate&&n.classList.add("norotate");if(!e.rect||this instanceof PopupAnnotationElement){const{rotation:t}=e;e.hasOwnCanvas||0===t||this.setRotation(t,n);return n}const{width:r,height:o}=getRectDims(e.rect);if(!t&&e.borderStyle.width>0){a.borderWidth=`${e.borderStyle.width}px`;const t=e.borderStyle.horizontalCornerRadius,i=e.borderStyle.verticalCornerRadius;if(t>0||i>0){const e=`calc(${t}px * var(--scale-factor)) / calc(${i}px * var(--scale-factor))`;a.borderRadius=e}else if(this instanceof RadioButtonWidgetAnnotationElement){const t=`calc(${r}px * var(--scale-factor)) / calc(${o}px * var(--scale-factor))`;a.borderRadius=t}switch(e.borderStyle.style){case G:a.borderStyle="solid";break;case $:a.borderStyle="dashed";break;case V:warn("Unimplemented border style: beveled");break;case j:warn("Unimplemented border style: inset");break;case W:a.borderBottomStyle="solid"}const s=e.borderColor||null;if(s){this.#zs=!0;a.borderColor=Util.makeHexColor(0|s[0],0|s[1],0|s[2])}else a.borderWidth=0}const l=Util.normalizeRect([e.rect[0],i.view[3]-e.rect[1]+i.view[1],e.rect[2],i.view[3]-e.rect[3]+i.view[1]]),{pageWidth:h,pageHeight:d,pageX:c,pageY:u}=s.rawDims;a.left=100*(l[0]-c)/h+"%";a.top=100*(l[1]-u)/d+"%";const{rotation:p}=e;if(e.hasOwnCanvas||0===p){a.width=100*r/h+"%";a.height=100*o/d+"%"}else this.setRotation(p,n);return n}setRotation(t,e=this.container){if(!this.data.rect)return;const{pageWidth:i,pageHeight:s}=this.parent.viewport.rawDims,{width:n,height:a}=getRectDims(this.data.rect);let r,o;if(t%180==0){r=100*n/i;o=100*a/s}else{r=100*a/i;o=100*n/s}e.style.width=`${r}%`;e.style.height=`${o}%`;e.setAttribute("data-main-rotation",(360-t)%360)}get _commonActions(){const setColor=(t,e,i)=>{const s=i.detail[t],n=s[0],a=s.slice(1);i.target.style[e]=ColorConverters[`${n}_HTML`](a);this.annotationStorage.setValue(this.data.id,{[e]:ColorConverters[`${n}_rgb`](a)})};return shadow(this,"_commonActions",{display:t=>{const{display:e}=t.detail,i=e%2==1;this.container.style.visibility=i?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{noView:i,noPrint:1===e||2===e})},print:t=>{this.annotationStorage.setValue(this.data.id,{noPrint:!t.detail.print})},hidden:t=>{const{hidden:e}=t.detail;this.container.style.visibility=e?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{noPrint:e,noView:e})},focus:t=>{setTimeout((()=>t.target.focus({preventScroll:!1})),0)},userName:t=>{t.target.title=t.detail.userName},readonly:t=>{t.target.disabled=t.detail.readonly},required:t=>{this._setRequired(t.target,t.detail.required)},bgColor:t=>{setColor("bgColor","backgroundColor",t)},fillColor:t=>{setColor("fillColor","backgroundColor",t)},fgColor:t=>{setColor("fgColor","color",t)},textColor:t=>{setColor("textColor","color",t)},borderColor:t=>{setColor("borderColor","borderColor",t)},strokeColor:t=>{setColor("strokeColor","borderColor",t)},rotation:t=>{const e=t.detail.rotation;this.setRotation(e);this.annotationStorage.setValue(this.data.id,{rotation:e})}})}_dispatchEventFromSandbox(t,e){const i=this._commonActions;for(const s of Object.keys(e.detail)){const n=t[s]||i[s];n?.(e)}}_setDefaultPropertiesFromJS(t){if(!this.enableScripting)return;const e=this.annotationStorage.getRawValue(this.data.id);if(!e)return;const i=this._commonActions;for(const[s,n]of Object.entries(e)){const a=i[s];if(a){a({detail:{[s]:n},target:t});delete e[s]}}}_createQuadrilaterals(){if(!this.container)return;const{quadPoints:t}=this.data;if(!t)return;const[e,i,s,n]=this.data.rect.map((t=>Math.fround(t)));if(8===t.length){const[a,r,o,l]=t.subarray(2,6);if(s===a&&n===r&&e===o&&i===l)return}const{style:a}=this.container;let r;if(this.#zs){const{borderColor:t,borderWidth:e}=a;a.borderWidth=0;r=["url('data:image/svg+xml;utf8,",'',``];this.container.classList.add("hasBorder")}const o=s-e,l=n-i,{svgFactory:h}=this,d=h.createElement("svg");d.classList.add("quadrilateralsContainer");d.setAttribute("width",0);d.setAttribute("height",0);const c=h.createElement("defs");d.append(c);const u=h.createElement("clipPath"),p=`clippath_${this.data.id}`;u.setAttribute("id",p);u.setAttribute("clipPathUnits","objectBoundingBox");c.append(u);for(let i=2,s=t.length;i`)}if(this.#zs){r.push("')");a.backgroundImage=r.join("")}this.container.append(d);this.container.style.clipPath=`url(#${p})`}_createPopup(){const{data:t}=this,e=this.#Us=new PopupAnnotationElement({data:{color:t.color,titleObj:t.titleObj,modificationDate:t.modificationDate,contentsObj:t.contentsObj,richText:t.richText,parentRect:t.rect,borderStyle:0,id:`popup_${t.id}`,rotation:t.rotation},parent:this.parent,elements:[this]});this.parent.div.append(e.render())}render(){unreachable("Abstract method `AnnotationElement.render` called")}_getElementsByName(t,e=null){const i=[];if(this._fieldObjects){const s=this._fieldObjects[t];if(s)for(const{page:t,id:n,exportValues:a}of s){if(-1===t)continue;if(n===e)continue;const s="string"==typeof a?a:null,r=document.querySelector(`[data-element-id="${n}"]`);!r||Ht.has(r)?i.push({id:n,exportValue:s,domElement:r}):warn(`_getElementsByName - element not allowed: ${n}`)}return i}for(const s of document.getElementsByName(t)){const{exportValue:t}=s,n=s.getAttribute("data-element-id");n!==e&&(Ht.has(s)&&i.push({id:n,exportValue:t,domElement:s}))}return i}show(){this.container&&(this.container.hidden=!1);this.popup?.maybeShow()}hide(){this.container&&(this.container.hidden=!0);this.popup?.forceHide()}getElementsToTriggerPopup(){return this.container}addHighlightArea(){const t=this.getElementsToTriggerPopup();if(Array.isArray(t))for(const e of t)e.classList.add("highlightArea");else t.classList.add("highlightArea")}_editOnDoubleClick(){if(!this._isEditable)return;const{annotationEditorType:t,data:{id:e}}=this;this.container.addEventListener("dblclick",(()=>{this.linkService.eventBus?.dispatch("switchannotationeditormode",{source:this,mode:t,editId:e})}))}}class LinkAnnotationElement extends AnnotationElement{constructor(t,e=null){super(t,{isRenderable:!0,ignoreBorder:!!e?.ignoreBorder,createQuadrilaterals:!0});this.isTooltipOnly=t.data.isTooltipOnly}render(){const{data:t,linkService:e}=this,i=document.createElement("a");i.setAttribute("data-element-id",t.id);let s=!1;if(t.url){e.addLinkAttributes(i,t.url,t.newWindow);s=!0}else if(t.action){this._bindNamedAction(i,t.action);s=!0}else if(t.attachment){this.#$s(i,t.attachment,t.attachmentDest);s=!0}else if(t.setOCGState){this.#Vs(i,t.setOCGState);s=!0}else if(t.dest){this._bindLink(i,t.dest);s=!0}else{if(t.actions&&(t.actions.Action||t.actions["Mouse Up"]||t.actions["Mouse Down"])&&this.enableScripting&&this.hasJSActions){this._bindJSAction(i,t);s=!0}if(t.resetForm){this._bindResetFormAction(i,t.resetForm);s=!0}else if(this.isTooltipOnly&&!s){this._bindLink(i,"");s=!0}}this.container.classList.add("linkAnnotation");s&&this.container.append(i);return this.container}#js(){this.container.setAttribute("data-internal-link","")}_bindLink(t,e){t.href=this.linkService.getDestinationHash(e);t.onclick=()=>{e&&this.linkService.goToDestination(e);return!1};(e||""===e)&&this.#js()}_bindNamedAction(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeNamedAction(e);return!1};this.#js()}#$s(t,e,i=null){t.href=this.linkService.getAnchorUrl("");e.description&&(t.title=e.description);t.onclick=()=>{this.downloadManager?.openOrDownloadData(e.content,e.filename,i);return!1};this.#js()}#Vs(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeSetOCGState(e);return!1};this.#js()}_bindJSAction(t,e){t.href=this.linkService.getAnchorUrl("");const i=new Map([["Action","onclick"],["Mouse Up","onmouseup"],["Mouse Down","onmousedown"]]);for(const s of Object.keys(e.actions)){const n=i.get(s);n&&(t[n]=()=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e.id,name:s}});return!1})}t.onclick||(t.onclick=()=>!1);this.#js()}_bindResetFormAction(t,e){const i=t.onclick;i||(t.href=this.linkService.getAnchorUrl(""));this.#js();if(this._fieldObjects)t.onclick=()=>{i?.();const{fields:t,refs:s,include:n}=e,a=[];if(0!==t.length||0!==s.length){const e=new Set(s);for(const i of t){const t=this._fieldObjects[i]||[];for(const{id:i}of t)e.add(i)}for(const t of Object.values(this._fieldObjects))for(const i of t)e.has(i.id)===n&&a.push(i)}else for(const t of Object.values(this._fieldObjects))a.push(...t);const r=this.annotationStorage,o=[];for(const t of a){const{id:e}=t;o.push(e);switch(t.type){case"text":{const i=t.defaultValue||"";r.setValue(e,{value:i});break}case"checkbox":case"radiobutton":{const i=t.defaultValue===t.exportValues;r.setValue(e,{value:i});break}case"combobox":case"listbox":{const i=t.defaultValue||"";r.setValue(e,{value:i});break}default:continue}const i=document.querySelector(`[data-element-id="${e}"]`);i&&(Ht.has(i)?i.dispatchEvent(new Event("resetform")):warn(`_bindResetFormAction - element not allowed: ${e}`))}this.enableScripting&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:"app",ids:o,name:"ResetForm"}});return!1};else{warn('_bindResetFormAction - "resetForm" action not supported, ensure that the `fieldObjects` parameter is provided.');i||(t.onclick=()=>!1)}}}class TextAnnotationElement extends AnnotationElement{constructor(t){super(t,{isRenderable:!0})}render(){this.container.classList.add("textAnnotation");const t=document.createElement("img");t.src=this.imageResourcesPath+"annotation-"+this.data.name.toLowerCase()+".svg";t.setAttribute("data-l10n-id","pdfjs-text-annotation-type");t.setAttribute("data-l10n-args",JSON.stringify({type:this.data.name}));!this.data.popupRef&&this.hasPopupData&&this._createPopup();this.container.append(t);return this.container}}class WidgetAnnotationElement extends AnnotationElement{render(){return this.container}showElementAndHideCanvas(t){if(this.data.hasOwnCanvas){"CANVAS"===t.previousSibling?.nodeName&&(t.previousSibling.hidden=!0);t.hidden=!1}}_getKeyModifier(t){return util_FeatureTest.platform.isMac?t.metaKey:t.ctrlKey}_setEventListener(t,e,i,s,n){i.includes("mouse")?t.addEventListener(i,(t=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t),shift:t.shiftKey,modifier:this._getKeyModifier(t)}})})):t.addEventListener(i,(t=>{if("blur"===i){if(!e.focused||!t.relatedTarget)return;e.focused=!1}else if("focus"===i){if(e.focused)return;e.focused=!0}n&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t)}})}))}_setEventListeners(t,e,i,s){for(const[n,a]of i)if("Action"===a||this.data.actions?.[a]){"Focus"!==a&&"Blur"!==a||(e||={focused:!1});this._setEventListener(t,e,n,a,s);"Focus"!==a||this.data.actions?.Blur?"Blur"!==a||this.data.actions?.Focus||this._setEventListener(t,e,"focus","Focus",null):this._setEventListener(t,e,"blur","Blur",null)}}_setBackgroundColor(t){const e=this.data.backgroundColor||null;t.style.backgroundColor=null===e?"transparent":Util.makeHexColor(e[0],e[1],e[2])}_setTextStyle(t){const e=["left","center","right"],{fontColor:i}=this.data.defaultAppearanceData,s=this.data.defaultAppearanceData.fontSize||9,a=t.style;let r;const roundToOneDecimal=t=>Math.round(10*t)/10;if(this.data.multiLine){const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2),e=t/(Math.round(t/(n*s))||1);r=Math.min(s,roundToOneDecimal(e/n))}else{const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2);r=Math.min(s,roundToOneDecimal(t/n))}a.fontSize=`calc(${r}px * var(--scale-factor))`;a.color=Util.makeHexColor(i[0],i[1],i[2]);null!==this.data.textAlignment&&(a.textAlign=e[this.data.textAlignment])}_setRequired(t,e){e?t.setAttribute("required",!0):t.removeAttribute("required");t.setAttribute("aria-required",e)}}class TextWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms||t.data.hasOwnCanvas||!t.data.hasAppearance&&!!t.data.fieldValue})}setPropertyOnSiblings(t,e,i,s){const n=this.annotationStorage;for(const a of this._getElementsByName(t.name,t.id)){a.domElement&&(a.domElement[e]=i);n.setValue(a.id,{[s]:i})}}render(){const t=this.annotationStorage,e=this.data.id;this.container.classList.add("textWidgetAnnotation");let i=null;if(this.renderForms){const s=t.getValue(e,{value:this.data.fieldValue});let n=s.value||"";const a=t.getValue(e,{charLimit:this.data.maxLen}).charLimit;a&&n.length>a&&(n=n.slice(0,a));let r=s.formattedValue||this.data.textContent?.join("\n")||null;r&&this.data.comb&&(r=r.replaceAll(/\s+/g,""));const o={userValue:n,formattedValue:r,lastCommittedValue:null,commitKey:1,focused:!1};if(this.data.multiLine){i=document.createElement("textarea");i.textContent=r??n;this.data.doNotScroll&&(i.style.overflowY="hidden")}else{i=document.createElement("input");i.type="text";i.setAttribute("value",r??n);this.data.doNotScroll&&(i.style.overflowX="hidden")}this.data.hasOwnCanvas&&(i.hidden=!0);Ht.add(i);i.setAttribute("data-element-id",e);i.disabled=this.data.readOnly;i.name=this.data.fieldName;i.tabIndex=Bt;this._setRequired(i,this.data.required);a&&(i.maxLength=a);i.addEventListener("input",(s=>{t.setValue(e,{value:s.target.value});this.setPropertyOnSiblings(i,"value",s.target.value,"value");o.formattedValue=null}));i.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue??"";i.value=o.userValue=e;o.formattedValue=null}));let blurListener=t=>{const{formattedValue:e}=o;null!=e&&(t.target.value=e);t.target.scrollLeft=0};if(this.enableScripting&&this.hasJSActions){i.addEventListener("focus",(t=>{if(o.focused)return;const{target:e}=t;o.userValue&&(e.value=o.userValue);o.lastCommittedValue=e.value;o.commitKey=1;this.data.actions?.Focus||(o.focused=!0)}));i.addEventListener("updatefromsandbox",(i=>{this.showElementAndHideCanvas(i.target);const s={value(i){o.userValue=i.detail.value??"";t.setValue(e,{value:o.userValue.toString()});i.target.value=o.userValue},formattedValue(i){const{formattedValue:s}=i.detail;o.formattedValue=s;null!=s&&i.target!==document.activeElement&&(i.target.value=s);t.setValue(e,{formattedValue:s})},selRange(t){t.target.setSelectionRange(...t.detail.selRange)},charLimit:i=>{const{charLimit:s}=i.detail,{target:n}=i;if(0===s){n.removeAttribute("maxLength");return}n.setAttribute("maxLength",s);let a=o.userValue;if(a&&!(a.length<=s)){a=a.slice(0,s);n.value=o.userValue=a;t.setValue(e,{value:a});this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:a,willCommit:!0,commitKey:1,selStart:n.selectionStart,selEnd:n.selectionEnd}})}}};this._dispatchEventFromSandbox(s,i)}));i.addEventListener("keydown",(t=>{o.commitKey=1;let i=-1;"Escape"===t.key?i=0:"Enter"!==t.key||this.data.multiLine?"Tab"===t.key&&(o.commitKey=3):i=2;if(-1===i)return;const{value:s}=t.target;if(o.lastCommittedValue!==s){o.lastCommittedValue=s;o.userValue=s;this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:s,willCommit:!0,commitKey:i,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}})}}));const s=blurListener;blurListener=null;i.addEventListener("blur",(t=>{if(!o.focused||!t.relatedTarget)return;this.data.actions?.Blur||(o.focused=!1);const{value:i}=t.target;o.userValue=i;o.lastCommittedValue!==i&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:i,willCommit:!0,commitKey:o.commitKey,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}});s(t)}));this.data.actions?.Keystroke&&i.addEventListener("beforeinput",(t=>{o.lastCommittedValue=null;const{data:i,target:s}=t,{value:n,selectionStart:a,selectionEnd:r}=s;let l=a,h=r;switch(t.inputType){case"deleteWordBackward":{const t=n.substring(0,a).match(/\w*[^\w]*$/);t&&(l-=t[0].length);break}case"deleteWordForward":{const t=n.substring(a).match(/^[^\w]*\w*/);t&&(h+=t[0].length);break}case"deleteContentBackward":a===r&&(l-=1);break;case"deleteContentForward":a===r&&(h+=1)}t.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:n,change:i||"",willCommit:!1,selStart:l,selEnd:h}})}));this._setEventListeners(i,o,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.value))}blurListener&&i.addEventListener("blur",blurListener);if(this.data.comb){const t=(this.data.rect[2]-this.data.rect[0])/a;i.classList.add("comb");i.style.letterSpacing=`calc(${t}px * var(--scale-factor) - 1ch)`}}else{i=document.createElement("div");i.textContent=this.data.fieldValue;i.style.verticalAlign="middle";i.style.display="table-cell";this.data.hasOwnCanvas&&(i.hidden=!0)}this._setTextStyle(i);this._setBackgroundColor(i);this._setDefaultPropertiesFromJS(i);this.container.append(i);return this.container}}class SignatureWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:!!t.data.hasOwnCanvas})}}class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){const t=this.annotationStorage,e=this.data,i=e.id;let s=t.getValue(i,{value:e.exportValue===e.fieldValue}).value;if("string"==typeof s){s="Off"!==s;t.setValue(i,{value:s})}this.container.classList.add("buttonWidgetAnnotation","checkBox");const n=document.createElement("input");Ht.add(n);n.setAttribute("data-element-id",i);n.disabled=e.readOnly;this._setRequired(n,this.data.required);n.type="checkbox";n.name=e.fieldName;s&&n.setAttribute("checked",!0);n.setAttribute("exportValue",e.exportValue);n.tabIndex=Bt;n.addEventListener("change",(s=>{const{name:n,checked:a}=s.target;for(const s of this._getElementsByName(n,i)){const i=a&&s.exportValue===e.exportValue;s.domElement&&(s.domElement.checked=i);t.setValue(s.id,{value:i})}t.setValue(i,{value:a})}));n.addEventListener("resetform",(t=>{const i=e.defaultFieldValue||"Off";t.target.checked=i===e.exportValue}));if(this.enableScripting&&this.hasJSActions){n.addEventListener("updatefromsandbox",(e=>{const s={value(e){e.target.checked="Off"!==e.detail.value;t.setValue(i,{value:e.target.checked})}};this._dispatchEventFromSandbox(s,e)}));this._setEventListeners(n,null,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(n);this._setDefaultPropertiesFromJS(n);this.container.append(n);return this.container}}class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.classList.add("buttonWidgetAnnotation","radioButton");const t=this.annotationStorage,e=this.data,i=e.id;let s=t.getValue(i,{value:e.fieldValue===e.buttonValue}).value;if("string"==typeof s){s=s!==e.buttonValue;t.setValue(i,{value:s})}if(s)for(const s of this._getElementsByName(e.fieldName,i))t.setValue(s.id,{value:!1});const n=document.createElement("input");Ht.add(n);n.setAttribute("data-element-id",i);n.disabled=e.readOnly;this._setRequired(n,this.data.required);n.type="radio";n.name=e.fieldName;s&&n.setAttribute("checked",!0);n.tabIndex=Bt;n.addEventListener("change",(e=>{const{name:s,checked:n}=e.target;for(const e of this._getElementsByName(s,i))t.setValue(e.id,{value:!1});t.setValue(i,{value:n})}));n.addEventListener("resetform",(t=>{const i=e.defaultFieldValue;t.target.checked=null!=i&&i===e.buttonValue}));if(this.enableScripting&&this.hasJSActions){const s=e.buttonValue;n.addEventListener("updatefromsandbox",(e=>{const n={value:e=>{const n=s===e.detail.value;for(const s of this._getElementsByName(e.target.name)){const e=n&&s.id===i;s.domElement&&(s.domElement.checked=e);t.setValue(s.id,{value:e})}}};this._dispatchEventFromSandbox(n,e)}));this._setEventListeners(n,null,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(n);this._setDefaultPropertiesFromJS(n);this.container.append(n);return this.container}}class PushButtonWidgetAnnotationElement extends LinkAnnotationElement{constructor(t){super(t,{ignoreBorder:t.data.hasAppearance})}render(){const t=super.render();t.classList.add("buttonWidgetAnnotation","pushButton");const e=t.lastChild;if(this.enableScripting&&this.hasJSActions&&e){this._setDefaultPropertiesFromJS(e);e.addEventListener("updatefromsandbox",(t=>{this._dispatchEventFromSandbox({},t)}))}return t}}class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.classList.add("choiceWidgetAnnotation");const t=this.annotationStorage,e=this.data.id,i=t.getValue(e,{value:this.data.fieldValue}),s=document.createElement("select");Ht.add(s);s.setAttribute("data-element-id",e);s.disabled=this.data.readOnly;this._setRequired(s,this.data.required);s.name=this.data.fieldName;s.tabIndex=Bt;let n=this.data.combo&&this.data.options.length>0;if(!this.data.combo){s.size=this.data.options.length;this.data.multiSelect&&(s.multiple=!0)}s.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue;for(const t of s.options)t.selected=t.value===e}));for(const t of this.data.options){const e=document.createElement("option");e.textContent=t.displayValue;e.value=t.exportValue;if(i.value.includes(t.exportValue)){e.setAttribute("selected",!0);n=!1}s.append(e)}let a=null;if(n){const t=document.createElement("option");t.value=" ";t.setAttribute("hidden",!0);t.setAttribute("selected",!0);s.prepend(t);a=()=>{t.remove();s.removeEventListener("input",a);a=null};s.addEventListener("input",a)}const getValue=t=>{const e=t?"value":"textContent",{options:i,multiple:n}=s;return n?Array.prototype.filter.call(i,(t=>t.selected)).map((t=>t[e])):-1===i.selectedIndex?null:i[i.selectedIndex][e]};let r=getValue(!1);const getItems=t=>{const e=t.target.options;return Array.prototype.map.call(e,(t=>({displayValue:t.textContent,exportValue:t.value})))};if(this.enableScripting&&this.hasJSActions){s.addEventListener("updatefromsandbox",(i=>{const n={value(i){a?.();const n=i.detail.value,o=new Set(Array.isArray(n)?n:[n]);for(const t of s.options)t.selected=o.has(t.value);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},multipleSelection(t){s.multiple=!0},remove(i){const n=s.options,a=i.detail.remove;n[a].selected=!1;s.remove(a);if(n.length>0){-1===Array.prototype.findIndex.call(n,(t=>t.selected))&&(n[0].selected=!0)}t.setValue(e,{value:getValue(!0),items:getItems(i)});r=getValue(!1)},clear(i){for(;0!==s.length;)s.remove(0);t.setValue(e,{value:null,items:[]});r=getValue(!1)},insert(i){const{index:n,displayValue:a,exportValue:o}=i.detail.insert,l=s.children[n],h=document.createElement("option");h.textContent=a;h.value=o;l?l.before(h):s.append(h);t.setValue(e,{value:getValue(!0),items:getItems(i)});r=getValue(!1)},items(i){const{items:n}=i.detail;for(;0!==s.length;)s.remove(0);for(const t of n){const{displayValue:e,exportValue:i}=t,n=document.createElement("option");n.textContent=e;n.value=i;s.append(n)}s.options.length>0&&(s.options[0].selected=!0);t.setValue(e,{value:getValue(!0),items:getItems(i)});r=getValue(!1)},indices(i){const s=new Set(i.detail.indices);for(const t of i.target.options)t.selected=s.has(t.index);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},editable(t){t.target.disabled=!t.detail.editable}};this._dispatchEventFromSandbox(n,i)}));s.addEventListener("input",(i=>{const s=getValue(!0),n=getValue(!1);t.setValue(e,{value:s});i.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:r,change:n,changeEx:s,willCommit:!1,commitKey:1,keyDown:!1}})}));this._setEventListeners(s,null,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"],["input","Action"],["input","Validate"]],(t=>t.target.value))}else s.addEventListener("input",(function(i){t.setValue(e,{value:getValue(!0)})}));this.data.combo&&this._setTextStyle(s);this._setBackgroundColor(s);this._setDefaultPropertiesFromJS(s);this.container.append(s);return this.container}}class PopupAnnotationElement extends AnnotationElement{constructor(t){const{data:e,elements:i}=t;super(t,{isRenderable:AnnotationElement._hasPopupData(e)});this.elements=i;this.popup=null}render(){this.container.classList.add("popupAnnotation");const t=this.popup=new PopupElement({container:this.container,color:this.data.color,titleObj:this.data.titleObj,modificationDate:this.data.modificationDate,contentsObj:this.data.contentsObj,richText:this.data.richText,rect:this.data.rect,parentRect:this.data.parentRect||null,parent:this.parent,elements:this.elements,open:this.data.open}),e=[];for(const i of this.elements){i.popup=t;i.container.ariaHasPopup="dialog";e.push(i.data.id);i.addHighlightArea()}this.container.setAttribute("aria-controls",e.map((t=>`${et}${t}`)).join(","));return this.container}}class PopupElement{#Ws=this.#qs.bind(this);#Xs=this.#Ks.bind(this);#Ys=this.#Qs.bind(this);#Js=this.#Zs.bind(this);#tn=null;#pt=null;#en=null;#in=null;#sn=null;#nn=null;#an=null;#rn=!1;#on=null;#C=null;#ln=null;#hn=null;#dn=null;#Hs=null;#cn=!1;constructor({container:t,color:e,elements:i,titleObj:s,modificationDate:n,contentsObj:a,richText:r,parent:o,rect:l,parentRect:h,open:d}){this.#pt=t;this.#dn=s;this.#en=a;this.#hn=r;this.#nn=o;this.#tn=e;this.#ln=l;this.#an=h;this.#sn=i;this.#in=PDFDateString.toDateObject(n);this.trigger=i.flatMap((t=>t.getElementsToTriggerPopup()));for(const t of this.trigger){t.addEventListener("click",this.#Js);t.addEventListener("mouseenter",this.#Ys);t.addEventListener("mouseleave",this.#Xs);t.classList.add("popupTriggerArea")}for(const t of i)t.container?.addEventListener("keydown",this.#Ws);this.#pt.hidden=!0;d&&this.#Zs()}render(){if(this.#on)return;const t=this.#on=document.createElement("div");t.className="popup";if(this.#tn){const e=t.style.outlineColor=Util.makeHexColor(...this.#tn);if(CSS.supports("background-color","color-mix(in srgb, red 30%, white)"))t.style.backgroundColor=`color-mix(in srgb, ${e} 30%, white)`;else{const e=.7;t.style.backgroundColor=Util.makeHexColor(...this.#tn.map((t=>Math.floor(e*(255-t)+t))))}}const e=document.createElement("span");e.className="header";const i=document.createElement("h1");e.append(i);({dir:i.dir,str:i.textContent}=this.#dn);t.append(e);if(this.#in){const t=document.createElement("span");t.classList.add("popupDate");t.setAttribute("data-l10n-id","pdfjs-annotation-date-time-string");t.setAttribute("data-l10n-args",JSON.stringify({dateObj:this.#in.valueOf()}));e.append(t)}const s=this.#un;if(s){XfaLayer.render({xfaHtml:s,intent:"richText",div:t});t.lastChild.classList.add("richText","popupContent")}else{const e=this._formatContents(this.#en);t.append(e)}this.#pt.append(t)}get#un(){const t=this.#hn,e=this.#en;return!t?.str||e?.str&&e.str!==t.str?null:this.#hn.html||null}get#pn(){return this.#un?.attributes?.style?.fontSize||0}get#gn(){return this.#un?.attributes?.style?.color||null}#mn(t){const e=[],i={str:t,html:{name:"div",attributes:{dir:"auto"},children:[{name:"p",children:e}]}},s={style:{color:this.#gn,fontSize:this.#pn?`calc(${this.#pn}px * var(--scale-factor))`:""}};for(const i of t.split("\n"))e.push({name:"span",value:i,attributes:s});return i}_formatContents({str:t,dir:e}){const i=document.createElement("p");i.classList.add("popupContent");i.dir=e;const s=t.split(/(?:\r\n?|\n)/);for(let t=0,e=s.length;t=0&&n.setAttribute("stroke-width",e||1);if(i)for(let t=0,e=this.#xn.length;t{"Enter"===t.key&&(s?t.metaKey:t.ctrlKey)&&this.#Sn()}));!e.popupRef&&this.hasPopupData?this._createPopup():i.classList.add("popupTriggerArea");t.append(i);return t}getElementsToTriggerPopup(){return this.#En}addHighlightArea(){this.container.classList.add("highlightArea")}#Sn(){this.downloadManager?.openOrDownloadData(this.content,this.filename)}}class AnnotationLayer{#Cn=null;#Tn=null;#Mn=new Map;#Pn=null;constructor({div:t,accessibilityManager:e,annotationCanvasMap:i,annotationEditorUIManager:s,page:n,viewport:a,structTreeLayer:r}){this.div=t;this.#Cn=e;this.#Tn=i;this.#Pn=r||null;this.page=n;this.viewport=a;this.zIndex=0;this._annotationEditorUIManager=s}hasEditableAnnotations(){return this.#Mn.size>0}async#Dn(t,e){const i=t.firstChild||t,s=i.id=`${et}${e}`,n=await(this.#Pn?.getAriaAttributes(s));if(n)for(const[t,e]of n)i.setAttribute(t,e);this.div.append(t);this.#Cn?.moveElementInDOM(this.div,t,i,!1)}async render(t){const{annotations:e}=t,i=this.div;setLayerDimensions(i,this.viewport);const s=new Map,n={data:null,layer:i,linkService:t.linkService,downloadManager:t.downloadManager,imageResourcesPath:t.imageResourcesPath||"",renderForms:!1!==t.renderForms,svgFactory:new DOMSVGFactory,annotationStorage:t.annotationStorage||new AnnotationStorage,enableScripting:!0===t.enableScripting,hasJSActions:t.hasJSActions,fieldObjects:t.fieldObjects,parent:this,elements:null};for(const t of e){if(t.noHTML)continue;const e=t.annotationType===H;if(e){const e=s.get(t.id);if(!e)continue;n.elements=e}else{const{width:e,height:i}=getRectDims(t.rect);if(e<=0||i<=0)continue}n.data=t;const i=AnnotationElementFactory.create(n);if(!i.isRenderable)continue;if(!e&&t.popupRef){const e=s.get(t.popupRef);e?e.push(i):s.set(t.popupRef,[i])}const a=i.render();t.hidden&&(a.style.visibility="hidden");await this.#Dn(a,t.id);if(i._isEditable){this.#Mn.set(i.data.id,i);this._annotationEditorUIManager?.renderAnnotationElement(i)}}this.#kn()}update({viewport:t}){const e=this.div;this.viewport=t;setLayerDimensions(e,{rotation:t.rotation});this.#kn();e.hidden=!1}#kn(){if(!this.#Tn)return;const t=this.div;for(const[e,i]of this.#Tn){const s=t.querySelector(`[data-annotation-id="${e}"]`);if(!s)continue;i.className="annotationContent";const{firstChild:n}=s;n?"CANVAS"===n.nodeName?n.replaceWith(i):n.classList.contains("annotationContent")?n.after(i):n.before(i):s.append(i)}this.#Tn.clear()}getEditableAnnotations(){return Array.from(this.#Mn.values())}getEditableAnnotation(t){return this.#Mn.get(t)}}const zt=/\r\n?|\n/g;class FreeTextEditor extends AnnotationEditor{#tn;#Rn="";#In=`${this.id}-editor`;#Fn=null;#pn;static _freeTextDefaultContent="";static _internalPadding=0;static _defaultColor=null;static _defaultFontSize=10;static get _keyboardManager(){const t=FreeTextEditor.prototype,arrowChecker=t=>t.isEmpty(),e=AnnotationEditorUIManager.TRANSLATE_SMALL,i=AnnotationEditorUIManager.TRANSLATE_BIG;return shadow(this,"_keyboardManager",new KeyboardManager([[["ctrl+s","mac+meta+s","ctrl+p","mac+meta+p"],t.commitOrRemove,{bubbles:!0}],[["ctrl+Enter","mac+meta+Enter","Escape","mac+Escape"],t.commitOrRemove],[["ArrowLeft","mac+ArrowLeft"],t._translateEmpty,{args:[-e,0],checker:arrowChecker}],[["ctrl+ArrowLeft","mac+shift+ArrowLeft"],t._translateEmpty,{args:[-i,0],checker:arrowChecker}],[["ArrowRight","mac+ArrowRight"],t._translateEmpty,{args:[e,0],checker:arrowChecker}],[["ctrl+ArrowRight","mac+shift+ArrowRight"],t._translateEmpty,{args:[i,0],checker:arrowChecker}],[["ArrowUp","mac+ArrowUp"],t._translateEmpty,{args:[0,-e],checker:arrowChecker}],[["ctrl+ArrowUp","mac+shift+ArrowUp"],t._translateEmpty,{args:[0,-i],checker:arrowChecker}],[["ArrowDown","mac+ArrowDown"],t._translateEmpty,{args:[0,e],checker:arrowChecker}],[["ctrl+ArrowDown","mac+shift+ArrowDown"],t._translateEmpty,{args:[0,i],checker:arrowChecker}]]))}static _type="freetext";static _editorType=g.FREETEXT;constructor(t){super({...t,name:"freeTextEditor"});this.#tn=t.color||FreeTextEditor._defaultColor||AnnotationEditor._defaultLineColor;this.#pn=t.fontSize||FreeTextEditor._defaultFontSize}static initialize(t,e){AnnotationEditor.initialize(t,e);const i=getComputedStyle(document.documentElement);this._internalPadding=parseFloat(i.getPropertyValue("--freetext-padding"))}static updateDefaultParams(t,e){switch(t){case m.FREETEXT_SIZE:FreeTextEditor._defaultFontSize=e;break;case m.FREETEXT_COLOR:FreeTextEditor._defaultColor=e}}updateParams(t,e){switch(t){case m.FREETEXT_SIZE:this.#Ln(e);break;case m.FREETEXT_COLOR:this.#On(e)}}static get defaultPropertiesToUpdate(){return[[m.FREETEXT_SIZE,FreeTextEditor._defaultFontSize],[m.FREETEXT_COLOR,FreeTextEditor._defaultColor||AnnotationEditor._defaultLineColor]]}get propertiesToUpdate(){return[[m.FREETEXT_SIZE,this.#pn],[m.FREETEXT_COLOR,this.#tn]]}#Ln(t){const setFontsize=t=>{this.editorDiv.style.fontSize=`calc(${t}px * var(--scale-factor))`;this.translate(0,-(t-this.#pn)*this.parentScale);this.#pn=t;this.#Nn()},e=this.#pn;this.addCommands({cmd:setFontsize.bind(this,t),undo:setFontsize.bind(this,e),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.FREETEXT_SIZE,overwriteIfSameType:!0,keepUndo:!0})}#On(t){const setColor=t=>{this.#tn=this.editorDiv.style.color=t},e=this.#tn;this.addCommands({cmd:setColor.bind(this,t),undo:setColor.bind(this,e),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.FREETEXT_COLOR,overwriteIfSameType:!0,keepUndo:!0})}_translateEmpty(t,e){this._uiManager.translateSelectedEditors(t,e,!0)}getInitialTranslation(){const t=this.parentScale;return[-FreeTextEditor._internalPadding*t,-(FreeTextEditor._internalPadding+this.#pn)*t]}rebuild(){if(this.parent){super.rebuild();null!==this.div&&(this.isAttachedToDOM||this.parent.add(this))}}enableEditMode(){if(this.isInEditMode())return;this.parent.setEditingState(!1);this.parent.updateToolbar(g.FREETEXT);super.enableEditMode();this.overlayDiv.classList.remove("enabled");this.editorDiv.contentEditable=!0;this._isDraggable=!1;this.div.removeAttribute("aria-activedescendant");this.#Fn=new AbortController;const t=this._uiManager.combinedSignal(this.#Fn);this.editorDiv.addEventListener("keydown",this.editorDivKeydown.bind(this),{signal:t});this.editorDiv.addEventListener("focus",this.editorDivFocus.bind(this),{signal:t});this.editorDiv.addEventListener("blur",this.editorDivBlur.bind(this),{signal:t});this.editorDiv.addEventListener("input",this.editorDivInput.bind(this),{signal:t});this.editorDiv.addEventListener("paste",this.editorDivPaste.bind(this),{signal:t})}disableEditMode(){if(this.isInEditMode()){this.parent.setEditingState(!0);super.disableEditMode();this.overlayDiv.classList.add("enabled");this.editorDiv.contentEditable=!1;this.div.setAttribute("aria-activedescendant",this.#In);this._isDraggable=!0;this.#Fn?.abort();this.#Fn=null;this.div.focus({preventScroll:!0});this.isEditing=!1;this.parent.div.classList.add("freetextEditing")}}focusin(t){if(this._focusEventsAllowed){super.focusin(t);t.target!==this.editorDiv&&this.editorDiv.focus()}}onceAdded(t){if(!this.width){this.enableEditMode();t&&this.editorDiv.focus();this._initialOptions?.isCentered&&this.center();this._initialOptions=null}}isEmpty(){return!this.editorDiv||""===this.editorDiv.innerText.trim()}remove(){this.isEditing=!1;if(this.parent){this.parent.setEditingState(!0);this.parent.div.classList.add("freetextEditing")}super.remove()}#Bn(){const t=[];this.editorDiv.normalize();let e=null;for(const i of this.editorDiv.childNodes)if(e?.nodeType!==Node.TEXT_NODE||"BR"!==i.nodeName){t.push(FreeTextEditor.#Hn(i));e=i}return t.join("\n")}#Nn(){const[t,e]=this.parentDimensions;let i;if(this.isAttachedToDOM)i=this.div.getBoundingClientRect();else{const{currentLayer:t,div:e}=this,s=e.style.display,n=e.classList.contains("hidden");e.classList.remove("hidden");e.style.display="hidden";t.div.append(this.div);i=e.getBoundingClientRect();e.remove();e.style.display=s;e.classList.toggle("hidden",n)}if(this.rotation%180==this.parentRotation%180){this.width=i.width/t;this.height=i.height/e}else{this.width=i.height/t;this.height=i.width/e}this.fixAndSetPosition()}commit(){if(!this.isInEditMode())return;super.commit();this.disableEditMode();const t=this.#Rn,e=this.#Rn=this.#Bn().trimEnd();if(t===e)return;const setText=t=>{this.#Rn=t;if(t){this.#zn();this._uiManager.rebuild(this);this.#Nn()}else this.remove()};this.addCommands({cmd:()=>{setText(e)},undo:()=>{setText(t)},mustExec:!1});this.#Nn()}shouldGetKeyboardEvents(){return this.isInEditMode()}enterInEditMode(){this.enableEditMode();this.editorDiv.focus()}dblclick(t){this.enterInEditMode()}keydown(t){if(t.target===this.div&&"Enter"===t.key){this.enterInEditMode();t.preventDefault()}}editorDivKeydown(t){FreeTextEditor._keyboardManager.exec(this,t)}editorDivFocus(t){this.isEditing=!0}editorDivBlur(t){this.isEditing=!1}editorDivInput(t){this.parent.div.classList.toggle("freetextEditing",this.isEmpty())}disableEditing(){this.editorDiv.setAttribute("role","comment");this.editorDiv.removeAttribute("aria-multiline")}enableEditing(){this.editorDiv.setAttribute("role","textbox");this.editorDiv.setAttribute("aria-multiline",!0)}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();this.editorDiv=document.createElement("div");this.editorDiv.className="internal";this.editorDiv.setAttribute("id",this.#In);this.editorDiv.setAttribute("data-l10n-id","pdfjs-free-text2");this.editorDiv.setAttribute("data-l10n-attrs","default-content");this.enableEditing();this.editorDiv.contentEditable=!0;const{style:i}=this.editorDiv;i.fontSize=`calc(${this.#pn}px * var(--scale-factor))`;i.color=this.#tn;this.div.append(this.editorDiv);this.overlayDiv=document.createElement("div");this.overlayDiv.classList.add("overlay","enabled");this.div.append(this.overlayDiv);bindEvents(this,this.div,["dblclick","keydown"]);if(this.width){const[i,s]=this.parentDimensions;if(this.annotationElementId){const{position:n}=this._initialData;let[a,r]=this.getInitialTranslation();[a,r]=this.pageTranslationToScreen(a,r);const[o,l]=this.pageDimensions,[h,d]=this.pageTranslation;let c,u;switch(this.rotation){case 0:c=t+(n[0]-h)/o;u=e+this.height-(n[1]-d)/l;break;case 90:c=t+(n[0]-h)/o;u=e-(n[1]-d)/l;[a,r]=[r,-a];break;case 180:c=t-this.width+(n[0]-h)/o;u=e-(n[1]-d)/l;[a,r]=[-a,-r];break;case 270:c=t+(n[0]-h-this.height*l)/o;u=e+(n[1]-d-this.width*o)/l;[a,r]=[-r,a]}this.setAt(c*i,u*s,a,r)}else this.setAt(t*i,e*s,this.width*i,this.height*s);this.#zn();this._isDraggable=!0;this.editorDiv.contentEditable=!1}else{this._isDraggable=!1;this.editorDiv.contentEditable=!0}return this.div}static#Hn(t){return(t.nodeType===Node.TEXT_NODE?t.nodeValue:t.innerText).replaceAll(zt,"")}editorDivPaste(t){const e=t.clipboardData||window.clipboardData,{types:i}=e;if(1===i.length&&"text/plain"===i[0])return;t.preventDefault();const s=FreeTextEditor.#Un(e.getData("text")||"").replaceAll(zt,"\n");if(!s)return;const n=window.getSelection();if(!n.rangeCount)return;this.editorDiv.normalize();n.deleteFromDocument();const a=n.getRangeAt(0);if(!s.includes("\n")){a.insertNode(document.createTextNode(s));this.editorDiv.normalize();n.collapseToStart();return}const{startContainer:r,startOffset:o}=a,l=[],h=[];if(r.nodeType===Node.TEXT_NODE){const t=r.parentElement;h.push(r.nodeValue.slice(o).replaceAll(zt,""));if(t!==this.editorDiv){let e=l;for(const i of this.editorDiv.childNodes)i!==t?e.push(FreeTextEditor.#Hn(i)):e=h}l.push(r.nodeValue.slice(0,o).replaceAll(zt,""))}else if(r===this.editorDiv){let t=l,e=0;for(const i of this.editorDiv.childNodes){e++===o&&(t=h);t.push(FreeTextEditor.#Hn(i))}}this.#Rn=`${l.join("\n")}${s}${h.join("\n")}`;this.#zn();const d=new Range;let c=l.reduce(((t,e)=>t+e.length),0);for(const{firstChild:t}of this.editorDiv.childNodes)if(t.nodeType===Node.TEXT_NODE){const e=t.nodeValue.length;if(c<=e){d.setStart(t,c);d.setEnd(t,c);break}c-=e}n.removeAllRanges();n.addRange(d)}#zn(){this.editorDiv.replaceChildren();if(this.#Rn)for(const t of this.#Rn.split("\n")){const e=document.createElement("div");e.append(t?document.createTextNode(t):document.createElement("br"));this.editorDiv.append(e)}}#Gn(){return this.#Rn.replaceAll(" "," ")}static#Un(t){return t.replaceAll(" "," ")}get contentDiv(){return this.editorDiv}static async deserialize(t,e,i){let s=null;if(t instanceof FreeTextAnnotationElement){const{data:{defaultAppearanceData:{fontSize:e,fontColor:i},rect:n,rotation:a,id:r,popupRef:o},textContent:l,textPosition:h,parent:{page:{pageNumber:d}}}=t;if(!l||0===l.length)return null;s=t={annotationType:g.FREETEXT,color:Array.from(i),fontSize:e,value:l.join("\n"),position:h,pageIndex:d-1,rect:n.slice(0),rotation:a,id:r,deleted:!1,popupRef:o}}const n=await super.deserialize(t,e,i);n.#pn=t.fontSize;n.#tn=Util.makeHexColor(...t.color);n.#Rn=FreeTextEditor.#Un(t.value);n.annotationElementId=t.id||null;n._initialData=s;return n}serialize(t=!1){if(this.isEmpty())return null;if(this.deleted)return this.serializeDeleted();const e=FreeTextEditor._internalPadding*this.parentScale,i=this.getRect(e,e),s=AnnotationEditor._colorManager.convert(this.isAttachedToDOM?getComputedStyle(this.editorDiv).color:this.#tn),n={annotationType:g.FREETEXT,color:s,fontSize:this.#pn,value:this.#Gn(),pageIndex:this.pageIndex,rect:i,rotation:this.rotation,structTreeParentId:this._structTreeParentId};if(t)return n;if(this.annotationElementId&&!this.#$n(n))return null;n.id=this.annotationElementId;return n}#$n(t){const{value:e,fontSize:i,color:s,pageIndex:n}=this._initialData;return this._hasBeenMoved||t.value!==e||t.fontSize!==i||t.color.some(((t,e)=>t!==s[e]))||t.pageIndex!==n}renderAnnotationElement(t){const e=super.renderAnnotationElement(t);if(this.deleted)return e;const{style:i}=e;i.fontSize=`calc(${this.#pn}px * var(--scale-factor))`;i.color=this.#tn;e.replaceChildren();for(const t of this.#Rn.split("\n")){const i=document.createElement("div");i.append(t?document.createTextNode(t):document.createElement("br"));e.append(i)}const s=FreeTextEditor._internalPadding*this.parentScale;t.updateEdited({rect:this.getRect(s,s),popupContent:this.#Rn});return e}resetAnnotationElement(t){super.resetAnnotationElement(t);t.resetEdited()}}class Outline{static PRECISION=1e-4;toSVGPath(){unreachable("Abstract method `toSVGPath` must be implemented.")}get box(){unreachable("Abstract getter `box` must be implemented.")}serialize(t,e){unreachable("Abstract method `serialize` must be implemented.")}static _rescale(t,e,i,s,n,a){a||=new Float32Array(t.length);for(let r=0,o=t.length;r=6;t-=6)isNaN(e[t])?i.push(`L${e[t+4]} ${e[t+5]}`):i.push(`C${e[t]} ${e[t+1]} ${e[t+2]} ${e[t+3]} ${e[t+4]} ${e[t+5]}`);this.#ha(i);return i.join(" ")}#oa(){const[t,e,i,s]=this.#Vn,[n,a,r,o]=this.#ra();return`M${(this.#Kn[2]-t)/i} ${(this.#Kn[3]-e)/s} L${(this.#Kn[4]-t)/i} ${(this.#Kn[5]-e)/s} L${n} ${a} L${r} ${o} L${(this.#Kn[16]-t)/i} ${(this.#Kn[17]-e)/s} L${(this.#Kn[14]-t)/i} ${(this.#Kn[15]-e)/s} Z`}#ha(t){const e=this.#jn;t.push(`L${e[4]} ${e[5]} Z`)}#la(t){const[e,i,s,n]=this.#Vn,a=this.#Kn.subarray(4,6),r=this.#Kn.subarray(16,18),[o,l,h,d]=this.#ra();t.push(`L${(a[0]-e)/s} ${(a[1]-i)/n} L${o} ${l} L${h} ${d} L${(r[0]-e)/s} ${(r[1]-i)/n}`)}newFreeDrawOutline(t,e,i,s,n,a){return new FreeDrawOutline(t,e,i,s,n,a)}getOutlines(){const t=this.#Xn,e=this.#jn,i=this.#Kn,[s,n,a,r]=this.#Vn,o=new Float32Array((this.#ia?.length??0)+2);for(let t=0,e=o.length-2;t=6;t-=6)for(let i=0;i<6;i+=2)if(isNaN(e[t+i])){l[h]=l[h+1]=NaN;h+=2}else{l[h]=e[t+i];l[h+1]=e[t+i+1];h+=2}this.#ua(l,h);return this.newFreeDrawOutline(l,o,this.#Vn,this.#ta,this.#Wn,this.#qn)}#da(t){const e=this.#Kn,[i,s,n,a]=this.#Vn,[r,o,l,h]=this.#ra(),d=new Float32Array(36);d.set([NaN,NaN,NaN,NaN,(e[2]-i)/n,(e[3]-s)/a,NaN,NaN,NaN,NaN,(e[4]-i)/n,(e[5]-s)/a,NaN,NaN,NaN,NaN,r,o,NaN,NaN,NaN,NaN,l,h,NaN,NaN,NaN,NaN,(e[16]-i)/n,(e[17]-s)/a,NaN,NaN,NaN,NaN,(e[14]-i)/n,(e[15]-s)/a],0);return this.newFreeDrawOutline(d,t,this.#Vn,this.#ta,this.#Wn,this.#qn)}#ua(t,e){const i=this.#jn;t.set([NaN,NaN,NaN,NaN,i[4],i[5]],e);return e+6}#ca(t,e){const i=this.#Kn.subarray(4,6),s=this.#Kn.subarray(16,18),[n,a,r,o]=this.#Vn,[l,h,d,c]=this.#ra();t.set([NaN,NaN,NaN,NaN,(i[0]-n)/r,(i[1]-a)/o,NaN,NaN,NaN,NaN,l,h,NaN,NaN,NaN,NaN,d,c,NaN,NaN,NaN,NaN,(s[0]-n)/r,(s[1]-a)/o],e);return e+24}}class FreeDrawOutline extends Outline{#Vn;#pa=new Float32Array(4);#Wn;#qn;#ia;#ta;#ga;constructor(t,e,i,s,n,a){super();this.#ga=t;this.#ia=e;this.#Vn=i;this.#ta=s;this.#Wn=n;this.#qn=a;this.lastPoint=[NaN,NaN];this.#ma(a);const[r,o,l,h]=this.#pa;for(let e=0,i=t.length;et[0]-e[0]||t[1]-e[1]||t[2]-e[2]));const t=[];for(const e of this.#ba)if(e[3]){t.push(...this.#wa(e));this.#va(e)}else{this.#ya(e);t.push(...this.#wa(e))}return this.#xa(t)}#xa(t){const e=[],i=new Set;for(const i of t){const[t,s,n]=i;e.push([t,s,i],[t,n,i])}e.sort(((t,e)=>t[1]-e[1]||t[0]-e[0]));for(let t=0,s=e.length;t0;){const t=i.values().next().value;let[e,a,r,o,l]=t;i.delete(t);let h=e,d=a;n=[e,r];s.push(n);for(;;){let t;if(i.has(o))t=o;else{if(!i.has(l))break;t=l}i.delete(t);[e,a,r,o,l]=t;if(h!==e){n.push(h,d,e,d===a?a:r);h=e}d=d===a?r:a}n.push(h,d)}return new HighlightOutline(s,this.#Vn,this.#fa)}#_a(t){const e=this.#Aa;let i=0,s=e.length-1;for(;i<=s;){const n=i+s>>1,a=e[n][0];if(a===t)return n;a=0;s--){const[i,n]=this.#Aa[s];if(i!==t)break;if(i===t&&n===e){this.#Aa.splice(s,1);return}}}#wa(t){const[e,i,s]=t,n=[[e,i,s]],a=this.#_a(s);for(let t=0;t=i)if(o>s)n[t][1]=s;else{if(1===a)return[];n.splice(t,1);t--;a--}else{n[t][2]=i;o>s&&n.push([e,s,o])}}}return n}}class HighlightOutline extends Outline{#Vn;#Ea;constructor(t,e,i){super();this.#Ea=t;this.#Vn=e;this.lastPoint=i}toSVGPath(){const t=[];for(const e of this.#Ea){let[i,s]=e;t.push(`M${i} ${s}`);for(let n=2;n-1){this.#Xa=!0;this.#Za(t);this.#tr()}else if(this.#Ua){this.#Ha=t.anchorNode;this.#za=t.anchorOffset;this.#Va=t.focusNode;this.#ja=t.focusOffset;this.#er();this.#tr();this.rotate(this.rotation)}}get telemetryInitialData(){return{action:"added",type:this.#Xa?"free_highlight":"highlight",color:this._uiManager.highlightColorNames.get(this.color),thickness:this.#ea,methodOfCreation:this.#Ja}}get telemetryFinalData(){return{type:"highlight",color:this._uiManager.highlightColorNames.get(this.color)}}static computeTelemetryFinalData(t){return{numberOfColors:t.get("color").size}}#er(){const t=new HighlightOutliner(this.#Ua,.001);this.#qa=t.getOutlines();[this.x,this.y,this.width,this.height]=this.#qa.box;const e=new HighlightOutliner(this.#Ua,.0025,.001,"ltr"===this._uiManager.direction);this.#$a=e.getOutlines();const{lastPoint:i}=this.#$a;this.#fa=[(i[0]-this.x)/this.width,(i[1]-this.y)/this.height]}#Za({highlightOutlines:t,highlightId:e,clipPathId:i}){this.#qa=t;this.#$a=t.getNewOutline(this.#ea/2+1.5,.0025);if(e>=0){this.#w=e;this.#Ga=i;this.parent.drawLayer.finalizeDraw(e,{bbox:t.box,path:{d:t.toSVGPath()}});this.#Ya=this.parent.drawLayer.drawOutline({rootClass:{highlightOutline:!0,free:!0},bbox:this.#$a.box,path:{d:this.#$a.toSVGPath()}},!0)}else if(this.parent){const e=this.parent.viewport.rotation;this.parent.drawLayer.updateProperties(this.#w,{bbox:HighlightEditor.#ir(this.#qa.box,(e-this.rotation+360)%360),path:{d:t.toSVGPath()}});this.parent.drawLayer.updateProperties(this.#Ya,{bbox:HighlightEditor.#ir(this.#$a.box,e),path:{d:this.#$a.toSVGPath()}})}const[s,n,a,r]=t.box;switch(this.rotation){case 0:this.x=s;this.y=n;this.width=a;this.height=r;break;case 90:{const[t,e]=this.parentDimensions;this.x=n;this.y=1-s;this.width=a*e/t;this.height=r*t/e;break}case 180:this.x=1-s;this.y=1-n;this.width=a;this.height=r;break;case 270:{const[t,e]=this.parentDimensions;this.x=1-n;this.y=s;this.width=a*e/t;this.height=r*t/e;break}}const{lastPoint:o}=this.#$a;this.#fa=[(o[0]-s)/a,(o[1]-n)/r]}static initialize(t,e){AnnotationEditor.initialize(t,e);HighlightEditor._defaultColor||=e.highlightColors?.values().next().value||"#fff066"}static updateDefaultParams(t,e){switch(t){case m.HIGHLIGHT_DEFAULT_COLOR:HighlightEditor._defaultColor=e;break;case m.HIGHLIGHT_THICKNESS:HighlightEditor._defaultThickness=e}}translateInPage(t,e){}get toolbarPosition(){return this.#fa}updateParams(t,e){switch(t){case m.HIGHLIGHT_COLOR:this.#On(e);break;case m.HIGHLIGHT_THICKNESS:this.#sr(e)}}static get defaultPropertiesToUpdate(){return[[m.HIGHLIGHT_DEFAULT_COLOR,HighlightEditor._defaultColor],[m.HIGHLIGHT_THICKNESS,HighlightEditor._defaultThickness]]}get propertiesToUpdate(){return[[m.HIGHLIGHT_COLOR,this.color||HighlightEditor._defaultColor],[m.HIGHLIGHT_THICKNESS,this.#ea||HighlightEditor._defaultThickness],[m.HIGHLIGHT_FREE,this.#Xa]]}#On(t){const setColorAndOpacity=(t,e)=>{this.color=t;this.#Ka=e;this.parent?.drawLayer.updateProperties(this.#w,{root:{fill:t,"fill-opacity":e}});this.#n?.updateColor(t)},e=this.color,i=this.#Ka;this.addCommands({cmd:setColorAndOpacity.bind(this,t,HighlightEditor._defaultOpacity),undo:setColorAndOpacity.bind(this,e,i),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.HIGHLIGHT_COLOR,overwriteIfSameType:!0,keepUndo:!0});this._reportTelemetry({action:"color_changed",color:this._uiManager.highlightColorNames.get(t)},!0)}#sr(t){const e=this.#ea,setThickness=t=>{this.#ea=t;this.#nr(t)};this.addCommands({cmd:setThickness.bind(this,t),undo:setThickness.bind(this,e),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.INK_THICKNESS,overwriteIfSameType:!0,keepUndo:!0});this._reportTelemetry({action:"thickness_changed",thickness:t},!0)}async addEditToolbar(){const t=await super.addEditToolbar();if(!t)return null;if(this._uiManager.highlightColors){this.#n=new ColorPicker({editor:this});t.addColorPicker(this.#n)}return t}disableEditing(){super.disableEditing();this.div.classList.toggle("disabled",!0)}enableEditing(){super.enableEditing();this.div.classList.toggle("disabled",!1)}fixAndSetPosition(){return super.fixAndSetPosition(this.#ar())}getBaseTranslation(){return[0,0]}getRect(t,e){return super.getRect(t,e,this.#ar())}onceAdded(t){this.annotationElementId||this.parent.addUndoableEditor(this);t&&this.div.focus()}remove(){this.#rr();this._reportTelemetry({action:"deleted"});super.remove()}rebuild(){if(this.parent){super.rebuild();if(null!==this.div){this.#tr();this.isAttachedToDOM||this.parent.add(this)}}}setParent(t){let e=!1;if(this.parent&&!t)this.#rr();else if(t){this.#tr(t);e=!this.parent&&this.div?.classList.contains("selectedEditor")}super.setParent(t);this.show(this._isVisible);e&&this.select()}#nr(t){if(!this.#Xa)return;this.#Za({highlightOutlines:this.#qa.getNewOutline(t/2)});this.fixAndSetPosition();const[e,i]=this.parentDimensions;this.setDims(this.width*e,this.height*i)}#rr(){if(null!==this.#w&&this.parent){this.parent.drawLayer.remove(this.#w);this.#w=null;this.parent.drawLayer.remove(this.#Ya);this.#Ya=null}}#tr(t=this.parent){if(null===this.#w){({id:this.#w,clipPathId:this.#Ga}=t.drawLayer.draw({bbox:this.#qa.box,root:{viewBox:"0 0 1 1",fill:this.color,"fill-opacity":this.#Ka},rootClass:{highlight:!0,free:this.#Xa},path:{d:this.#qa.toSVGPath()}},!1,!0));this.#Ya=t.drawLayer.drawOutline({rootClass:{highlightOutline:!0,free:this.#Xa},bbox:this.#$a.box,path:{d:this.#$a.toSVGPath()}},this.#Xa);this.#Wa&&(this.#Wa.style.clipPath=this.#Ga)}}static#ir([t,e,i,s],n){switch(n){case 90:return[1-e-s,t,s,i];case 180:return[1-t-i,1-e-s,i,s];case 270:return[e,1-t-i,s,i]}return[t,e,i,s]}rotate(t){const{drawLayer:e}=this.parent;let i;if(this.#Xa){t=(t-this.rotation+360)%360;i=HighlightEditor.#ir(this.#qa.box,t)}else i=HighlightEditor.#ir([this.x,this.y,this.width,this.height],t);e.updateProperties(this.#w,{bbox:i,root:{"data-main-rotation":t}});e.updateProperties(this.#Ya,{bbox:HighlightEditor.#ir(this.#$a.box,t),root:{"data-main-rotation":t}})}render(){if(this.div)return this.div;const t=super.render();if(this.#Qa){t.setAttribute("aria-label",this.#Qa);t.setAttribute("role","mark")}this.#Xa?t.classList.add("free"):this.div.addEventListener("keydown",this.#or.bind(this),{signal:this._uiManager._signal});const e=this.#Wa=document.createElement("div");t.append(e);e.setAttribute("aria-hidden","true");e.className="internal";e.style.clipPath=this.#Ga;const[i,s]=this.parentDimensions;this.setDims(this.width*i,this.height*s);bindEvents(this,this.#Wa,["pointerover","pointerleave"]);this.enableEditing();return t}pointerover(){this.isSelected||this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{hovered:!0}})}pointerleave(){this.isSelected||this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{hovered:!1}})}#or(t){HighlightEditor._keyboardManager.exec(this,t)}_moveCaret(t){this.parent.unselect(this);switch(t){case 0:case 2:this.#lr(!0);break;case 1:case 3:this.#lr(!1)}}#lr(t){if(!this.#Ha)return;const e=window.getSelection();t?e.setPosition(this.#Ha,this.#za):e.setPosition(this.#Va,this.#ja)}select(){super.select();this.#Ya&&this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{hovered:!1,selected:!0}})}unselect(){super.unselect();if(this.#Ya){this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{selected:!1}});this.#Xa||this.#lr(!1)}}get _mustFixPosition(){return!this.#Xa}show(t=this._isVisible){super.show(t);if(this.parent){this.parent.drawLayer.updateProperties(this.#w,{rootClass:{hidden:!t}});this.parent.drawLayer.updateProperties(this.#Ya,{rootClass:{hidden:!t}})}}#ar(){return this.#Xa?this.rotation:0}#hr(){if(this.#Xa)return null;const[t,e]=this.pageDimensions,[i,s]=this.pageTranslation,n=this.#Ua,a=new Float32Array(8*n.length);let r=0;for(const{x:o,y:l,width:h,height:d}of n){const n=o*t+i,c=(1-l)*e+s;a[r]=a[r+4]=n;a[r+1]=a[r+3]=c;a[r+2]=a[r+6]=n+h*t;a[r+5]=a[r+7]=c-d*e;r+=8}return a}#dr(t){return this.#qa.serialize(t,this.#ar())}static startHighlighting(t,e,{target:i,x:s,y:n}){const{x:a,y:r,width:o,height:l}=i.getBoundingClientRect(),h=new AbortController,d=t.combinedSignal(h),pointerUpCallback=e=>{h.abort();this.#cr(t,e)};window.addEventListener("blur",pointerUpCallback,{signal:d});window.addEventListener("pointerup",pointerUpCallback,{signal:d});window.addEventListener("pointerdown",stopEvent,{capture:!0,passive:!1,signal:d});window.addEventListener("contextmenu",noContextMenu,{signal:d});i.addEventListener("pointermove",this.#ur.bind(this,t),{signal:d});this._freeHighlight=new FreeHighlightOutliner({x:s,y:n},[a,r,o,l],t.scale,this._defaultThickness/2,e,.001);({id:this._freeHighlightId,clipPathId:this._freeHighlightClipId}=t.drawLayer.draw({bbox:[0,0,1,1],root:{viewBox:"0 0 1 1",fill:this._defaultColor,"fill-opacity":this._defaultOpacity},rootClass:{highlight:!0,free:!0},path:{d:this._freeHighlight.toSVGPath()}},!0,!0))}static#ur(t,e){this._freeHighlight.add(e)&&t.drawLayer.updateProperties(this._freeHighlightId,{path:{d:this._freeHighlight.toSVGPath()}})}static#cr(t,e){this._freeHighlight.isEmpty()?t.drawLayer.remove(this._freeHighlightId):t.createAndAddNewEditor(e,!1,{highlightId:this._freeHighlightId,highlightOutlines:this._freeHighlight.getOutlines(),clipPathId:this._freeHighlightClipId,methodOfCreation:"main_toolbar"});this._freeHighlightId=-1;this._freeHighlight=null;this._freeHighlightClipId=""}static async deserialize(t,e,i){let s=null;if(t instanceof HighlightAnnotationElement){const{data:{quadPoints:e,rect:i,rotation:n,id:a,color:r,opacity:o,popupRef:l},parent:{page:{pageNumber:h}}}=t;s=t={annotationType:g.HIGHLIGHT,color:Array.from(r),opacity:o,quadPoints:e,boxes:null,pageIndex:h-1,rect:i.slice(0),rotation:n,id:a,deleted:!1,popupRef:l}}else if(t instanceof InkAnnotationElement){const{data:{inkLists:e,rect:i,rotation:n,id:a,color:r,borderStyle:{rawWidth:o},popupRef:l},parent:{page:{pageNumber:h}}}=t;s=t={annotationType:g.HIGHLIGHT,color:Array.from(r),thickness:o,inkLists:e,boxes:null,pageIndex:h-1,rect:i.slice(0),rotation:n,id:a,deleted:!1,popupRef:l}}const{color:n,quadPoints:a,inkLists:r,opacity:o}=t,l=await super.deserialize(t,e,i);l.color=Util.makeHexColor(...n);l.#Ka=o||1;r&&(l.#ea=t.thickness);l.annotationElementId=t.id||null;l._initialData=s;const[h,d]=l.pageDimensions,[c,u]=l.pageTranslation;if(a){const t=l.#Ua=[];for(let e=0;et!==e[i]))}renderAnnotationElement(t){t.updateEdited({rect:this.getRect(0,0)});return null}static canCreateNewEmptyEditor(){return!1}}class DrawingOptions{#pr=Object.create(null);updateProperty(t,e){this[t]=e;this.updateSVGProperty(t,e)}updateProperties(t){if(t)for(const[e,i]of Object.entries(t))this.updateProperty(e,i)}updateSVGProperty(t,e){this.#pr[t]=e}toSVGProperties(){const t=this.#pr;this.#pr=Object.create(null);return{root:t}}reset(){this.#pr=Object.create(null)}updateAll(t=this){this.updateProperties(t)}clone(){unreachable("Not implemented")}}class DrawingEditor extends AnnotationEditor{#gr=null;#mr;_drawId=null;static _currentDrawId=-1;static _currentParent=null;static#fr=null;static#br=null;static#Ar=null;static#wr=NaN;static#vr=null;static#yr=null;static#xr=NaN;static _INNER_MARGIN=3;constructor(t){super(t);this.#mr=t.mustBeCommitted||!1;if(t.drawOutlines){this.#_r(t);this.#tr()}}#_r({drawOutlines:t,drawId:e,drawingOptions:i}){this.#gr=t;this._drawingOptions||=i;if(e>=0){this._drawId=e;this.parent.drawLayer.finalizeDraw(e,t.defaultProperties)}else this._drawId=this.#Er(t,this.parent);this.#Sr(t.box)}#Er(t,e){const{id:i}=e.drawLayer.draw(DrawingEditor._mergeSVGProperties(this._drawingOptions.toSVGProperties(),t.defaultSVGProperties),!1,!1);return i}static _mergeSVGProperties(t,e){const i=new Set(Object.keys(t));for(const[s,n]of Object.entries(e))i.has(s)?Object.assign(t[s],n):t[s]=n;return t}static getDefaultDrawingOptions(t){unreachable("Not implemented")}static get typesMap(){unreachable("Not implemented")}static get isDrawer(){return!0}static get supportMultipleDrawings(){return!1}static updateDefaultParams(t,e){const i=this.typesMap.get(t);i&&this._defaultDrawingOptions.updateProperty(i,e);if(this._currentParent){DrawingEditor.#fr.updateProperty(i,e);this._currentParent.drawLayer.updateProperties(this._currentDrawId,this._defaultDrawingOptions.toSVGProperties())}}updateParams(t,e){const i=this.constructor.typesMap.get(t);i&&this._updateProperty(t,i,e)}static get defaultPropertiesToUpdate(){const t=[],e=this._defaultDrawingOptions;for(const[i,s]of this.typesMap)t.push([i,e[s]]);return t}get propertiesToUpdate(){const t=[],{_drawingOptions:e}=this;for(const[i,s]of this.constructor.typesMap)t.push([i,e[s]]);return t}_updateProperty(t,e,i){const s=this._drawingOptions,n=s[e],setter=t=>{s.updateProperty(e,t);const i=this.#gr.updateProperty(e,t);i&&this.#Sr(i);this.parent?.drawLayer.updateProperties(this._drawId,s.toSVGProperties())};this.addCommands({cmd:setter.bind(this,i),undo:setter.bind(this,n),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:t,overwriteIfSameType:!0,keepUndo:!0})}_onResizing(){this.parent?.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties(this.#gr.getPathResizingSVGProperties(this.#Cr()),{bbox:this.#Tr()}))}_onResized(){this.parent?.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties(this.#gr.getPathResizedSVGProperties(this.#Cr()),{bbox:this.#Tr()}))}_onTranslating(t,e){this.parent?.drawLayer.updateProperties(this._drawId,{bbox:this.#Tr(t,e)})}_onTranslated(){this.parent?.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties(this.#gr.getPathTranslatedSVGProperties(this.#Cr(),this.parentDimensions),{bbox:this.#Tr()}))}_onStartDragging(){this.parent?.drawLayer.updateProperties(this._drawId,{rootClass:{moving:!0}})}_onStopDragging(){this.parent?.drawLayer.updateProperties(this._drawId,{rootClass:{moving:!1}})}commit(){super.commit();this.disableEditMode();this.disableEditing()}disableEditing(){super.disableEditing();this.div.classList.toggle("disabled",!0)}enableEditing(){super.enableEditing();this.div.classList.toggle("disabled",!1)}getBaseTranslation(){return[0,0]}get isResizable(){return!0}onceAdded(t){this.annotationElementId||this.parent.addUndoableEditor(this);this._isDraggable=!0;if(this.#mr){this.#mr=!1;this.commit();this.parent.setSelected(this);t&&this.isOnScreen&&this.div.focus()}}remove(){this.#rr();super.remove()}rebuild(){if(this.parent){super.rebuild();if(null!==this.div){this.#tr();this.#Sr(this.#gr.box);this.isAttachedToDOM||this.parent.add(this)}}}setParent(t){let e=!1;if(this.parent&&!t){this._uiManager.removeShouldRescale(this);this.#rr()}else if(t){this._uiManager.addShouldRescale(this);this.#tr(t);e=!this.parent&&this.div?.classList.contains("selectedEditor")}super.setParent(t);e&&this.select()}#rr(){if(null!==this._drawId&&this.parent){this.parent.drawLayer.remove(this._drawId);this._drawId=null;this._drawingOptions.reset()}}#tr(t=this.parent){if(null===this._drawId||this.parent!==t)if(null===this._drawId){this._drawingOptions.updateAll();this._drawId=this.#Er(this.#gr,t)}else this.parent.drawLayer.updateParent(this._drawId,t.drawLayer)}#Mr([t,e,i,s]){const{parentDimensions:[n,a],rotation:r}=this;switch(r){case 90:return[e,1-t,i*(a/n),s*(n/a)];case 180:return[1-t,1-e,i,s];case 270:return[1-e,t,i*(a/n),s*(n/a)];default:return[t,e,i,s]}}#Cr(){const{x:t,y:e,width:i,height:s,parentDimensions:[n,a],rotation:r}=this;switch(r){case 90:return[1-e,t,i*(n/a),s*(a/n)];case 180:return[1-t,1-e,i,s];case 270:return[e,1-t,i*(n/a),s*(a/n)];default:return[t,e,i,s]}}#Sr(t){[this.x,this.y,this.width,this.height]=this.#Mr(t);if(this.div){this.fixAndSetPosition();const[t,e]=this.parentDimensions;this.setDims(this.width*t,this.height*e)}this._onResized()}#Tr(){const{x:t,y:e,width:i,height:s,rotation:n,parentRotation:a,parentDimensions:[r,o]}=this;switch((4*n+a)/90){case 1:return[1-e-s,t,s,i];case 2:return[1-t-i,1-e-s,i,s];case 3:return[e,1-t-i,s,i];case 4:return[t,e-i*(r/o),s*(o/r),i*(r/o)];case 5:return[1-e,t,i*(r/o),s*(o/r)];case 6:return[1-t-s*(o/r),1-e,s*(o/r),i*(r/o)];case 7:return[e-i*(r/o),1-t-s*(o/r),i*(r/o),s*(o/r)];case 8:return[t-i,e-s,i,s];case 9:return[1-e,t-i,s,i];case 10:return[1-t,1-e,i,s];case 11:return[e-s,1-t,s,i];case 12:return[t-s*(o/r),e,s*(o/r),i*(r/o)];case 13:return[1-e-i*(r/o),t-s*(o/r),i*(r/o),s*(o/r)];case 14:return[1-t,1-e-i*(r/o),s*(o/r),i*(r/o)];case 15:return[e,1-t,i*(r/o),s*(o/r)];default:return[t,e,i,s]}}rotate(){this.parent&&this.parent.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties({bbox:this.#Tr()},this.#gr.updateRotation((this.parentRotation-this.rotation+360)%360)))}onScaleChanging(){this.parent&&this.#Sr(this.#gr.updateParentDimensions(this.parentDimensions,this.parent.scale))}static onScaleChangingWhenDrawing(){}render(){if(this.div)return this.div;const t=super.render();t.classList.add("draw");const e=document.createElement("div");t.append(e);e.setAttribute("aria-hidden","true");e.className="internal";const[i,s]=this.parentDimensions;this.setDims(this.width*i,this.height*s);this._uiManager.addShouldRescale(this);this.disableEditing();return t}static createDrawerInstance(t,e,i,s,n){unreachable("Not implemented")}static startDrawing(t,e,i,s){const{target:n,offsetX:a,offsetY:r,pointerId:o,pointerType:l}=s;if(DrawingEditor.#vr&&DrawingEditor.#vr!==l)return;const{viewport:{rotation:h}}=t,{width:d,height:c}=n.getBoundingClientRect(),u=DrawingEditor.#br=new AbortController,p=t.combinedSignal(u);DrawingEditor.#wr||=o;DrawingEditor.#vr??=l;window.addEventListener("pointerup",(t=>{DrawingEditor.#wr===t.pointerId?this._endDraw(t):DrawingEditor.#yr?.delete(t.pointerId)}),{signal:p});window.addEventListener("pointercancel",(t=>{DrawingEditor.#wr===t.pointerId?this._currentParent.endDrawingSession():DrawingEditor.#yr?.delete(t.pointerId)}),{signal:p});window.addEventListener("pointerdown",(t=>{if(DrawingEditor.#vr===t.pointerType){(DrawingEditor.#yr||=new Set).add(t.pointerId);if(DrawingEditor.#fr.isCancellable()){DrawingEditor.#fr.removeLastElement();DrawingEditor.#fr.isEmpty()?this._currentParent.endDrawingSession(!0):this._endDraw(null)}}}),{capture:!0,passive:!1,signal:p});window.addEventListener("contextmenu",noContextMenu,{signal:p});n.addEventListener("pointermove",this._drawMove.bind(this),{signal:p});n.addEventListener("touchmove",(t=>{t.timeStamp===DrawingEditor.#xr&&stopEvent(t)}),{signal:p});t.toggleDrawing();e._editorUndoBar?.hide();if(DrawingEditor.#fr)t.drawLayer.updateProperties(this._currentDrawId,DrawingEditor.#fr.startNew(a,r,d,c,h));else{e.updateUIForDefaultProperties(this);DrawingEditor.#fr=this.createDrawerInstance(a,r,d,c,h);DrawingEditor.#Ar=this.getDefaultDrawingOptions();this._currentParent=t;({id:this._currentDrawId}=t.drawLayer.draw(this._mergeSVGProperties(DrawingEditor.#Ar.toSVGProperties(),DrawingEditor.#fr.defaultSVGProperties),!0,!1))}}static _drawMove(t){DrawingEditor.#xr=-1;if(!DrawingEditor.#fr)return;const{offsetX:e,offsetY:i,pointerId:s}=t;if(DrawingEditor.#wr===s)if(DrawingEditor.#yr?.size>=1)this._endDraw(t);else{this._currentParent.drawLayer.updateProperties(this._currentDrawId,DrawingEditor.#fr.add(e,i));DrawingEditor.#xr=t.timeStamp;stopEvent(t)}}static _cleanup(t){if(t){this._currentDrawId=-1;this._currentParent=null;DrawingEditor.#fr=null;DrawingEditor.#Ar=null;DrawingEditor.#vr=null;DrawingEditor.#xr=NaN}if(DrawingEditor.#br){DrawingEditor.#br.abort();DrawingEditor.#br=null;DrawingEditor.#wr=NaN;DrawingEditor.#yr=null}}static _endDraw(t){const e=this._currentParent;if(e){e.toggleDrawing(!0);this._cleanup(!1);t&&e.drawLayer.updateProperties(this._currentDrawId,DrawingEditor.#fr.end(t.offsetX,t.offsetY));if(this.supportMultipleDrawings){const t=DrawingEditor.#fr,i=this._currentDrawId,s=t.getLastElement();e.addCommands({cmd:()=>{e.drawLayer.updateProperties(i,t.setLastElement(s))},undo:()=>{e.drawLayer.updateProperties(i,t.removeLastElement())},mustExec:!1,type:m.DRAW_STEP})}else this.endDrawing(!1)}}static endDrawing(t){const e=this._currentParent;if(!e)return null;e.toggleDrawing(!0);e.cleanUndoStack(m.DRAW_STEP);if(!DrawingEditor.#fr.isEmpty()){const{pageDimensions:[i,s],scale:n}=e,a=e.createAndAddNewEditor({offsetX:0,offsetY:0},!1,{drawId:this._currentDrawId,drawOutlines:DrawingEditor.#fr.getOutlines(i*n,s*n,n,this._INNER_MARGIN),drawingOptions:DrawingEditor.#Ar,mustBeCommitted:!t});this._cleanup(!0);return a}e.drawLayer.remove(this._currentDrawId);this._cleanup(!0);return null}createDrawingOptions(t){}static deserializeDraw(t,e,i,s,n,a){unreachable("Not implemented")}static async deserialize(t,e,i){const{rawDims:{pageWidth:s,pageHeight:n,pageX:a,pageY:r}}=e.viewport,o=this.deserializeDraw(a,r,s,n,this._INNER_MARGIN,t),l=await super.deserialize(t,e,i);l.createDrawingOptions(t);l.#_r({drawOutlines:o});l.#tr();l.onScaleChanging();l.rotate();return l}serializeDraw(t){const[e,i]=this.pageTranslation,[s,n]=this.pageDimensions;return this.#gr.serialize([e,i,s,n],t)}renderAnnotationElement(t){t.updateEdited({rect:this.getRect(0,0)});return null}static canCreateNewEmptyEditor(){return!1}}class InkDrawOutliner{#Kn=new Float64Array(6);#bn;#Pr;#Fi;#ea;#ia;#Dr="";#kr=0;#Ea=new InkDrawOutline;#Rr;#Ir;constructor(t,e,i,s,n,a){this.#Rr=i;this.#Ir=s;this.#Fi=n;this.#ea=a;[t,e]=this.#Fr(t,e);const r=this.#bn=[NaN,NaN,NaN,NaN,t,e];this.#ia=[t,e];this.#Pr=[{line:r,points:this.#ia}];this.#Kn.set(r,0)}updateProperty(t,e){"stroke-width"===t&&(this.#ea=e)}#Fr(t,e){return Outline._normalizePoint(t,e,this.#Rr,this.#Ir,this.#Fi)}isEmpty(){return!this.#Pr||0===this.#Pr.length}isCancellable(){return this.#ia.length<=10}add(t,e){[t,e]=this.#Fr(t,e);const[i,s,n,a]=this.#Kn.subarray(2,6),r=t-n,o=e-a;if(Math.hypot(this.#Rr*r,this.#Ir*o)<=2)return null;this.#ia.push(t,e);if(isNaN(i)){this.#Kn.set([n,a,t,e],2);this.#bn.push(NaN,NaN,NaN,NaN,t,e);return{path:{d:this.toSVGPath()}}}isNaN(this.#Kn[0])&&this.#bn.splice(6,6);this.#Kn.set([i,s,n,a,t,e],0);this.#bn.push(...Outline.createBezierPoints(i,s,n,a,t,e));return{path:{d:this.toSVGPath()}}}end(t,e){const i=this.add(t,e);return i||(2===this.#ia.length?{path:{d:this.toSVGPath()}}:null)}startNew(t,e,i,s,n){this.#Rr=i;this.#Ir=s;this.#Fi=n;[t,e]=this.#Fr(t,e);const a=this.#bn=[NaN,NaN,NaN,NaN,t,e];this.#ia=[t,e];const r=this.#Pr.at(-1);if(r){r.line=new Float32Array(r.line);r.points=new Float32Array(r.points)}this.#Pr.push({line:a,points:this.#ia});this.#Kn.set(a,0);this.#kr=0;this.toSVGPath();return null}getLastElement(){return this.#Pr.at(-1)}setLastElement(t){if(!this.#Pr)return this.#Ea.setLastElement(t);this.#Pr.push(t);this.#bn=t.line;this.#ia=t.points;this.#kr=0;return{path:{d:this.toSVGPath()}}}removeLastElement(){if(!this.#Pr)return this.#Ea.removeLastElement();this.#Pr.pop();this.#Dr="";for(let t=0,e=this.#Pr.length;tt??NaN)),d,c,u,p),points:g(r[t].map((t=>t??NaN)),d,c,u,p)});const m=new InkDrawOutline;m.build(h,i,s,1,o,l,n);return m}#Hr(t=this.#ea){const e=this.#Wn+t/2*this.#Or;return this.#Fi%180==0?[e/this.#Rr,e/this.#Ir]:[e/this.#Ir,e/this.#Rr]}#Br(){const[t,e,i,s]=this.#pa,[n,a]=this.#Hr(0);return[t+n,e+a,i-2*n,s-2*a]}#Nr(){const t=this.#pa=new Float32Array([1/0,1/0,-1/0,-1/0]);for(const{line:e}of this.#Pr){if(e.length<=12){for(let i=4,s=e.length;it!==e[i]))||t.thickness!==i||t.opacity!==s||t.pageIndex!==n}renderAnnotationElement(t){const{points:e,rect:i}=this.serializeDraw(!1);t.updateEdited({rect:i,thickness:this._drawingOptions["stroke-width"],points:e});return null}}class StampEditor extends AnnotationEditor{#Ur=null;#Gr=null;#$r=null;#Vr=null;#jr=null;#Wr="";#qr=null;#Xr=null;#Kr=!1;#Yr=!1;static _type="stamp";static _editorType=g.STAMP;constructor(t){super({...t,name:"stampEditor"});this.#Vr=t.bitmapUrl;this.#jr=t.bitmapFile}static initialize(t,e){AnnotationEditor.initialize(t,e)}static get supportedTypes(){return shadow(this,"supportedTypes",["apng","avif","bmp","gif","jpeg","png","svg+xml","webp","x-icon"].map((t=>`image/${t}`)))}static get supportedTypesStr(){return shadow(this,"supportedTypesStr",this.supportedTypes.join(","))}static isHandlingMimeForPasting(t){return this.supportedTypes.includes(t)}static paste(t,e){e.pasteEditor(g.STAMP,{bitmapFile:t.getAsFile()})}altTextFinish(){this._uiManager.useNewAltTextFlow&&(this.div.hidden=!1);super.altTextFinish()}get telemetryFinalData(){return{type:"stamp",hasAltText:!!this.altTextData?.altText}}static computeTelemetryFinalData(t){const e=t.get("hasAltText");return{hasAltText:e.get(!0)??0,hasNoAltText:e.get(!1)??0}}#Qr(t,e=!1){if(t){this.#Ur=t.bitmap;if(!e){this.#Gr=t.id;this.#Kr=t.isSvg}t.file&&(this.#Wr=t.file.name);this.#Jr()}else this.remove()}#Zr(){this.#$r=null;this._uiManager.enableWaiting(!1);if(this.#qr)if(this._uiManager.useNewAltTextWhenAddingImage&&this._uiManager.useNewAltTextFlow&&this.#Ur){this._editToolbar.hide();this._uiManager.editAltText(this,!0)}else{if(!this._uiManager.useNewAltTextWhenAddingImage&&this._uiManager.useNewAltTextFlow&&this.#Ur){this._reportTelemetry({action:"pdfjs.image.image_added",data:{alt_text_modal:!1,alt_text_type:"empty"}});try{this.mlGuessAltText()}catch{}}this.div.focus()}}async mlGuessAltText(t=null,e=!0){if(this.hasAltTextData())return null;const{mlManager:i}=this._uiManager;if(!i)throw new Error("No ML.");if(!await i.isEnabledFor("altText"))throw new Error("ML isn't enabled for alt text.");const{data:s,width:n,height:a}=t||this.copyCanvas(null,null,!0).imageData,r=await i.guess({name:"altText",request:{data:s,width:n,height:a,channels:s.length/(n*a)}});if(!r)throw new Error("No response from the AI service.");if(r.error)throw new Error("Error from the AI service.");if(r.cancel)return null;if(!r.output)throw new Error("No valid response from the AI service.");const o=r.output;await this.setGuessedAltText(o);e&&!this.hasAltTextData()&&(this.altTextData={alt:o,decorative:!1});return o}#to(){if(this.#Gr){this._uiManager.enableWaiting(!0);this._uiManager.imageManager.getFromId(this.#Gr).then((t=>this.#Qr(t,!0))).finally((()=>this.#Zr()));return}if(this.#Vr){const t=this.#Vr;this.#Vr=null;this._uiManager.enableWaiting(!0);this.#$r=this._uiManager.imageManager.getFromUrl(t).then((t=>this.#Qr(t))).finally((()=>this.#Zr()));return}if(this.#jr){const t=this.#jr;this.#jr=null;this._uiManager.enableWaiting(!0);this.#$r=this._uiManager.imageManager.getFromFile(t).then((t=>this.#Qr(t))).finally((()=>this.#Zr()));return}const t=document.createElement("input");t.type="file";t.accept=StampEditor.supportedTypesStr;const e=this._uiManager._signal;this.#$r=new Promise((i=>{t.addEventListener("change",(async()=>{if(t.files&&0!==t.files.length){this._uiManager.enableWaiting(!0);const e=await this._uiManager.imageManager.getFromFile(t.files[0]);this._reportTelemetry({action:"pdfjs.image.image_selected",data:{alt_text_modal:this._uiManager.useNewAltTextFlow}});this.#Qr(e)}else this.remove();i()}),{signal:e});t.addEventListener("cancel",(()=>{this.remove();i()}),{signal:e})})).finally((()=>this.#Zr()));t.click()}remove(){if(this.#Gr){this.#Ur=null;this._uiManager.imageManager.deleteId(this.#Gr);this.#qr?.remove();this.#qr=null;if(this.#Xr){clearTimeout(this.#Xr);this.#Xr=null}}super.remove()}rebuild(){if(this.parent){super.rebuild();if(null!==this.div){this.#Gr&&null===this.#qr&&this.#to();this.isAttachedToDOM||this.parent.add(this)}}else this.#Gr&&this.#to()}onceAdded(t){this._isDraggable=!0;t&&this.div.focus()}isEmpty(){return!(this.#$r||this.#Ur||this.#Vr||this.#jr||this.#Gr)}get isResizable(){return!0}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();this.div.hidden=!0;this.div.setAttribute("role","figure");this.addAltTextButton();this.#Ur?this.#Jr():this.#to();if(this.width&&!this.annotationElementId){const[i,s]=this.parentDimensions;this.setAt(t*i,e*s,this.width*i,this.height*s)}this._uiManager.addShouldRescale(this);return this.div}_onResized(){this.onScaleChanging()}onScaleChanging(){if(!this.parent)return;null!==this.#Xr&&clearTimeout(this.#Xr);this.#Xr=setTimeout((()=>{this.#Xr=null;this.#eo()}),200)}#Jr(){const{div:t}=this;let{width:e,height:i}=this.#Ur;const[s,n]=this.pageDimensions,a=.75;if(this.width){e=this.width*s;i=this.height*n}else if(e>a*s||i>a*n){const t=Math.min(a*s/e,a*n/i);e*=t;i*=t}const[r,o]=this.parentDimensions;this.setDims(e*r/s,i*o/n);this._uiManager.enableWaiting(!1);const l=this.#qr=document.createElement("canvas");l.setAttribute("role","img");this.addContainer(l);this.width=e/s;this.height=i/n;this._initialOptions?.isCentered?this.center():this.fixAndSetPosition();this._initialOptions=null;this._uiManager.useNewAltTextWhenAddingImage&&this._uiManager.useNewAltTextFlow&&!this.annotationElementId||(t.hidden=!1);this.#eo();if(!this.#Yr){this.parent.addUndoableEditor(this);this.#Yr=!0}this._reportTelemetry({action:"inserted_image"});this.#Wr&&l.setAttribute("aria-label",this.#Wr)}copyCanvas(t,e,i=!1){t||(t=224);const{width:s,height:n}=this.#Ur,a=new OutputScale;let r=this.#Ur,o=s,l=n,h=null;if(e){if(s>e||n>e){const t=Math.min(e/s,e/n);o=Math.floor(s*t);l=Math.floor(n*t)}h=document.createElement("canvas");const t=h.width=Math.ceil(o*a.sx),i=h.height=Math.ceil(l*a.sy);this.#Kr||(r=this.#io(t,i));const d=h.getContext("2d");d.filter=this._uiManager.hcmFilter;let c="white",u="#cfcfd8";if("none"!==this._uiManager.hcmFilter)u="black";else if(window.matchMedia?.("(prefers-color-scheme: dark)").matches){c="#8f8f9d";u="#42414d"}const p=15,g=p*a.sx,m=p*a.sy,f=new OffscreenCanvas(2*g,2*m),b=f.getContext("2d");b.fillStyle=c;b.fillRect(0,0,2*g,2*m);b.fillStyle=u;b.fillRect(0,0,g,m);b.fillRect(g,m,g,m);d.fillStyle=d.createPattern(f,"repeat");d.fillRect(0,0,t,i);d.drawImage(r,0,0,r.width,r.height,0,0,t,i)}let d=null;if(i){let e,i;if(a.symmetric&&r.widtht||n>t){const a=Math.min(t/s,t/n);e=Math.floor(s*a);i=Math.floor(n*a);this.#Kr||(r=this.#io(e,i))}}const o=new OffscreenCanvas(e,i).getContext("2d",{willReadFrequently:!0});o.drawImage(r,0,0,r.width,r.height,0,0,e,i);d={width:e,height:i,data:o.getImageData(0,0,e,i).data}}return{canvas:h,width:o,height:l,imageData:d}}#io(t,e){const{width:i,height:s}=this.#Ur;let n=i,a=s,r=this.#Ur;for(;n>2*t||a>2*e;){const i=n,s=a;n>2*t&&(n=n>=16384?Math.floor(n/2)-1:Math.ceil(n/2));a>2*e&&(a=a>=16384?Math.floor(a/2)-1:Math.ceil(a/2));const o=new OffscreenCanvas(n,a);o.getContext("2d").drawImage(r,0,0,i,s,0,0,n,a);r=o.transferToImageBitmap()}return r}#eo(){const[t,e]=this.parentDimensions,{width:i,height:s}=this,n=new OutputScale,a=Math.ceil(i*t*n.sx),r=Math.ceil(s*e*n.sy),o=this.#qr;if(!o||o.width===a&&o.height===r)return;o.width=a;o.height=r;const l=this.#Kr?this.#Ur:this.#io(a,r),h=o.getContext("2d");h.filter=this._uiManager.hcmFilter;h.drawImage(l,0,0,l.width,l.height,0,0,a,r)}getImageForAltText(){return this.#qr}#so(t){if(t){if(this.#Kr){const t=this._uiManager.imageManager.getSvgUrl(this.#Gr);if(t)return t}const t=document.createElement("canvas");({width:t.width,height:t.height}=this.#Ur);t.getContext("2d").drawImage(this.#Ur,0,0);return t.toDataURL()}if(this.#Kr){const[t,e]=this.pageDimensions,i=Math.round(this.width*t*PixelsPerInch.PDF_TO_CSS_UNITS),s=Math.round(this.height*e*PixelsPerInch.PDF_TO_CSS_UNITS),n=new OffscreenCanvas(i,s);n.getContext("2d").drawImage(this.#Ur,0,0,this.#Ur.width,this.#Ur.height,0,0,i,s);return n.transferToImageBitmap()}return structuredClone(this.#Ur)}static async deserialize(t,e,i){let s=null;if(t instanceof StampAnnotationElement){const{data:{rect:n,rotation:a,id:r,structParent:o,popupRef:l},container:h,parent:{page:{pageNumber:d}}}=t,c=h.querySelector("canvas"),u=i.imageManager.getFromCanvas(h.id,c);c.remove();const p=(await e._structTree.getAriaAttributes(`${et}${r}`))?.get("aria-label")||"";s=t={annotationType:g.STAMP,bitmapId:u.id,bitmap:u.bitmap,pageIndex:d-1,rect:n.slice(0),rotation:a,id:r,deleted:!1,accessibilityData:{decorative:!1,altText:p},isSvg:!1,structParent:o,popupRef:l}}const n=await super.deserialize(t,e,i),{rect:a,bitmap:r,bitmapUrl:o,bitmapId:l,isSvg:h,accessibilityData:d}=t;if(l&&i.imageManager.isValidId(l)){n.#Gr=l;r&&(n.#Ur=r)}else n.#Vr=o;n.#Kr=h;const[c,u]=n.pageDimensions;n.width=(a[2]-a[0])/c;n.height=(a[3]-a[1])/u;n.annotationElementId=t.id||null;d&&(n.altTextData=d);n._initialData=s;n.#Yr=!!s;return n}serialize(t=!1,e=null){if(this.isEmpty())return null;if(this.deleted)return this.serializeDeleted();const i={annotationType:g.STAMP,bitmapId:this.#Gr,pageIndex:this.pageIndex,rect:this.getRect(0,0),rotation:this.rotation,isSvg:this.#Kr,structTreeParentId:this._structTreeParentId};if(t){i.bitmapUrl=this.#so(!0);i.accessibilityData=this.serializeAltText(!0);return i}const{decorative:s,altText:n}=this.serializeAltText(!1);!s&&n&&(i.accessibilityData={type:"Figure",alt:n});if(this.annotationElementId){const t=this.#$n(i);if(t.isSame)return null;t.isSameAltText?delete i.accessibilityData:i.accessibilityData.structParent=this._initialData.structParent??-1}i.id=this.annotationElementId;if(null===e)return i;e.stamps||=new Map;const a=this.#Kr?(i.rect[2]-i.rect[0])*(i.rect[3]-i.rect[1]):null;if(e.stamps.has(this.#Gr)){if(this.#Kr){const t=e.stamps.get(this.#Gr);if(a>t.area){t.area=a;t.serialized.bitmap.close();t.serialized.bitmap=this.#so(!1)}}}else{e.stamps.set(this.#Gr,{area:a,serialized:i});i.bitmap=this.#so(!1)}return i}#$n(t){const{pageIndex:e,accessibilityData:{altText:i}}=this._initialData,s=t.pageIndex===e,n=(t.accessibilityData?.alt||"")===i;return{isSame:!this._hasBeenMoved&&!this._hasBeenResized&&s&&n,isSameAltText:n}}renderAnnotationElement(t){t.updateEdited({rect:this.getRect(0,0)});return null}}class AnnotationEditorLayer{#Cn;#no=!1;#ao=null;#ro=null;#oo=null;#lo=new Map;#ho=!1;#do=!1;#co=!1;#uo=null;#po=null;#go=null;#mo=null;#m;static _initialized=!1;static#U=new Map([FreeTextEditor,InkEditor,StampEditor,HighlightEditor].map((t=>[t._editorType,t])));constructor({uiManager:t,pageIndex:e,div:i,structTreeLayer:s,accessibilityManager:n,annotationLayer:a,drawLayer:r,textLayer:o,viewport:l,l10n:h}){const d=[...AnnotationEditorLayer.#U.values()];if(!AnnotationEditorLayer._initialized){AnnotationEditorLayer._initialized=!0;for(const e of d)e.initialize(h,t)}t.registerEditorTypes(d);this.#m=t;this.pageIndex=e;this.div=i;this.#Cn=n;this.#ao=a;this.viewport=l;this.#go=o;this.drawLayer=r;this._structTree=s;this.#m.addLayer(this)}get isEmpty(){return 0===this.#lo.size}get isInvisible(){return this.isEmpty&&this.#m.getMode()===g.NONE}updateToolbar(t){this.#m.updateToolbar(t)}updateMode(t=this.#m.getMode()){this.#fo();switch(t){case g.NONE:this.disableTextSelection();this.togglePointerEvents(!1);this.toggleAnnotationLayerPointerEvents(!0);this.disableClick();return;case g.INK:this.disableTextSelection();this.togglePointerEvents(!0);this.enableClick();break;case g.HIGHLIGHT:this.enableTextSelection();this.togglePointerEvents(!1);this.disableClick();break;default:this.disableTextSelection();this.togglePointerEvents(!0);this.enableClick()}this.toggleAnnotationLayerPointerEvents(!1);const{classList:e}=this.div;for(const i of AnnotationEditorLayer.#U.values())e.toggle(`${i._type}Editing`,t===i._editorType);this.div.hidden=!1}hasTextLayer(t){return t===this.#go?.div}setEditingState(t){this.#m.setEditingState(t)}addCommands(t){this.#m.addCommands(t)}cleanUndoStack(t){this.#m.cleanUndoStack(t)}toggleDrawing(t=!1){this.div.classList.toggle("drawing",!t)}togglePointerEvents(t=!1){this.div.classList.toggle("disabled",!t)}toggleAnnotationLayerPointerEvents(t=!1){this.#ao?.div.classList.toggle("disabled",!t)}async enable(){this.#co=!0;this.div.tabIndex=0;this.togglePointerEvents(!0);const t=new Set;for(const e of this.#lo.values()){e.enableEditing();e.show(!0);if(e.annotationElementId){this.#m.removeChangedExistingAnnotation(e);t.add(e.annotationElementId)}}if(!this.#ao){this.#co=!1;return}const e=this.#ao.getEditableAnnotations();for(const i of e){i.hide();if(this.#m.isDeletedAnnotationElement(i.data.id))continue;if(t.has(i.data.id))continue;const e=await this.deserialize(i);if(e){this.addOrRebuild(e);e.enableEditing()}}this.#co=!1}disable(){this.#do=!0;this.div.tabIndex=-1;this.togglePointerEvents(!1);const t=new Map,e=new Map;for(const i of this.#lo.values()){i.disableEditing();if(i.annotationElementId)if(null===i.serialize()){e.set(i.annotationElementId,i);this.getEditableAnnotation(i.annotationElementId)?.show();i.remove()}else t.set(i.annotationElementId,i)}if(this.#ao){const i=this.#ao.getEditableAnnotations();for(const s of i){const{id:i}=s.data;if(this.#m.isDeletedAnnotationElement(i))continue;let n=e.get(i);if(n){n.resetAnnotationElement(s);n.show(!1);s.show()}else{n=t.get(i);if(n){this.#m.addChangedExistingAnnotation(n);n.renderAnnotationElement(s)&&n.show(!1)}s.show()}}}this.#fo();this.isEmpty&&(this.div.hidden=!0);const{classList:i}=this.div;for(const t of AnnotationEditorLayer.#U.values())i.remove(`${t._type}Editing`);this.disableTextSelection();this.toggleAnnotationLayerPointerEvents(!0);this.#do=!1}getEditableAnnotation(t){return this.#ao?.getEditableAnnotation(t)||null}setActiveEditor(t){this.#m.getActive()!==t&&this.#m.setActiveEditor(t)}enableTextSelection(){this.div.tabIndex=-1;if(this.#go?.div&&!this.#mo){this.#mo=new AbortController;const t=this.#m.combinedSignal(this.#mo);this.#go.div.addEventListener("pointerdown",this.#bo.bind(this),{signal:t});this.#go.div.classList.add("highlighting")}}disableTextSelection(){this.div.tabIndex=0;if(this.#go?.div&&this.#mo){this.#mo.abort();this.#mo=null;this.#go.div.classList.remove("highlighting")}}#bo(t){this.#m.unselectAll();const{target:e}=t;if(e===this.#go.div||("img"===e.getAttribute("role")||e.classList.contains("endOfContent"))&&this.#go.div.contains(e)){const{isMac:e}=util_FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)return;this.#m.showAllEditors("highlight",!0,!0);this.#go.div.classList.add("free");this.toggleDrawing();HighlightEditor.startHighlighting(this,"ltr"===this.#m.direction,{target:this.#go.div,x:t.x,y:t.y});this.#go.div.addEventListener("pointerup",(()=>{this.#go.div.classList.remove("free");this.toggleDrawing(!0)}),{once:!0,signal:this.#m._signal});t.preventDefault()}}enableClick(){if(this.#ro)return;this.#ro=new AbortController;const t=this.#m.combinedSignal(this.#ro);this.div.addEventListener("pointerdown",this.pointerdown.bind(this),{signal:t});const e=this.pointerup.bind(this);this.div.addEventListener("pointerup",e,{signal:t});this.div.addEventListener("pointercancel",e,{signal:t})}disableClick(){this.#ro?.abort();this.#ro=null}attach(t){this.#lo.set(t.id,t);const{annotationElementId:e}=t;e&&this.#m.isDeletedAnnotationElement(e)&&this.#m.removeDeletedAnnotationElement(t)}detach(t){this.#lo.delete(t.id);this.#Cn?.removePointerInTextLayer(t.contentDiv);!this.#do&&t.annotationElementId&&this.#m.addDeletedAnnotationElement(t)}remove(t){this.detach(t);this.#m.removeEditor(t);t.div.remove();t.isAttachedToDOM=!1}changeParent(t){if(t.parent!==this){if(t.parent&&t.annotationElementId){this.#m.addDeletedAnnotationElement(t.annotationElementId);AnnotationEditor.deleteAnnotationElement(t);t.annotationElementId=null}this.attach(t);t.parent?.detach(t);t.setParent(this);if(t.div&&t.isAttachedToDOM){t.div.remove();this.div.append(t.div)}}}add(t){if(t.parent!==this||!t.isAttachedToDOM){this.changeParent(t);this.#m.addEditor(t);this.attach(t);if(!t.isAttachedToDOM){const e=t.render();this.div.append(e);t.isAttachedToDOM=!0}t.fixAndSetPosition();t.onceAdded(!this.#co);this.#m.addToAnnotationStorage(t);t._reportTelemetry(t.telemetryInitialData)}}moveEditorInDOM(t){if(!t.isAttachedToDOM)return;const{activeElement:e}=document;if(t.div.contains(e)&&!this.#oo){t._focusEventsAllowed=!1;this.#oo=setTimeout((()=>{this.#oo=null;if(t.div.contains(document.activeElement))t._focusEventsAllowed=!0;else{t.div.addEventListener("focusin",(()=>{t._focusEventsAllowed=!0}),{once:!0,signal:this.#m._signal});e.focus()}}),0)}t._structTreeParentId=this.#Cn?.moveElementInDOM(this.div,t.div,t.contentDiv,!0)}addOrRebuild(t){if(t.needsToBeRebuilt()){t.parent||=this;t.rebuild();t.show()}else this.add(t)}addUndoableEditor(t){this.addCommands({cmd:()=>t._uiManager.rebuild(t),undo:()=>{t.remove()},mustExec:!1})}getNextId(){return this.#m.getId()}get#Ao(){return AnnotationEditorLayer.#U.get(this.#m.getMode())}combinedSignal(t){return this.#m.combinedSignal(t)}#wo(t){const e=this.#Ao;return e?new e.prototype.constructor(t):null}canCreateNewEmptyEditor(){return this.#Ao?.canCreateNewEmptyEditor()}pasteEditor(t,e){this.#m.updateToolbar(t);this.#m.updateMode(t);const{offsetX:i,offsetY:s}=this.#vo(),n=this.getNextId(),a=this.#wo({parent:this,id:n,x:i,y:s,uiManager:this.#m,isCentered:!0,...e});a&&this.add(a)}async deserialize(t){return await(AnnotationEditorLayer.#U.get(t.annotationType??t.annotationEditorType)?.deserialize(t,this,this.#m))||null}createAndAddNewEditor(t,e,i={}){const s=this.getNextId(),n=this.#wo({parent:this,id:s,x:t.offsetX,y:t.offsetY,uiManager:this.#m,isCentered:e,...i});n&&this.add(n);return n}#vo(){const{x:t,y:e,width:i,height:s}=this.div.getBoundingClientRect(),n=Math.max(0,t),a=Math.max(0,e),r=(n+Math.min(window.innerWidth,t+i))/2-t,o=(a+Math.min(window.innerHeight,e+s))/2-e,[l,h]=this.viewport.rotation%180==0?[r,o]:[o,r];return{offsetX:l,offsetY:h}}addNewEditor(){this.createAndAddNewEditor(this.#vo(),!0)}setSelected(t){this.#m.setSelected(t)}toggleSelected(t){this.#m.toggleSelected(t)}unselect(t){this.#m.unselect(t)}pointerup(t){const{isMac:e}=util_FeatureTest.platform;if(!(0!==t.button||t.ctrlKey&&e)&&t.target===this.div&&this.#ho){this.#ho=!1;this.#Ao?.isDrawer&&this.#Ao.supportMultipleDrawings||(this.#no?this.#m.getMode()!==g.STAMP?this.createAndAddNewEditor(t,!1):this.#m.unselectAll():this.#no=!0)}}pointerdown(t){this.#m.getMode()===g.HIGHLIGHT&&this.enableTextSelection();if(this.#ho){this.#ho=!1;return}const{isMac:e}=util_FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)return;if(t.target!==this.div)return;this.#ho=!0;if(this.#Ao?.isDrawer){this.startDrawingSession(t);return}const i=this.#m.getActive();this.#no=!i||i.isEmpty()}startDrawingSession(t){this.div.focus();if(this.#uo){this.#Ao.startDrawing(this,this.#m,!1,t);return}this.#m.setCurrentDrawingSession(this);this.#uo=new AbortController;const e=this.#m.combinedSignal(this.#uo);this.div.addEventListener("blur",(({relatedTarget:t})=>{if(t&&!this.div.contains(t)){this.#po=null;this.commitOrRemove()}}),{signal:e});this.#Ao.startDrawing(this,this.#m,!1,t)}pause(t){if(t){const{activeElement:t}=document;this.div.contains(t)&&(this.#po=t)}else this.#po&&setTimeout((()=>{this.#po?.focus();this.#po=null}),0)}endDrawingSession(t=!1){if(!this.#uo)return null;this.#m.setCurrentDrawingSession(null);this.#uo.abort();this.#uo=null;this.#po=null;return this.#Ao.endDrawing(t)}findNewParent(t,e,i){const s=this.#m.findParent(e,i);if(null===s||s===this)return!1;s.changeParent(t);return!0}commitOrRemove(){if(this.#uo){this.endDrawingSession();return!0}return!1}onScaleChanging(){this.#uo&&this.#Ao.onScaleChangingWhenDrawing(this)}destroy(){this.commitOrRemove();if(this.#m.getActive()?.parent===this){this.#m.commitOrRemove();this.#m.setActiveEditor(null)}if(this.#oo){clearTimeout(this.#oo);this.#oo=null}for(const t of this.#lo.values()){this.#Cn?.removePointerInTextLayer(t.contentDiv);t.setParent(null);t.isAttachedToDOM=!1;t.div.remove()}this.div=null;this.#lo.clear();this.#m.removeLayer(this)}#fo(){for(const t of this.#lo.values())t.isEmpty()&&t.remove()}render({viewport:t}){this.viewport=t;setLayerDimensions(this.div,t);for(const t of this.#m.getEditors(this.pageIndex)){this.add(t);t.rebuild()}this.updateMode()}update({viewport:t}){this.#m.commitOrRemove();this.#fo();const e=this.viewport.rotation,i=t.rotation;this.viewport=t;setLayerDimensions(this.div,{rotation:i});if(e!==i)for(const t of this.#lo.values())t.rotate(i)}get pageDimensions(){const{pageWidth:t,pageHeight:e}=this.viewport.rawDims;return[t,e]}get scale(){return this.#m.viewParameters.realScale}}class DrawLayer{#nn=null;#w=0;#yo=new Map;#xo=new Map;constructor({pageIndex:t}){this.pageIndex=t}setParent(t){if(this.#nn){if(this.#nn!==t){if(this.#yo.size>0)for(const e of this.#yo.values()){e.remove();t.append(e)}this.#nn=t}}else this.#nn=t}static get _svgFactory(){return shadow(this,"_svgFactory",new DOMSVGFactory)}static#_o(t,[e,i,s,n]){const{style:a}=t;a.top=100*i+"%";a.left=100*e+"%";a.width=100*s+"%";a.height=100*n+"%"}#Eo(){const t=DrawLayer._svgFactory.create(1,1,!0);this.#nn.append(t);t.setAttribute("aria-hidden",!0);return t}#So(t,e){const i=DrawLayer._svgFactory.createElement("clipPath");t.append(i);const s=`clip_${e}`;i.setAttribute("id",s);i.setAttribute("clipPathUnits","objectBoundingBox");const n=DrawLayer._svgFactory.createElement("use");i.append(n);n.setAttribute("href",`#${e}`);n.classList.add("clip");return s}#Co(t,e){for(const[i,s]of Object.entries(e))null===s?t.removeAttribute(i):t.setAttribute(i,s)}draw(t,e=!1,i=!1){const s=this.#w++,n=this.#Eo(),a=DrawLayer._svgFactory.createElement("defs");n.append(a);const r=DrawLayer._svgFactory.createElement("path");a.append(r);const o=`path_p${this.pageIndex}_${s}`;r.setAttribute("id",o);r.setAttribute("vector-effect","non-scaling-stroke");e&&this.#xo.set(s,r);const l=i?this.#So(a,o):null,h=DrawLayer._svgFactory.createElement("use");n.append(h);h.setAttribute("href",`#${o}`);this.updateProperties(n,t);this.#yo.set(s,n);return{id:s,clipPathId:`url(#${l})`}}drawOutline(t,e){const i=this.#w++,s=this.#Eo(),n=DrawLayer._svgFactory.createElement("defs");s.append(n);const a=DrawLayer._svgFactory.createElement("path");n.append(a);const r=`path_p${this.pageIndex}_${i}`;a.setAttribute("id",r);a.setAttribute("vector-effect","non-scaling-stroke");let o;if(e){const t=DrawLayer._svgFactory.createElement("mask");n.append(t);o=`mask_p${this.pageIndex}_${i}`;t.setAttribute("id",o);t.setAttribute("maskUnits","objectBoundingBox");const e=DrawLayer._svgFactory.createElement("rect");t.append(e);e.setAttribute("width","1");e.setAttribute("height","1");e.setAttribute("fill","white");const s=DrawLayer._svgFactory.createElement("use");t.append(s);s.setAttribute("href",`#${r}`);s.setAttribute("stroke","none");s.setAttribute("fill","black");s.setAttribute("fill-rule","nonzero");s.classList.add("mask")}const l=DrawLayer._svgFactory.createElement("use");s.append(l);l.setAttribute("href",`#${r}`);o&&l.setAttribute("mask",`url(#${o})`);const h=l.cloneNode();s.append(h);l.classList.add("mainOutline");h.classList.add("secondaryOutline");this.updateProperties(s,t);this.#yo.set(i,s);return i}finalizeDraw(t,e){this.#xo.delete(t);this.updateProperties(t,e)}updateProperties(t,e){if(!e)return;const{root:i,bbox:s,rootClass:n,path:a}=e,r="number"==typeof t?this.#yo.get(t):t;if(r){i&&this.#Co(r,i);s&&DrawLayer.#_o(r,s);if(n){const{classList:t}=r;for(const[e,i]of Object.entries(n))t.toggle(e,i)}if(a){const t=r.firstChild.firstChild;this.#Co(t,a)}}}updateParent(t,e){if(e===this)return;const i=this.#yo.get(t);if(i){e.#nn.append(i);this.#yo.delete(t);e.#yo.set(t,i)}}remove(t){this.#xo.delete(t);if(null!==this.#nn){this.#yo.get(t).remove();this.#yo.delete(t)}}destroy(){this.#nn=null;for(const t of this.#yo.values())t.remove();this.#yo.clear();this.#xo.clear()}}globalThis.pdfjsTestingUtils={HighlightOutliner};var Ut=__webpack_exports__.AbortException,Gt=__webpack_exports__.AnnotationEditorLayer,$t=__webpack_exports__.AnnotationEditorParamsType,Vt=__webpack_exports__.AnnotationEditorType,jt=__webpack_exports__.AnnotationEditorUIManager,Wt=__webpack_exports__.AnnotationLayer,qt=__webpack_exports__.AnnotationMode,Xt=__webpack_exports__.ColorPicker,Kt=__webpack_exports__.DOMSVGFactory,Yt=__webpack_exports__.DrawLayer,Qt=__webpack_exports__.FeatureTest,Jt=__webpack_exports__.GlobalWorkerOptions,Zt=__webpack_exports__.ImageKind,te=__webpack_exports__.InvalidPDFException,ee=__webpack_exports__.MissingPDFException,ie=__webpack_exports__.OPS,se=__webpack_exports__.OutputScale,ne=__webpack_exports__.PDFDataRangeTransport,ae=__webpack_exports__.PDFDateString,re=__webpack_exports__.PDFWorker,oe=__webpack_exports__.PasswordResponses,le=__webpack_exports__.PermissionFlag,he=__webpack_exports__.PixelsPerInch,de=__webpack_exports__.RenderingCancelledException,ce=__webpack_exports__.TextLayer,ue=__webpack_exports__.TouchManager,pe=__webpack_exports__.UnexpectedResponseException,ge=__webpack_exports__.Util,me=__webpack_exports__.VerbosityLevel,fe=__webpack_exports__.XfaLayer,be=__webpack_exports__.build,Ae=__webpack_exports__.createValidAbsoluteUrl,we=__webpack_exports__.fetchData,ve=__webpack_exports__.getDocument,ye=__webpack_exports__.getFilenameFromUrl,xe=__webpack_exports__.getPdfFilenameFromUrl,_e=__webpack_exports__.getXfaPageViewport,Ee=__webpack_exports__.isDataScheme,Se=__webpack_exports__.isPdfFile,Ce=__webpack_exports__.noContextMenu,Te=__webpack_exports__.normalizeUnicode,Me=__webpack_exports__.setLayerDimensions,Pe=__webpack_exports__.shadow,De=__webpack_exports__.stopEvent,ke=__webpack_exports__.version;export{Ut as AbortException,Gt as AnnotationEditorLayer,$t as AnnotationEditorParamsType,Vt as AnnotationEditorType,jt as AnnotationEditorUIManager,Wt as AnnotationLayer,qt as AnnotationMode,Xt as ColorPicker,Kt as DOMSVGFactory,Yt as DrawLayer,Qt as FeatureTest,Jt as GlobalWorkerOptions,Zt as ImageKind,te as InvalidPDFException,ee as MissingPDFException,ie as OPS,se as OutputScale,ne as PDFDataRangeTransport,ae as PDFDateString,re as PDFWorker,oe as PasswordResponses,le as PermissionFlag,he as PixelsPerInch,de as RenderingCancelledException,ce as TextLayer,ue as TouchManager,pe as UnexpectedResponseException,ge as Util,me as VerbosityLevel,fe as XfaLayer,be as build,Ae as createValidAbsoluteUrl,we as fetchData,ve as getDocument,ye as getFilenameFromUrl,xe as getPdfFilenameFromUrl,_e as getXfaPageViewport,Ee as isDataScheme,Se as isPdfFile,Ce as noContextMenu,Te as normalizeUnicode,Me as setLayerDimensions,Pe as shadow,De as stopEvent,ke as version}; \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs new file mode 100644 index 00000000000..ee4038504a6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs @@ -0,0 +1,21 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2024 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */var e={d:(t,i)=>{for(var a in i)e.o(i,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:i[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},__webpack_exports__ = globalThis.pdfjsWorker = {};e.d(__webpack_exports__,{WorkerMessageHandler:()=>WorkerMessageHandler});const t=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],a=[.001,0,0,.001,0,0],s=1.35,r=.35,n=.25925925925925924,g=1,o=2,c=4,C=8,h=16,l=64,Q=128,E=256,u="pdfjs_internal_editor_",d=3,f=9,p=13,m=15,y={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},w=0,D=4,b=1,F=2,S=3,k=1,R=2,N=3,G=4,M=5,U=6,x=7,L=8,H=9,J=10,Y=11,v=12,K=13,T=14,q=15,O=16,W=17,j=20,X="Group",Z="R",V=1,z=2,_=4,$=16,AA=32,eA=128,tA=512,iA=1,aA=2,sA=4096,rA=8192,nA=32768,gA=65536,oA=131072,IA=1048576,cA=2097152,CA=8388608,hA=16777216,lA=1,BA=2,QA=3,EA=4,uA=5,dA={E:"Mouse Enter",X:"Mouse Exit",D:"Mouse Down",U:"Mouse Up",Fo:"Focus",Bl:"Blur",PO:"PageOpen",PC:"PageClose",PV:"PageVisible",PI:"PageInvisible",K:"Keystroke",F:"Format",V:"Validate",C:"Calculate"},fA={WC:"WillClose",WS:"WillSave",DS:"DidSave",WP:"WillPrint",DP:"DidPrint"},pA={O:"PageOpen",C:"PageClose"},mA=1,yA=5,wA=1,DA=2,bA=3,FA=4,SA=5,kA=6,RA=7,NA=8,GA=9,MA=10,UA=11,xA=12,LA=13,HA=14,JA=15,YA=16,vA=17,KA=18,TA=19,qA=20,OA=21,PA=22,WA=23,jA=24,XA=25,ZA=26,VA=27,zA=28,_A=29,$A=30,Ae=31,ee=32,te=33,ie=34,ae=35,se=36,re=37,ne=38,ge=39,oe=40,Ie=41,ce=42,Ce=43,he=44,le=45,Be=46,Qe=47,Ee=48,ue=49,de=50,fe=51,pe=52,me=53,ye=54,we=55,De=56,be=57,Fe=58,Se=59,ke=60,Re=61,Ne=62,Ge=63,Me=64,Ue=65,xe=66,Le=67,He=68,Je=69,Ye=70,ve=71,Ke=72,Te=73,qe=74,Oe=75,Pe=76,We=77,je=80,Xe=81,Ze=83,Ve=84,ze=85,_e=86,$e=87,At=88,et=89,tt=90,it=91,at=92,st=93,rt=1,nt=2;let gt=mA;function getVerbosityLevel(){return gt}function info(e){gt>=yA&&console.log(`Info: ${e}`)}function warn(e){gt>=mA&&console.log(`Warning: ${e}`)}function unreachable(e){throw new Error(e)}function assert(e,t){e||unreachable(t)}function createValidAbsoluteUrl(e,t=null,i=null){if(!e)return null;try{if(i&&"string"==typeof e){if(i.addDefaultProtocol&&e.startsWith("www.")){const t=e.match(/\./g);t?.length>=2&&(e=`http://${e}`)}if(i.tryConvertEncoding)try{e=stringToUTF8String(e)}catch{}}const a=t?new URL(e,t):new URL(e);if(function _isValidProtocol(e){switch(e?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(a))return a}catch{}return null}function shadow(e,t,i,a=!1){Object.defineProperty(e,t,{value:i,enumerable:!a,configurable:!0,writable:!1});return i}const ot=function BaseExceptionClosure(){function BaseException(e,t){this.message=e;this.name=t}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends ot{constructor(e,t){super(e,"PasswordException");this.code=t}}class UnknownErrorException extends ot{constructor(e,t){super(e,"UnknownErrorException");this.details=t}}class InvalidPDFException extends ot{constructor(e){super(e,"InvalidPDFException")}}class MissingPDFException extends ot{constructor(e){super(e,"MissingPDFException")}}class UnexpectedResponseException extends ot{constructor(e,t){super(e,"UnexpectedResponseException");this.status=t}}class FormatError extends ot{constructor(e){super(e,"FormatError")}}class AbortException extends ot{constructor(e){super(e,"AbortException")}}function bytesToString(e){"object"==typeof e&&void 0!==e?.length||unreachable("Invalid argument for bytesToString");const t=e.length,i=8192;if(t>24&255,e>>16&255,e>>8&255,255&e)}function objectSize(e){return Object.keys(e).length}class FeatureTest{static get isLittleEndian(){return shadow(this,"isLittleEndian",function isLittleEndian(){const e=new Uint8Array(4);e[0]=1;return 1===new Uint32Array(e.buffer,0,1)[0]}())}static get isEvalSupported(){return shadow(this,"isEvalSupported",function isEvalSupported(){try{new Function("");return!0}catch{return!1}}())}static get isOffscreenCanvasSupported(){return shadow(this,"isOffscreenCanvasSupported","undefined"!=typeof OffscreenCanvas)}static get isImageDecoderSupported(){return shadow(this,"isImageDecoderSupported","undefined"!=typeof ImageDecoder)}static get platform(){return"undefined"!=typeof navigator&&"string"==typeof navigator?.platform?shadow(this,"platform",{isMac:navigator.platform.includes("Mac"),isWindows:navigator.platform.includes("Win"),isFirefox:"string"==typeof navigator?.userAgent&&navigator.userAgent.includes("Firefox")}):shadow(this,"platform",{isMac:!1,isWindows:!1,isFirefox:!1})}static get isCSSRoundSupported(){return shadow(this,"isCSSRoundSupported",globalThis.CSS?.supports?.("width: round(1.5px, 1px)"))}}const It=Array.from(Array(256).keys(),(e=>e.toString(16).padStart(2,"0")));class Util{static makeHexColor(e,t,i){return`#${It[e]}${It[t]}${It[i]}`}static scaleMinMax(e,t){let i;if(e[0]){if(e[0]<0){i=t[0];t[0]=t[2];t[2]=i}t[0]*=e[0];t[2]*=e[0];if(e[3]<0){i=t[1];t[1]=t[3];t[3]=i}t[1]*=e[3];t[3]*=e[3]}else{i=t[0];t[0]=t[1];t[1]=i;i=t[2];t[2]=t[3];t[3]=i;if(e[1]<0){i=t[1];t[1]=t[3];t[3]=i}t[1]*=e[1];t[3]*=e[1];if(e[2]<0){i=t[0];t[0]=t[2];t[2]=i}t[0]*=e[2];t[2]*=e[2]}t[0]+=e[4];t[1]+=e[5];t[2]+=e[4];t[3]+=e[5]}static transform(e,t){return[e[0]*t[0]+e[2]*t[1],e[1]*t[0]+e[3]*t[1],e[0]*t[2]+e[2]*t[3],e[1]*t[2]+e[3]*t[3],e[0]*t[4]+e[2]*t[5]+e[4],e[1]*t[4]+e[3]*t[5]+e[5]]}static applyTransform(e,t){return[e[0]*t[0]+e[1]*t[2]+t[4],e[0]*t[1]+e[1]*t[3]+t[5]]}static applyInverseTransform(e,t){const i=t[0]*t[3]-t[1]*t[2];return[(e[0]*t[3]-e[1]*t[2]+t[2]*t[5]-t[4]*t[3])/i,(-e[0]*t[1]+e[1]*t[0]+t[4]*t[1]-t[5]*t[0])/i]}static getAxialAlignedBoundingBox(e,t){const i=this.applyTransform(e,t),a=this.applyTransform(e.slice(2,4),t),s=this.applyTransform([e[0],e[3]],t),r=this.applyTransform([e[2],e[1]],t);return[Math.min(i[0],a[0],s[0],r[0]),Math.min(i[1],a[1],s[1],r[1]),Math.max(i[0],a[0],s[0],r[0]),Math.max(i[1],a[1],s[1],r[1])]}static inverseTransform(e){const t=e[0]*e[3]-e[1]*e[2];return[e[3]/t,-e[1]/t,-e[2]/t,e[0]/t,(e[2]*e[5]-e[4]*e[3])/t,(e[4]*e[1]-e[5]*e[0])/t]}static singularValueDecompose2dScale(e){const t=[e[0],e[2],e[1],e[3]],i=e[0]*t[0]+e[1]*t[2],a=e[0]*t[1]+e[1]*t[3],s=e[2]*t[0]+e[3]*t[2],r=e[2]*t[1]+e[3]*t[3],n=(i+r)/2,g=Math.sqrt((i+r)**2-4*(i*r-s*a))/2,o=n+g||1,c=n-g||1;return[Math.sqrt(o),Math.sqrt(c)]}static normalizeRect(e){const t=e.slice(0);if(e[0]>e[2]){t[0]=e[2];t[2]=e[0]}if(e[1]>e[3]){t[1]=e[3];t[3]=e[1]}return t}static intersect(e,t){const i=Math.max(Math.min(e[0],e[2]),Math.min(t[0],t[2])),a=Math.min(Math.max(e[0],e[2]),Math.max(t[0],t[2]));if(i>a)return null;const s=Math.max(Math.min(e[1],e[3]),Math.min(t[1],t[3])),r=Math.min(Math.max(e[1],e[3]),Math.max(t[1],t[3]));return s>r?null:[i,s,a,r]}static#A(e,t,i,a,s,r,n,g,o,c){if(o<=0||o>=1)return;const C=1-o,h=o*o,l=h*o,Q=C*(C*(C*e+3*o*t)+3*h*i)+l*a,E=C*(C*(C*s+3*o*r)+3*h*n)+l*g;c[0]=Math.min(c[0],Q);c[1]=Math.min(c[1],E);c[2]=Math.max(c[2],Q);c[3]=Math.max(c[3],E)}static#e(e,t,i,a,s,r,n,g,o,c,C,h){if(Math.abs(o)<1e-12){Math.abs(c)>=1e-12&&this.#A(e,t,i,a,s,r,n,g,-C/c,h);return}const l=c**2-4*C*o;if(l<0)return;const Q=Math.sqrt(l),E=2*o;this.#A(e,t,i,a,s,r,n,g,(-c+Q)/E,h);this.#A(e,t,i,a,s,r,n,g,(-c-Q)/E,h)}static bezierBoundingBox(e,t,i,a,s,r,n,g,o){if(o){o[0]=Math.min(o[0],e,n);o[1]=Math.min(o[1],t,g);o[2]=Math.max(o[2],e,n);o[3]=Math.max(o[3],t,g)}else o=[Math.min(e,n),Math.min(t,g),Math.max(e,n),Math.max(t,g)];this.#e(e,i,s,n,t,a,r,g,3*(3*(i-s)-e+n),6*(e-2*i+s),3*(i-e),o);this.#e(e,i,s,n,t,a,r,g,3*(3*(a-r)-t+g),6*(t-2*a+r),3*(a-t),o);return o}}const ct=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,728,711,710,729,733,731,730,732,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8226,8224,8225,8230,8212,8211,402,8260,8249,8250,8722,8240,8222,8220,8221,8216,8217,8218,8482,64257,64258,321,338,352,376,381,305,322,339,353,382,0,8364];function stringToPDFString(e){if(e[0]>="ï"){let t;if("þ"===e[0]&&"ÿ"===e[1]){t="utf-16be";e.length%2==1&&(e=e.slice(0,-1))}else if("ÿ"===e[0]&&"þ"===e[1]){t="utf-16le";e.length%2==1&&(e=e.slice(0,-1))}else"ï"===e[0]&&"»"===e[1]&&"¿"===e[2]&&(t="utf-8");if(t)try{const i=new TextDecoder(t,{fatal:!0}),a=stringToBytes(e),s=i.decode(a);return s.includes("")?s.replaceAll(/\x1b[^\x1b]*(?:\x1b|$)/g,""):s}catch(e){warn(`stringToPDFString: "${e}".`)}}const t=[];for(let i=0,a=e.length;iIt[e])).join("")}"function"!=typeof Promise.try&&(Promise.try=function(e,...t){return new Promise((i=>{i(e(...t))}))});const lt=Symbol("CIRCULAR_REF"),Bt=Symbol("EOF");let Qt=Object.create(null),Et=Object.create(null),ut=Object.create(null);class Name{constructor(e){this.name=e}static get(e){return Et[e]||=new Name(e)}}class Cmd{constructor(e){this.cmd=e}static get(e){return Qt[e]||=new Cmd(e)}}const dt=function nonSerializableClosure(){return dt};class Dict{constructor(e=null){this._map=new Map;this.xref=e;this.objId=null;this.suppressEncryption=!1;this.__nonSerializable__=dt}assignXref(e){this.xref=e}get size(){return this._map.size}get(e,t,i){let a=this._map.get(e);if(void 0===a&&void 0!==t){a=this._map.get(t);void 0===a&&void 0!==i&&(a=this._map.get(i))}return a instanceof Ref&&this.xref?this.xref.fetch(a,this.suppressEncryption):a}async getAsync(e,t,i){let a=this._map.get(e);if(void 0===a&&void 0!==t){a=this._map.get(t);void 0===a&&void 0!==i&&(a=this._map.get(i))}return a instanceof Ref&&this.xref?this.xref.fetchAsync(a,this.suppressEncryption):a}getArray(e,t,i){let a=this._map.get(e);if(void 0===a&&void 0!==t){a=this._map.get(t);void 0===a&&void 0!==i&&(a=this._map.get(i))}a instanceof Ref&&this.xref&&(a=this.xref.fetch(a,this.suppressEncryption));if(Array.isArray(a)){a=a.slice();for(let e=0,t=a.length;e{unreachable("Should not call `set` on the empty dictionary.")};return shadow(this,"empty",e)}static merge({xref:e,dictArray:t,mergeSubDicts:i=!1}){const a=new Dict(e),s=new Map;for(const e of t)if(e instanceof Dict)for(const[t,a]of e._map){let e=s.get(t);if(void 0===e){e=[];s.set(t,e)}else if(!(i&&a instanceof Dict))continue;e.push(a)}for(const[t,i]of s){if(1===i.length||!(i[0]instanceof Dict)){a._map.set(t,i[0]);continue}const s=new Dict(e);for(const e of i)for(const[t,i]of e._map)s._map.has(t)||s._map.set(t,i);s.size>0&&a._map.set(t,s)}s.clear();return a.size>0?a:Dict.empty}clone(){const e=new Dict(this.xref);for(const t of this.getKeys())e.set(t,this.getRaw(t));return e}delete(e){delete this._map[e]}}class Ref{constructor(e,t){this.num=e;this.gen=t}toString(){return 0===this.gen?`${this.num}R`:`${this.num}R${this.gen}`}static fromString(e){const t=ut[e];if(t)return t;const i=/^(\d+)R(\d*)$/.exec(e);return i&&"0"!==i[1]?ut[e]=new Ref(parseInt(i[1]),i[2]?parseInt(i[2]):0):null}static get(e,t){const i=0===t?`${e}R`:`${e}R${t}`;return ut[i]||=new Ref(e,t)}}class RefSet{constructor(e=null){this._set=new Set(e?._set)}has(e){return this._set.has(e.toString())}put(e){this._set.add(e.toString())}remove(e){this._set.delete(e.toString())}[Symbol.iterator](){return this._set.values()}clear(){this._set.clear()}}class RefSetCache{constructor(){this._map=new Map}get size(){return this._map.size}get(e){return this._map.get(e.toString())}has(e){return this._map.has(e.toString())}put(e,t){this._map.set(e.toString(),t)}putAlias(e,t){this._map.set(e.toString(),this.get(t))}[Symbol.iterator](){return this._map.values()}clear(){this._map.clear()}*values(){yield*this._map.values()}*items(){for(const[e,t]of this._map)yield[Ref.fromString(e),t]}}function isName(e,t){return e instanceof Name&&(void 0===t||e.name===t)}function isCmd(e,t){return e instanceof Cmd&&(void 0===t||e.cmd===t)}function isDict(e,t){return e instanceof Dict&&(void 0===t||isName(e.get("Type"),t))}function isRefsEqual(e,t){return e.num===t.num&&e.gen===t.gen}class BaseStream{get length(){unreachable("Abstract getter `length` accessed")}get isEmpty(){unreachable("Abstract getter `isEmpty` accessed")}get isDataLoaded(){return shadow(this,"isDataLoaded",!0)}getByte(){unreachable("Abstract method `getByte` called")}getBytes(e){unreachable("Abstract method `getBytes` called")}async getImageData(e,t){return this.getBytes(e,t)}async asyncGetBytes(){unreachable("Abstract method `asyncGetBytes` called")}get isAsync(){return!1}get canAsyncDecodeImageFromBuffer(){return!1}async getTransferableImage(){return null}peekByte(){const e=this.getByte();-1!==e&&this.pos--;return e}peekBytes(e){const t=this.getBytes(e);this.pos-=t.length;return t}getUint16(){const e=this.getByte(),t=this.getByte();return-1===e||-1===t?-1:(e<<8)+t}getInt32(){return(this.getByte()<<24)+(this.getByte()<<16)+(this.getByte()<<8)+this.getByte()}getByteRange(e,t){unreachable("Abstract method `getByteRange` called")}getString(e){return bytesToString(this.getBytes(e))}skip(e){this.pos+=e||1}reset(){unreachable("Abstract method `reset` called")}moveStart(){unreachable("Abstract method `moveStart` called")}makeSubStream(e,t,i=null){unreachable("Abstract method `makeSubStream` called")}getBaseStreams(){return null}}const ft=/^[1-9]\.\d$/,pt=2**31-1;function getLookupTableFactory(e){let t;return function(){if(e){t=Object.create(null);e(t);e=null}return t}}class MissingDataException extends ot{constructor(e,t){super(`Missing data [${e}, ${t})`,"MissingDataException");this.begin=e;this.end=t}}class ParserEOFException extends ot{constructor(e){super(e,"ParserEOFException")}}class XRefEntryException extends ot{constructor(e){super(e,"XRefEntryException")}}class XRefParseException extends ot{constructor(e){super(e,"XRefParseException")}}function arrayBuffersToBytes(e){const t=e.length;if(0===t)return new Uint8Array(0);if(1===t)return new Uint8Array(e[0]);let i=0;for(let a=0;a0,"The number should be a positive integer.");const i="M".repeat(e/1e3|0)+mt[e%1e3/100|0]+mt[10+(e%100/10|0)]+mt[20+e%10];return t?i.toLowerCase():i}function log2(e){return e>0?Math.ceil(Math.log2(e)):0}function readInt8(e,t){return e[t]<<24>>24}function readUint16(e,t){return e[t]<<8|e[t+1]}function readUint32(e,t){return(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0}function isWhiteSpace(e){return 32===e||9===e||13===e||10===e}function isNumberArray(e,t){return Array.isArray(e)?(null===t||e.length===t)&&e.every((e=>"number"==typeof e)):ArrayBuffer.isView(e)&&(0===e.length||"number"==typeof e[0])&&(null===t||e.length===t)}function lookupMatrix(e,t){return isNumberArray(e,6)?e:t}function lookupRect(e,t){return isNumberArray(e,4)?e:t}function lookupNormalRect(e,t){return isNumberArray(e,4)?Util.normalizeRect(e):t}function parseXFAPath(e){const t=/(.+)\[(\d+)\]$/;return e.split(".").map((e=>{const i=e.match(t);return i?{name:i[1],pos:parseInt(i[2],10)}:{name:e,pos:0}}))}function escapePDFName(e){const t=[];let i=0;for(let a=0,s=e.length;a126||35===s||40===s||41===s||60===s||62===s||91===s||93===s||123===s||125===s||47===s||37===s){i"\n"===e?"\\n":"\r"===e?"\\r":`\\${e}`))}function _collectJS(e,t,i,a){if(!e)return;let s=null;if(e instanceof Ref){if(a.has(e))return;s=e;a.put(s);e=t.fetch(e)}if(Array.isArray(e))for(const s of e)_collectJS(s,t,i,a);else if(e instanceof Dict){if(isName(e.get("S"),"JavaScript")){const t=e.get("JS");let a;t instanceof BaseStream?a=t.getString():"string"==typeof t&&(a=t);a&&=stringToPDFString(a).replaceAll("\0","");a&&i.push(a)}_collectJS(e.getRaw("Next"),t,i,a)}s&&a.remove(s)}function collectActions(e,t,i){const a=Object.create(null),s=getInheritableProperty({dict:t,key:"AA",stopWhenFound:!1});if(s)for(let t=s.length-1;t>=0;t--){const r=s[t];if(r instanceof Dict)for(const t of r.getKeys()){const s=i[t];if(!s)continue;const n=[];_collectJS(r.getRaw(t),e,n,new RefSet);n.length>0&&(a[s]=n)}}if(t.has("A")){const i=[];_collectJS(t.get("A"),e,i,new RefSet);i.length>0&&(a.Action=i)}return objectSize(a)>0?a:null}const yt={60:"<",62:">",38:"&",34:""",39:"'"};function*codePointIter(e){for(let t=0,i=e.length;t55295&&(i<57344||i>65533)&&t++;yield i}}function encodeToXmlString(e){const t=[];let i=0;for(let a=0,s=e.length;a55295&&(s<57344||s>65533)&&a++;i=a+1}}if(0===t.length)return e;i: ${e}.`);return!1}return!0}function validateCSSFont(e){const t=new Set(["100","200","300","400","500","600","700","800","900","1000","normal","bold","bolder","lighter"]),{fontFamily:i,fontWeight:a,italicAngle:s}=e;if(!validateFontName(i,!0))return!1;const r=a?a.toString():"";e.fontWeight=t.has(r)?r:"400";const n=parseFloat(s);e.italicAngle=isNaN(n)||n<-90||n>90?"14":s.toString();return!0}function recoverJsURL(e){const t=new RegExp("^\\s*("+["app.launchURL","window.open","xfa.host.gotoURL"].join("|").replaceAll(".","\\.")+")\\((?:'|\")([^'\"]*)(?:'|\")(?:,\\s*(\\w+)\\)|\\))","i").exec(e);return t?.[2]?{url:t[2],newWindow:"app.launchURL"===t[1]&&"true"===t[3]}:null}function numberToString(e){if(Number.isInteger(e))return e.toString();const t=Math.round(100*e);return t%100==0?(t/100).toString():t%10==0?e.toFixed(1):e.toFixed(2)}function getNewAnnotationsMap(e){if(!e)return null;const t=new Map;for(const[i,a]of e){if(!i.startsWith(u))continue;let e=t.get(a.pageIndex);if(!e){e=[];t.set(a.pageIndex,e)}e.push(a)}return t.size>0?t:null}function stringToAsciiOrUTF16BE(e){return function isAscii(e){return/^[\x00-\x7F]*$/.test(e)}(e)?e:stringToUTF16String(e,!0)}function stringToUTF16HexString(e){const t=[];for(let i=0,a=e.length;i>8&255],It[255&a])}return t.join("")}function stringToUTF16String(e,t=!1){const i=[];t&&i.push("þÿ");for(let t=0,a=e.length;t>8&255),String.fromCharCode(255&a))}return i.join("")}function getRotationMatrix(e,t,i){switch(e){case 90:return[0,1,-1,0,t,0];case 180:return[-1,0,0,-1,t,i];case 270:return[0,-1,1,0,0,i];default:throw new Error("Invalid rotation")}}function getSizeInBytes(e){return Math.ceil(Math.ceil(Math.log2(1+e))/8)}class Stream extends BaseStream{constructor(e,t,i,a){super();this.bytes=e instanceof Uint8Array?e:new Uint8Array(e);this.start=t||0;this.pos=this.start;this.end=t+i||this.bytes.length;this.dict=a}get length(){return this.end-this.start}get isEmpty(){return 0===this.length}getByte(){return this.pos>=this.end?-1:this.bytes[this.pos++]}getBytes(e){const t=this.bytes,i=this.pos,a=this.end;if(!e)return t.subarray(i,a);let s=i+e;s>a&&(s=a);this.pos=s;return t.subarray(i,s)}getByteRange(e,t){e<0&&(e=0);t>this.end&&(t=this.end);return this.bytes.subarray(e,t)}reset(){this.pos=this.start}moveStart(){this.start=this.pos}makeSubStream(e,t,i=null){return new Stream(this.bytes.buffer,e,t,i)}}class StringStream extends Stream{constructor(e){super(stringToBytes(e))}}class NullStream extends Stream{constructor(){super(new Uint8Array(0))}}class ChunkedStream extends Stream{constructor(e,t,i){super(new Uint8Array(e),0,e,null);this.chunkSize=t;this._loadedChunks=new Set;this.numChunks=Math.ceil(e/t);this.manager=i;this.progressiveDataLength=0;this.lastSuccessfulEnsureByteChunk=-1}getMissingChunks(){const e=[];for(let t=0,i=this.numChunks;t=this.end?this.numChunks:Math.floor(t/this.chunkSize);for(let e=i;ethis.numChunks)&&t!==this.lastSuccessfulEnsureByteChunk){if(!this._loadedChunks.has(t))throw new MissingDataException(e,e+1);this.lastSuccessfulEnsureByteChunk=t}}ensureRange(e,t){if(e>=t)return;if(t<=this.progressiveDataLength)return;const i=Math.floor(e/this.chunkSize);if(i>this.numChunks)return;const a=Math.min(Math.floor((t-1)/this.chunkSize)+1,this.numChunks);for(let s=i;s=this.end)return-1;e>=this.progressiveDataLength&&this.ensureByte(e);return this.bytes[this.pos++]}getBytes(e){const t=this.bytes,i=this.pos,a=this.end;if(!e){a>this.progressiveDataLength&&this.ensureRange(i,a);return t.subarray(i,a)}let s=i+e;s>a&&(s=a);s>this.progressiveDataLength&&this.ensureRange(i,s);this.pos=s;return t.subarray(i,s)}getByteRange(e,t){e<0&&(e=0);t>this.end&&(t=this.end);t>this.progressiveDataLength&&this.ensureRange(e,t);return this.bytes.subarray(e,t)}makeSubStream(e,t,i=null){t?e+t>this.progressiveDataLength&&this.ensureRange(e,e+t):e>=this.progressiveDataLength&&this.ensureByte(e);function ChunkedStreamSubstream(){}ChunkedStreamSubstream.prototype=Object.create(this);ChunkedStreamSubstream.prototype.getMissingChunks=function(){const e=this.chunkSize,t=Math.floor(this.start/e),i=Math.floor((this.end-1)/e)+1,a=[];for(let e=t;e{const readChunk=({value:r,done:n})=>{try{if(n){const t=arrayBuffersToBytes(a);a=null;e(t);return}s+=r.byteLength;i.isStreamingSupported&&this.onProgress({loaded:s});a.push(r);i.read().then(readChunk,t)}catch(e){t(e)}};i.read().then(readChunk,t)})).then((t=>{this.aborted||this.onReceiveData({chunk:t,begin:e})}))}requestAllChunks(e=!1){if(!e){const e=this.stream.getMissingChunks();this._requestChunks(e)}return this._loadedStreamCapability.promise}_requestChunks(e){const t=this.currRequestId++,i=new Set;this._chunksNeededByRequest.set(t,i);for(const t of e)this.stream.hasChunk(t)||i.add(t);if(0===i.size)return Promise.resolve();const a=Promise.withResolvers();this._promisesByRequest.set(t,a);const s=[];for(const e of i){let i=this._requestsByChunk.get(e);if(!i){i=[];this._requestsByChunk.set(e,i);s.push(e)}i.push(t)}if(s.length>0){const e=this.groupChunks(s);for(const t of e){const e=t.beginChunk*this.chunkSize,i=Math.min(t.endChunk*this.chunkSize,this.length);this.sendRequest(e,i).catch(a.reject)}}return a.promise.catch((e=>{if(!this.aborted)throw e}))}getStream(){return this.stream}requestRange(e,t){t=Math.min(t,this.length);const i=this.getBeginChunk(e),a=this.getEndChunk(t),s=[];for(let e=i;e=0&&a+1!==r){t.push({beginChunk:i,endChunk:a+1});i=r}s+1===e.length&&t.push({beginChunk:i,endChunk:r+1});a=r}return t}onProgress(e){this.msgHandler.send("DocProgress",{loaded:this.stream.numChunksLoaded*this.chunkSize+e.loaded,total:this.length})}onReceiveData(e){const t=e.chunk,i=void 0===e.begin,a=i?this.progressiveDataLength:e.begin,s=a+t.byteLength,r=Math.floor(a/this.chunkSize),n=s0||g.push(i)}}}if(!this.disableAutoFetch&&0===this._requestsByChunk.size){let e;if(1===this.stream.numChunksLoaded){const t=this.stream.numChunks-1;this.stream.hasChunk(t)||(e=t)}else e=this.stream.nextEmptyChunk(n);Number.isInteger(e)&&this._requestChunks([e])}for(const e of g){const t=this._promisesByRequest.get(e);this._promisesByRequest.delete(e);t.resolve()}this.msgHandler.send("DocProgress",{loaded:this.stream.numChunksLoaded*this.chunkSize,total:this.length})}onError(e){this._loadedStreamCapability.reject(e)}getBeginChunk(e){return Math.floor(e/this.chunkSize)}getEndChunk(e){return Math.floor((e-1)/this.chunkSize)+1}abort(e){this.aborted=!0;this.pdfNetworkStream?.cancelAllRequests(e);for(const t of this._promisesByRequest.values())t.reject(e)}}class ColorSpace{constructor(e,t){this.name=e;this.numComps=t}getRgb(e,t){const i=new Uint8ClampedArray(3);this.getRgbItem(e,t,i,0);return i}getRgbItem(e,t,i,a){unreachable("Should not call ColorSpace.getRgbItem")}getRgbBuffer(e,t,i,a,s,r,n){unreachable("Should not call ColorSpace.getRgbBuffer")}getOutputLength(e,t){unreachable("Should not call ColorSpace.getOutputLength")}isPassthrough(e){return!1}isDefaultDecode(e,t){return ColorSpace.isDefaultDecode(e,this.numComps)}fillRgb(e,t,i,a,s,r,n,g,o){const c=t*i;let C=null;const h=1<h&&"DeviceGray"!==this.name&&"DeviceRGB"!==this.name){const t=n<=8?new Uint8Array(h):new Uint16Array(h);for(let e=0;e=.99554525?1:this.#B(0,1,1.055*e**(1/2.4)-.055)}#B(e,t,i){return Math.max(e,Math.min(t,i))}#Q(e){return e<0?-this.#Q(-e):e>8?((e+16)/116)**3:e*CalRGBCS.#I}#E(e,t,i){if(0===e[0]&&0===e[1]&&0===e[2]){i[0]=t[0];i[1]=t[1];i[2]=t[2];return}const a=this.#Q(0),s=(1-a)/(1-this.#Q(e[0])),r=1-s,n=(1-a)/(1-this.#Q(e[1])),g=1-n,o=(1-a)/(1-this.#Q(e[2])),c=1-o;i[0]=t[0]*s+r;i[1]=t[1]*n+g;i[2]=t[2]*o+c}#u(e,t,i){if(1===e[0]&&1===e[2]){i[0]=t[0];i[1]=t[1];i[2]=t[2];return}const a=i;this.#c(CalRGBCS.#i,t,a);const s=CalRGBCS.#n;this.#C(e,a,s);this.#c(CalRGBCS.#a,s,i)}#d(e,t,i){const a=i;this.#c(CalRGBCS.#i,t,a);const s=CalRGBCS.#n;this.#h(e,a,s);this.#c(CalRGBCS.#a,s,i)}#t(e,t,i,a,s){const r=this.#B(0,1,e[t]*s),n=this.#B(0,1,e[t+1]*s),g=this.#B(0,1,e[t+2]*s),o=1===r?1:r**this.GR,c=1===n?1:n**this.GG,C=1===g?1:g**this.GB,h=this.MXA*o+this.MXB*c+this.MXC*C,l=this.MYA*o+this.MYB*c+this.MYC*C,Q=this.MZA*o+this.MZB*c+this.MZC*C,E=CalRGBCS.#g;E[0]=h;E[1]=l;E[2]=Q;const u=CalRGBCS.#o;this.#u(this.whitePoint,E,u);const d=CalRGBCS.#g;this.#E(this.blackPoint,u,d);const f=CalRGBCS.#o;this.#d(CalRGBCS.#r,d,f);const p=CalRGBCS.#g;this.#c(CalRGBCS.#s,f,p);i[a]=255*this.#l(p[0]);i[a+1]=255*this.#l(p[1]);i[a+2]=255*this.#l(p[2])}getRgbItem(e,t,i,a){this.#t(e,t,i,a,1)}getRgbBuffer(e,t,i,a,s,r,n){const g=1/((1<this.amax||this.bmin>this.bmax){info("Invalid Range, falling back to defaults");this.amin=-100;this.amax=100;this.bmin=-100;this.bmax=100}}#f(e){return e>=6/29?e**3:108/841*(e-4/29)}#p(e,t,i,a){return i+e*(a-i)/t}#t(e,t,i,a,s){let r=e[t],n=e[t+1],g=e[t+2];if(!1!==i){r=this.#p(r,i,0,100);n=this.#p(n,i,this.amin,this.amax);g=this.#p(g,i,this.bmin,this.bmax)}n>this.amax?n=this.amax:nthis.bmax?g=this.bmax:g>>0}function hexToStr(e,t){return 1===t?String.fromCharCode(e[0],e[1]):3===t?String.fromCharCode(e[0],e[1],e[2],e[3]):String.fromCharCode(...e.subarray(0,t+1))}function addHex(e,t,i){let a=0;for(let s=i;s>=0;s--){a+=e[s]+t[s];e[s]=255&a;a>>=8}}function incHex(e,t){let i=1;for(let a=t;a>=0&&i>0;a--){i+=e[a];e[a]=255&i;i>>=8}}const wt=16;class BinaryCMapStream{constructor(e){this.buffer=e;this.pos=0;this.end=e.length;this.tmpBuf=new Uint8Array(19)}readByte(){return this.pos>=this.end?-1:this.buffer[this.pos++]}readNumber(){let e,t=0;do{const i=this.readByte();if(i<0)throw new FormatError("unexpected EOF in bcmap");e=!(128&i);t=t<<7|127&i}while(!e);return t}readSigned(){const e=this.readNumber();return 1&e?~(e>>>1):e>>>1}readHex(e,t){e.set(this.buffer.subarray(this.pos,this.pos+t+1));this.pos+=t+1}readHexNumber(e,t){let i;const a=this.tmpBuf;let s=0;do{const e=this.readByte();if(e<0)throw new FormatError("unexpected EOF in bcmap");i=!(128&e);a[s++]=127&e}while(!i);let r=t,n=0,g=0;for(;r>=0;){for(;g<8&&a.length>0;){n|=a[--s]<>=8;g-=8}}readHexSigned(e,t){this.readHexNumber(e,t);const i=1&e[t]?255:0;let a=0;for(let s=0;s<=t;s++){a=(1&a)<<8|e[s];e[s]=a>>1^i}}readString(){const e=this.readNumber(),t=new Array(e);for(let i=0;i=0;){const e=l>>5;if(7===e){switch(31&l){case 0:a.readString();break;case 1:r=a.readString()}continue}const i=!!(16&l),s=15&l;if(s+1>wt)throw new Error("BinaryCMapReader.process: Invalid dataSize.");const Q=1,E=a.readNumber();switch(e){case 0:a.readHex(n,s);a.readHexNumber(g,s);addHex(g,n,s);t.addCodespaceRange(s+1,hexToInt(n,s),hexToInt(g,s));for(let e=1;es&&(a=s)}else{for(;!this.eof;)this.readBlock(t);a=this.bufferLength}this.pos=a;return this.buffer.subarray(i,a)}async getImageData(e,t=null){if(!this.canAsyncDecodeImageFromBuffer)return this.getBytes(e,t);const i=await this.stream.asyncGetBytes();return this.decodeImage(i,t)}reset(){this.pos=0}makeSubStream(e,t,i=null){if(void 0===t)for(;!this.eof;)this.readBlock();else{const i=e+t;for(;this.bufferLength<=i&&!this.eof;)this.readBlock()}return new Stream(this.buffer,e,t,i)}getBaseStreams(){return this.str?this.str.getBaseStreams():null}}class StreamsSequenceStream extends DecodeStream{constructor(e,t=null){e=e.filter((e=>e instanceof BaseStream));let i=0;for(const t of e)i+=t instanceof DecodeStream?t._rawMinBufferLength:t.length;super(i);this.streams=e;this._onError=t}readBlock(){const e=this.streams;if(0===e.length){this.eof=!0;return}const t=e.shift();let i;try{i=t.getBytes()}catch(e){if(this._onError){this._onError(e,t.dict?.objId);return}throw e}const a=this.bufferLength,s=a+i.length;this.ensureBuffer(s).set(i,a);this.bufferLength=s}getBaseStreams(){const e=[];for(const t of this.streams){const i=t.getBaseStreams();i&&e.push(...i)}return e.length>0?e:null}}class Ascii85Stream extends DecodeStream{constructor(e,t){t&&(t*=.8);super(t);this.str=e;this.dict=e.dict;this.input=new Uint8Array(5)}readBlock(){const e=this.str;let t=e.getByte();for(;isWhiteSpace(t);)t=e.getByte();if(-1===t||126===t){this.eof=!0;return}const i=this.bufferLength;let a,s;if(122===t){a=this.ensureBuffer(i+4);for(s=0;s<4;++s)a[i+s]=0;this.bufferLength+=4}else{const r=this.input;r[0]=t;for(s=1;s<5;++s){t=e.getByte();for(;isWhiteSpace(t);)t=e.getByte();r[s]=t;if(-1===t||126===t)break}a=this.ensureBuffer(i+s-1);this.bufferLength+=s-1;if(s<5){for(;s<5;++s)r[s]=117;this.eof=!0}let n=0;for(s=0;s<5;++s)n=85*n+(r[s]-33);for(s=3;s>=0;--s){a[i+s]=255&n;n>>=8}}}}class AsciiHexStream extends DecodeStream{constructor(e,t){t&&(t*=.5);super(t);this.str=e;this.dict=e.dict;this.firstDigit=-1}readBlock(){const e=this.str.getBytes(8e3);if(!e.length){this.eof=!0;return}const t=e.length+1>>1,i=this.ensureBuffer(this.bufferLength+t);let a=this.bufferLength,s=this.firstDigit;for(const t of e){let e;if(t>=48&&t<=57)e=15&t;else{if(!(t>=65&&t<=70||t>=97&&t<=102)){if(62===t){this.eof=!0;break}continue}e=9+(15&t)}if(s<0)s=e;else{i[a++]=s<<4|e;s=-1}}if(s>=0&&this.eof){i[a++]=s<<4;s=-1}this.firstDigit=s;this.bufferLength=a}}const bt=-1,Ft=[[-1,-1],[-1,-1],[7,8],[7,7],[6,6],[6,6],[6,5],[6,5],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2]],St=[[-1,-1],[12,-2],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[11,1792],[11,1792],[12,1984],[12,2048],[12,2112],[12,2176],[12,2240],[12,2304],[11,1856],[11,1856],[11,1920],[11,1920],[12,2368],[12,2432],[12,2496],[12,2560]],kt=[[-1,-1],[-1,-1],[-1,-1],[-1,-1],[8,29],[8,29],[8,30],[8,30],[8,45],[8,45],[8,46],[8,46],[7,22],[7,22],[7,22],[7,22],[7,23],[7,23],[7,23],[7,23],[8,47],[8,47],[8,48],[8,48],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[7,20],[7,20],[7,20],[7,20],[8,33],[8,33],[8,34],[8,34],[8,35],[8,35],[8,36],[8,36],[8,37],[8,37],[8,38],[8,38],[7,19],[7,19],[7,19],[7,19],[8,31],[8,31],[8,32],[8,32],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[8,53],[8,53],[8,54],[8,54],[7,26],[7,26],[7,26],[7,26],[8,39],[8,39],[8,40],[8,40],[8,41],[8,41],[8,42],[8,42],[8,43],[8,43],[8,44],[8,44],[7,21],[7,21],[7,21],[7,21],[7,28],[7,28],[7,28],[7,28],[8,61],[8,61],[8,62],[8,62],[8,63],[8,63],[8,0],[8,0],[8,320],[8,320],[8,384],[8,384],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[7,27],[7,27],[7,27],[7,27],[8,59],[8,59],[8,60],[8,60],[9,1472],[9,1536],[9,1600],[9,1728],[7,18],[7,18],[7,18],[7,18],[7,24],[7,24],[7,24],[7,24],[8,49],[8,49],[8,50],[8,50],[8,51],[8,51],[8,52],[8,52],[7,25],[7,25],[7,25],[7,25],[8,55],[8,55],[8,56],[8,56],[8,57],[8,57],[8,58],[8,58],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[8,448],[8,448],[8,512],[8,512],[9,704],[9,768],[8,640],[8,640],[8,576],[8,576],[9,832],[9,896],[9,960],[9,1024],[9,1088],[9,1152],[9,1216],[9,1280],[9,1344],[9,1408],[7,256],[7,256],[7,256],[7,256],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7]],Rt=[[-1,-1],[-1,-1],[12,-2],[12,-2],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[11,1792],[11,1792],[11,1792],[11,1792],[12,1984],[12,1984],[12,2048],[12,2048],[12,2112],[12,2112],[12,2176],[12,2176],[12,2240],[12,2240],[12,2304],[12,2304],[11,1856],[11,1856],[11,1856],[11,1856],[11,1920],[11,1920],[11,1920],[11,1920],[12,2368],[12,2368],[12,2432],[12,2432],[12,2496],[12,2496],[12,2560],[12,2560],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[12,52],[12,52],[13,640],[13,704],[13,768],[13,832],[12,55],[12,55],[12,56],[12,56],[13,1280],[13,1344],[13,1408],[13,1472],[12,59],[12,59],[12,60],[12,60],[13,1536],[13,1600],[11,24],[11,24],[11,24],[11,24],[11,25],[11,25],[11,25],[11,25],[13,1664],[13,1728],[12,320],[12,320],[12,384],[12,384],[12,448],[12,448],[13,512],[13,576],[12,53],[12,53],[12,54],[12,54],[13,896],[13,960],[13,1024],[13,1088],[13,1152],[13,1216],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64]],Nt=[[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[11,23],[11,23],[12,50],[12,51],[12,44],[12,45],[12,46],[12,47],[12,57],[12,58],[12,61],[12,256],[10,16],[10,16],[10,16],[10,16],[10,17],[10,17],[10,17],[10,17],[12,48],[12,49],[12,62],[12,63],[12,30],[12,31],[12,32],[12,33],[12,40],[12,41],[11,22],[11,22],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[12,128],[12,192],[12,26],[12,27],[12,28],[12,29],[11,19],[11,19],[11,20],[11,20],[12,34],[12,35],[12,36],[12,37],[12,38],[12,39],[11,21],[11,21],[12,42],[12,43],[10,0],[10,0],[10,0],[10,0],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12]],Gt=[[-1,-1],[-1,-1],[-1,-1],[-1,-1],[6,9],[6,8],[5,7],[5,7],[4,6],[4,6],[4,6],[4,6],[4,5],[4,5],[4,5],[4,5],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2]];class CCITTFaxDecoder{constructor(e,t={}){if("function"!=typeof e?.next)throw new Error('CCITTFaxDecoder - invalid "source" parameter.');this.source=e;this.eof=!1;this.encoding=t.K||0;this.eoline=t.EndOfLine||!1;this.byteAlign=t.EncodedByteAlign||!1;this.columns=t.Columns||1728;this.rows=t.Rows||0;this.eoblock=t.EndOfBlock??!0;this.black=t.BlackIs1||!1;this.codingLine=new Uint32Array(this.columns+1);this.refLine=new Uint32Array(this.columns+2);this.codingLine[0]=this.columns;this.codingPos=0;this.row=0;this.nextLine2D=this.encoding<0;this.inputBits=0;this.inputBuf=0;this.outputBits=0;this.rowsDone=!1;let i;for(;0===(i=this._lookBits(12));)this._eatBits(1);1===i&&this._eatBits(12);if(this.encoding>0){this.nextLine2D=!this._lookBits(1);this._eatBits(1)}}readNextChar(){if(this.eof)return-1;const e=this.refLine,t=this.codingLine,i=this.columns;let a,s,r,n,g;if(0===this.outputBits){this.rowsDone&&(this.eof=!0);if(this.eof)return-1;this.err=!1;let r,g,o;if(this.nextLine2D){for(n=0;t[n]=64);do{g+=o=this._getWhiteCode()}while(o>=64)}else{do{r+=o=this._getWhiteCode()}while(o>=64);do{g+=o=this._getBlackCode()}while(o>=64)}this._addPixels(t[this.codingPos]+r,s);t[this.codingPos]0?--a:++a;for(;e[a]<=t[this.codingPos]&&e[a]0?--a:++a;for(;e[a]<=t[this.codingPos]&&e[a]0?--a:++a;for(;e[a]<=t[this.codingPos]&&e[a]=64);else do{r+=o=this._getWhiteCode()}while(o>=64);this._addPixels(t[this.codingPos]+r,s);s^=1}}let c=!1;this.byteAlign&&(this.inputBits&=-8);if(this.eoblock||this.row!==this.rows-1){r=this._lookBits(12);if(this.eoline)for(;r!==bt&&1!==r;){this._eatBits(1);r=this._lookBits(12)}else for(;0===r;){this._eatBits(1);r=this._lookBits(12)}if(1===r){this._eatBits(12);c=!0}else r===bt&&(this.eof=!0)}else this.rowsDone=!0;if(!this.eof&&this.encoding>0&&!this.rowsDone){this.nextLine2D=!this._lookBits(1);this._eatBits(1)}if(this.eoblock&&c&&this.byteAlign){r=this._lookBits(12);if(1===r){this._eatBits(12);if(this.encoding>0){this._lookBits(1);this._eatBits(1)}if(this.encoding>=0)for(n=0;n<4;++n){r=this._lookBits(12);1!==r&&info("bad rtc code: "+r);this._eatBits(12);if(this.encoding>0){this._lookBits(1);this._eatBits(1)}}this.eof=!0}}else if(this.err&&this.eoline){for(;;){r=this._lookBits(13);if(r===bt){this.eof=!0;return-1}if(r>>1==1)break;this._eatBits(1)}this._eatBits(12);if(this.encoding>0){this._eatBits(1);this.nextLine2D=!(1&r)}}this.outputBits=t[0]>0?t[this.codingPos=0]:t[this.codingPos=1];this.row++}if(this.outputBits>=8){g=1&this.codingPos?0:255;this.outputBits-=8;if(0===this.outputBits&&t[this.codingPos]r){g<<=r;1&this.codingPos||(g|=255>>8-r);this.outputBits-=r;r=0}else{g<<=this.outputBits;1&this.codingPos||(g|=255>>8-this.outputBits);r-=this.outputBits;this.outputBits=0;if(t[this.codingPos]0){g<<=r;r=0}}}while(r)}this.black&&(g^=255);return g}_addPixels(e,t){const i=this.codingLine;let a=this.codingPos;if(e>i[a]){if(e>this.columns){info("row is wrong length");this.err=!0;e=this.columns}1&a^t&&++a;i[a]=e}this.codingPos=a}_addPixelsNeg(e,t){const i=this.codingLine;let a=this.codingPos;if(e>i[a]){if(e>this.columns){info("row is wrong length");this.err=!0;e=this.columns}1&a^t&&++a;i[a]=e}else if(e0&&e=s){const t=i[e-s];if(t[0]===a){this._eatBits(a);return[!0,t[1],!0]}}}return[!1,0,!1]}_getTwoDimCode(){let e,t=0;if(this.eoblock){t=this._lookBits(7);e=Ft[t];if(e?.[0]>0){this._eatBits(e[0]);return e[1]}}else{const e=this._findTableCode(1,7,Ft);if(e[0]&&e[2])return e[1]}info("Bad two dim code");return bt}_getWhiteCode(){let e,t=0;if(this.eoblock){t=this._lookBits(12);if(t===bt)return 1;e=t>>5?kt[t>>3]:St[t];if(e[0]>0){this._eatBits(e[0]);return e[1]}}else{let e=this._findTableCode(1,9,kt);if(e[0])return e[1];e=this._findTableCode(11,12,St);if(e[0])return e[1]}info("bad white code");this._eatBits(1);return 1}_getBlackCode(){let e,t;if(this.eoblock){e=this._lookBits(13);if(e===bt)return 1;t=e>>7?!(e>>9)&&e>>7?Nt[(e>>1)-64]:Gt[e>>7]:Rt[e];if(t[0]>0){this._eatBits(t[0]);return t[1]}}else{let e=this._findTableCode(2,6,Gt);if(e[0])return e[1];e=this._findTableCode(7,12,Nt,64);if(e[0])return e[1];e=this._findTableCode(10,13,Rt);if(e[0])return e[1]}info("bad black code");this._eatBits(1);return 1}_lookBits(e){let t;for(;this.inputBits>16-e;this.inputBuf=this.inputBuf<<8|t;this.inputBits+=8}return this.inputBuf>>this.inputBits-e&65535>>16-e}_eatBits(e){(this.inputBits-=e)<0&&(this.inputBits=0)}}class CCITTFaxStream extends DecodeStream{constructor(e,t,i){super(t);this.str=e;this.dict=e.dict;i instanceof Dict||(i=Dict.empty);const a={next:()=>e.getByte()};this.ccittFaxDecoder=new CCITTFaxDecoder(a,{K:i.get("K"),EndOfLine:i.get("EndOfLine"),EncodedByteAlign:i.get("EncodedByteAlign"),Columns:i.get("Columns"),Rows:i.get("Rows"),EndOfBlock:i.get("EndOfBlock"),BlackIs1:i.get("BlackIs1")})}readBlock(){for(;!this.eof;){const e=this.ccittFaxDecoder.readNextChar();if(-1===e){this.eof=!0;return}this.ensureBuffer(this.bufferLength+1);this.buffer[this.bufferLength++]=e}}}const Mt=new Int32Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Ut=new Int32Array([3,4,5,6,7,8,9,10,65547,65549,65551,65553,131091,131095,131099,131103,196643,196651,196659,196667,262211,262227,262243,262259,327811,327843,327875,327907,258,258,258]),xt=new Int32Array([1,2,3,4,65541,65543,131081,131085,196625,196633,262177,262193,327745,327777,393345,393409,459009,459137,524801,525057,590849,591361,657409,658433,724993,727041,794625,798721,868353,876545]),Lt=[new Int32Array([459008,524368,524304,524568,459024,524400,524336,590016,459016,524384,524320,589984,524288,524416,524352,590048,459012,524376,524312,589968,459028,524408,524344,590032,459020,524392,524328,59e4,524296,524424,524360,590064,459010,524372,524308,524572,459026,524404,524340,590024,459018,524388,524324,589992,524292,524420,524356,590056,459014,524380,524316,589976,459030,524412,524348,590040,459022,524396,524332,590008,524300,524428,524364,590072,459009,524370,524306,524570,459025,524402,524338,590020,459017,524386,524322,589988,524290,524418,524354,590052,459013,524378,524314,589972,459029,524410,524346,590036,459021,524394,524330,590004,524298,524426,524362,590068,459011,524374,524310,524574,459027,524406,524342,590028,459019,524390,524326,589996,524294,524422,524358,590060,459015,524382,524318,589980,459031,524414,524350,590044,459023,524398,524334,590012,524302,524430,524366,590076,459008,524369,524305,524569,459024,524401,524337,590018,459016,524385,524321,589986,524289,524417,524353,590050,459012,524377,524313,589970,459028,524409,524345,590034,459020,524393,524329,590002,524297,524425,524361,590066,459010,524373,524309,524573,459026,524405,524341,590026,459018,524389,524325,589994,524293,524421,524357,590058,459014,524381,524317,589978,459030,524413,524349,590042,459022,524397,524333,590010,524301,524429,524365,590074,459009,524371,524307,524571,459025,524403,524339,590022,459017,524387,524323,589990,524291,524419,524355,590054,459013,524379,524315,589974,459029,524411,524347,590038,459021,524395,524331,590006,524299,524427,524363,590070,459011,524375,524311,524575,459027,524407,524343,590030,459019,524391,524327,589998,524295,524423,524359,590062,459015,524383,524319,589982,459031,524415,524351,590046,459023,524399,524335,590014,524303,524431,524367,590078,459008,524368,524304,524568,459024,524400,524336,590017,459016,524384,524320,589985,524288,524416,524352,590049,459012,524376,524312,589969,459028,524408,524344,590033,459020,524392,524328,590001,524296,524424,524360,590065,459010,524372,524308,524572,459026,524404,524340,590025,459018,524388,524324,589993,524292,524420,524356,590057,459014,524380,524316,589977,459030,524412,524348,590041,459022,524396,524332,590009,524300,524428,524364,590073,459009,524370,524306,524570,459025,524402,524338,590021,459017,524386,524322,589989,524290,524418,524354,590053,459013,524378,524314,589973,459029,524410,524346,590037,459021,524394,524330,590005,524298,524426,524362,590069,459011,524374,524310,524574,459027,524406,524342,590029,459019,524390,524326,589997,524294,524422,524358,590061,459015,524382,524318,589981,459031,524414,524350,590045,459023,524398,524334,590013,524302,524430,524366,590077,459008,524369,524305,524569,459024,524401,524337,590019,459016,524385,524321,589987,524289,524417,524353,590051,459012,524377,524313,589971,459028,524409,524345,590035,459020,524393,524329,590003,524297,524425,524361,590067,459010,524373,524309,524573,459026,524405,524341,590027,459018,524389,524325,589995,524293,524421,524357,590059,459014,524381,524317,589979,459030,524413,524349,590043,459022,524397,524333,590011,524301,524429,524365,590075,459009,524371,524307,524571,459025,524403,524339,590023,459017,524387,524323,589991,524291,524419,524355,590055,459013,524379,524315,589975,459029,524411,524347,590039,459021,524395,524331,590007,524299,524427,524363,590071,459011,524375,524311,524575,459027,524407,524343,590031,459019,524391,524327,589999,524295,524423,524359,590063,459015,524383,524319,589983,459031,524415,524351,590047,459023,524399,524335,590015,524303,524431,524367,590079]),9],Ht=[new Int32Array([327680,327696,327688,327704,327684,327700,327692,327708,327682,327698,327690,327706,327686,327702,327694,0,327681,327697,327689,327705,327685,327701,327693,327709,327683,327699,327691,327707,327687,327703,327695,0]),5];class FlateStream extends DecodeStream{constructor(e,t){super(t);this.str=e;this.dict=e.dict;const i=e.getByte(),a=e.getByte();if(-1===i||-1===a)throw new FormatError(`Invalid header in flate stream: ${i}, ${a}`);if(8!=(15&i))throw new FormatError(`Unknown compression method in flate stream: ${i}, ${a}`);if(((i<<8)+a)%31!=0)throw new FormatError(`Bad FCHECK in flate stream: ${i}, ${a}`);if(32&a)throw new FormatError(`FDICT bit set in flate stream: ${i}, ${a}`);this.codeSize=0;this.codeBuf=0}async getImageData(e,t){const i=await this.asyncGetBytes();return i?.subarray(0,e)||this.getBytes(e)}async asyncGetBytes(){this.str.reset();const e=this.str.getBytes();try{const{readable:t,writable:i}=new DecompressionStream("deflate"),a=i.getWriter();await a.ready;a.write(e).then((async()=>{await a.ready;await a.close()})).catch((()=>{}));const s=[];let r=0;for await(const e of t){s.push(e);r+=e.byteLength}const n=new Uint8Array(r);let g=0;for(const e of s){n.set(e,g);g+=e.byteLength}return n}catch{this.str=new Stream(e,2,e.length,this.str.dict);this.reset();return null}}get isAsync(){return!0}getBits(e){const t=this.str;let i,a=this.codeSize,s=this.codeBuf;for(;a>e;this.codeSize=a-=e;return i}getCode(e){const t=this.str,i=e[0],a=e[1];let s,r=this.codeSize,n=this.codeBuf;for(;r>16,c=65535&g;if(o<1||r>o;this.codeSize=r-o;return c}generateHuffmanTable(e){const t=e.length;let i,a=0;for(i=0;ia&&(a=e[i]);const s=1<>=1}for(i=e;i>=1;if(0===t){let t;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}let i=t;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}i|=t<<8;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}let s=t;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}s|=t<<8;if(s!==(65535&~i)&&(0!==i||0!==s))throw new FormatError("Bad uncompressed block length in flate stream");this.codeBuf=0;this.codeSize=0;const r=this.bufferLength,n=r+i;e=this.ensureBuffer(n);this.bufferLength=n;if(0===i)-1===a.peekByte()&&(this.eof=!0);else{const t=a.getBytes(i);e.set(t,r);t.length0;)C[g++]=Q}s=this.generateHuffmanTable(C.subarray(0,e));r=this.generateHuffmanTable(C.subarray(e,c))}}e=this.buffer;let n=e?e.length:0,g=this.bufferLength;for(;;){let t=this.getCode(s);if(t<256){if(g+1>=n){e=this.ensureBuffer(g+1);n=e.length}e[g++]=t;continue}if(256===t){this.bufferLength=g;return}t-=257;t=Ut[t];let a=t>>16;a>0&&(a=this.getBits(a));i=(65535&t)+a;t=this.getCode(r);t=xt[t];a=t>>16;a>0&&(a=this.getBits(a));const o=(65535&t)+a;if(g+i>=n){e=this.ensureBuffer(g+i);n=e.length}for(let t=0;t>9&127;this.clow=this.clow<<7&65535;this.ct-=7;this.a=32768}byteIn(){const e=this.data;let t=this.bp;if(255===e[t])if(e[t+1]>143){this.clow+=65280;this.ct=8}else{t++;this.clow+=e[t]<<9;this.ct=7;this.bp=t}else{t++;this.clow+=t65535){this.chigh+=this.clow>>16;this.clow&=65535}}readBit(e,t){let i=e[t]>>1,a=1&e[t];const s=Jt[i],r=s.qe;let n,g=this.a-r;if(this.chigh>15&1;this.clow=this.clow<<1&65535;this.ct--}while(!(32768&g));this.a=g;e[t]=i<<1|a;return n}}class Jbig2Error extends ot{constructor(e){super(e,"Jbig2Error")}}class ContextCache{getContexts(e){return e in this?this[e]:this[e]=new Int8Array(65536)}}class DecodingContext{constructor(e,t,i){this.data=e;this.start=t;this.end=i}get decoder(){return shadow(this,"decoder",new ArithmeticDecoder(this.data,this.start,this.end))}get contextCache(){return shadow(this,"contextCache",new ContextCache)}}function decodeInteger(e,t,i){const a=e.getContexts(t);let s=1;function readBits(e){let t=0;for(let r=0;r>>0}const r=readBits(1),n=readBits(1)?readBits(1)?readBits(1)?readBits(1)?readBits(1)?readBits(32)+4436:readBits(12)+340:readBits(8)+84:readBits(6)+20:readBits(4)+4:readBits(2);let g;0===r?g=n:n>0&&(g=-n);return g>=-2147483648&&g<=pt?g:null}function decodeIAID(e,t,i){const a=e.getContexts("IAID");let s=1;for(let e=0;e=F&&x=S){K=K<<1&d;for(u=0;u=0&&H=0){J=G[L][H];J&&(K|=J<=e?c<<=1:c=c<<1|w[g][o]}for(Q=0;Q=m||o<0||o>=p?c<<=1:c=c<<1|a[g][o]}const E=D.readBit(b,c);t[n]=E}}return w}function decodeTextRegion(e,t,i,a,s,r,n,g,o,c,C,h,l,Q,E,u,d,f,p){if(e&&t)throw new Jbig2Error("refinement with Huffman is not supported");const m=[];let y,w;for(y=0;y1&&(s=e?p.readBits(f):decodeInteger(b,"IAIT",D));const r=n*F+s,S=e?Q.symbolIDTable.decode(p):decodeIAID(b,D,o),k=t&&(e?p.readBit():decodeInteger(b,"IARI",D));let R=g[S],N=R[0].length,G=R.length;if(k){const e=decodeInteger(b,"IARDW",D),t=decodeInteger(b,"IARDH",D);N+=e;G+=t;R=decodeRefinement(N,G,E,R,(e>>1)+decodeInteger(b,"IARDX",D),(t>>1)+decodeInteger(b,"IARDY",D),!1,u,d)}let M=0;c?1&h?M=G-1:a+=G-1:h>1?a+=N-1:M=N-1;const U=r-(1&h?0:G-1),x=a-(2&h?N-1:0);let L,H,J;if(c)for(L=0;L>5&7;const o=[31&n];let c=t+6;if(7===n){g=536870911&readUint32(e,c-1);c+=3;let t=g+7>>3;o[0]=e[c++];for(;--t>0;)o.push(e[c++])}else if(5===n||6===n)throw new Jbig2Error("invalid referred-to flags");i.retainBits=o;let C=4;i.number<=256?C=1:i.number<=65536&&(C=2);const h=[];let l,Q;for(l=0;l>>24&255;r[3]=t.height>>16&255;r[4]=t.height>>8&255;r[5]=255&t.height;for(l=c,Q=e.length;l>2&3;e.huffmanDWSelector=t>>4&3;e.bitmapSizeSelector=t>>6&1;e.aggregationInstancesSelector=t>>7&1;e.bitmapCodingContextUsed=!!(256&t);e.bitmapCodingContextRetained=!!(512&t);e.template=t>>10&3;e.refinementTemplate=t>>12&1;c+=2;if(!e.huffman){o=0===e.template?4:1;n=[];for(g=0;g>2&3;C.stripSize=1<>4&3;C.transposed=!!(64&h);C.combinationOperator=h>>7&3;C.defaultPixelValue=h>>9&1;C.dsOffset=h<<17>>27;C.refinementTemplate=h>>15&1;if(C.huffman){const e=readUint16(a,c);c+=2;C.huffmanFS=3&e;C.huffmanDS=e>>2&3;C.huffmanDT=e>>4&3;C.huffmanRefinementDW=e>>6&3;C.huffmanRefinementDH=e>>8&3;C.huffmanRefinementDX=e>>10&3;C.huffmanRefinementDY=e>>12&3;C.huffmanRefinementSizeSelector=!!(16384&e)}if(C.refinement&&!C.refinementTemplate){n=[];for(g=0;g<2;g++){n.push({x:readInt8(a,c),y:readInt8(a,c+1)});c+=2}C.refinementAt=n}C.numberOfSymbolInstances=readUint32(a,c);c+=4;r=[C,i.referredTo,a,c,s];break;case 16:const l={},Q=a[c++];l.mmr=!!(1&Q);l.template=Q>>1&3;l.patternWidth=a[c++];l.patternHeight=a[c++];l.maxPatternIndex=readUint32(a,c);c+=4;r=[l,i.number,a,c,s];break;case 22:case 23:const E={};E.info=readRegionSegmentInformation(a,c);c+=Ot;const u=a[c++];E.mmr=!!(1&u);E.template=u>>1&3;E.enableSkip=!!(8&u);E.combinationOperator=u>>4&7;E.defaultPixelValue=u>>7&1;E.gridWidth=readUint32(a,c);c+=4;E.gridHeight=readUint32(a,c);c+=4;E.gridOffsetX=4294967295&readUint32(a,c);c+=4;E.gridOffsetY=4294967295&readUint32(a,c);c+=4;E.gridVectorX=readUint16(a,c);c+=2;E.gridVectorY=readUint16(a,c);c+=2;r=[E,i.referredTo,a,c,s];break;case 38:case 39:const d={};d.info=readRegionSegmentInformation(a,c);c+=Ot;const f=a[c++];d.mmr=!!(1&f);d.template=f>>1&3;d.prediction=!!(8&f);if(!d.mmr){o=0===d.template?4:1;n=[];for(g=0;g>2&1;p.combinationOperator=m>>3&3;p.requiresBuffer=!!(32&m);p.combinationOperatorOverride=!!(64&m);r=[p];break;case 49:case 50:case 51:case 62:break;case 53:r=[i.number,a,c,s];break;default:throw new Jbig2Error(`segment type ${i.typeName}(${i.type}) is not implemented`)}const C="on"+i.typeName;C in t&&t[C].apply(t,r)}function processSegments(e,t){for(let i=0,a=e.length;i>3,i=new Uint8ClampedArray(t*e.height);e.defaultPixelValue&&i.fill(255);this.buffer=i}drawBitmap(e,t){const i=this.currentPageInfo,a=e.width,s=e.height,r=i.width+7>>3,n=i.combinationOperatorOverride?e.combinationOperator:i.combinationOperator,g=this.buffer,o=128>>(7&e.x);let c,C,h,l,Q=e.y*r+(e.x>>3);switch(n){case 0:for(c=0;c>=1;if(!h){h=128;l++}}Q+=r}break;case 2:for(c=0;c>=1;if(!h){h=128;l++}}Q+=r}break;default:throw new Jbig2Error(`operator ${n} is not supported`)}}onImmediateGenericRegion(e,t,i,a){const s=e.info,r=new DecodingContext(t,i,a),n=decodeBitmap(e.mmr,s.width,s.height,e.template,e.prediction,null,e.at,r);this.drawBitmap(s,n)}onImmediateLosslessGenericRegion(){this.onImmediateGenericRegion(...arguments)}onSymbolDictionary(e,t,i,a,s,r){let n,g;if(e.huffman){n=function getSymbolDictionaryHuffmanTables(e,t,i){let a,s,r,n,g=0;switch(e.huffmanDHSelector){case 0:case 1:a=getStandardTable(e.huffmanDHSelector+4);break;case 3:a=getCustomHuffmanTable(g,t,i);g++;break;default:throw new Jbig2Error("invalid Huffman DH selector")}switch(e.huffmanDWSelector){case 0:case 1:s=getStandardTable(e.huffmanDWSelector+2);break;case 3:s=getCustomHuffmanTable(g,t,i);g++;break;default:throw new Jbig2Error("invalid Huffman DW selector")}if(e.bitmapSizeSelector){r=getCustomHuffmanTable(g,t,i);g++}else r=getStandardTable(1);n=e.aggregationInstancesSelector?getCustomHuffmanTable(g,t,i):getStandardTable(1);return{tableDeltaHeight:a,tableDeltaWidth:s,tableBitmapSize:r,tableAggregateInstances:n}}(e,i,this.customTables);g=new Reader(a,s,r)}let o=this.symbols;o||(this.symbols=o={});const c=[];for(const e of i){const t=o[e];t&&c.push(...t)}const C=new DecodingContext(a,s,r);o[t]=function decodeSymbolDictionary(e,t,i,a,s,r,n,g,o,c,C,h){if(e&&t)throw new Jbig2Error("symbol refinement with Huffman is not supported");const l=[];let Q=0,E=log2(i.length+a);const u=C.decoder,d=C.contextCache;let f,p;if(e){f=getStandardTable(1);p=[];E=Math.max(E,1)}for(;l.length1)m=decodeTextRegion(e,t,a,Q,0,s,1,i.concat(l),E,0,0,1,0,r,o,c,C,0,h);else{const e=decodeIAID(d,u,E),t=decodeInteger(d,"IARDX",u),s=decodeInteger(d,"IARDY",u);m=decodeRefinement(a,Q,o,e=32){let i,a,n;switch(t){case 32:if(0===e)throw new Jbig2Error("no previous value in symbol ID table");a=s.readBits(2)+3;i=r[e-1].prefixLength;break;case 33:a=s.readBits(3)+3;i=0;break;case 34:a=s.readBits(7)+11;i=0;break;default:throw new Jbig2Error("invalid code length in symbol ID table")}for(n=0;n=0;d--){R=e?decodeMMRBitmap(k,o,c,!0):decodeBitmap(!1,o,c,i,!1,null,F,E);S[d]=R}for(N=0;N=0;f--){M^=S[f][N][G];U|=M<>8;H=h+N*l-G*Q>>8;if(L>=0&&L+w<=a&&H>=0&&H+D<=s)for(d=0;d=s)){Y=u[t];J=x[d];for(f=0;f=0&&e>1&7),o=1+(a>>4&7),c=[];let C,h,l=s;do{C=n.readBits(g);h=n.readBits(o);c.push(new HuffmanLine([l,C,h,0]));l+=1<>t&1;if(t<=0)this.children[i]=new HuffmanTreeNode(e);else{let a=this.children[i];a||(this.children[i]=a=new HuffmanTreeNode(null));a.buildTree(e,t-1)}}decodeNode(e){if(this.isLeaf){if(this.isOOB)return null;const t=e.readBits(this.rangeLength);return this.rangeLow+(this.isLowerRange?-t:t)}const t=this.children[e.readBit()];if(!t)throw new Jbig2Error("invalid Huffman data");return t.decodeNode(e)}}class HuffmanTable{constructor(e,t){t||this.assignPrefixCodes(e);this.rootNode=new HuffmanTreeNode(null);for(let t=0,i=e.length;t0&&this.rootNode.buildTree(i,i.prefixLength-1)}}decode(e){return this.rootNode.decodeNode(e)}assignPrefixCodes(e){const t=e.length;let i=0;for(let a=0;a=this.end)throw new Jbig2Error("end of data while reading bit");this.currentByte=this.data[this.position++];this.shift=7}const e=this.currentByte>>this.shift&1;this.shift--;return e}readBits(e){let t,i=0;for(t=e-1;t>=0;t--)i|=this.readBit()<=this.end?-1:this.data[this.position++]}}function getCustomHuffmanTable(e,t,i){let a=0;for(let s=0,r=t.length;s>i&1;i--}}if(a&&!g){const e=5;for(let t=0;t>2,c=new Uint32Array(e.buffer,t,o);if(FeatureTest.isLittleEndian){for(;n>>24|t<<8|4278190080;i[a+2]=t>>>16|s<<16|4278190080;i[a+3]=s>>>8|4278190080}for(let s=4*n,r=t+g;s>>8|255;i[a+2]=t<<16|s>>>16|255;i[a+3]=s<<8|255}for(let s=4*n,r=t+g;s>3,h=7&a,l=e.length;i=new Uint32Array(i.buffer);let Q=0;for(let a=0;a0&&!e[r-1];)r--;const n=[{children:[],index:0}];let g,o=n[0];for(i=0;i0;)o=n.pop();o.index++;n.push(o);for(;n.length<=i;){n.push(g={children:[],index:0});o.children[o.index]=g.children;o=g}s++}if(i+10){E--;return Q>>E&1}Q=e[t++];if(255===Q){const a=e[t++];if(a){if(220===a&&c){const a=readUint16(e,t+=2);t+=2;if(a>0&&a!==i.scanLines)throw new DNLMarkerError("Found DNL marker (0xFFDC) while parsing scan data",a)}else if(217===a){if(c){const e=p*(8===i.precision?8:0);if(e>0&&Math.round(i.scanLines/e)>=5)throw new DNLMarkerError("Found EOI marker (0xFFD9) while parsing scan data, possibly caused by incorrect `scanLines` parameter",e)}throw new EOIMarkerError("Found EOI marker (0xFFD9) while parsing scan data")}throw new JpegError(`unexpected marker ${(Q<<8|a).toString(16)}`)}}E=7;return Q>>>7}function decodeHuffman(e){let t=e;for(;;){t=t[readBit()];switch(typeof t){case"number":return t;case"object":continue}throw new JpegError("invalid huffman sequence")}}function receive(e){let t=0;for(;e>0;){t=t<<1|readBit();e--}return t}function receiveAndExtend(e){if(1===e)return 1===readBit()?1:-1;const t=receive(e);return t>=1<0){u--;return}let i=r;const a=n;for(;i<=a;){const a=decodeHuffman(e.huffmanTableAC),s=15&a,r=a>>4;if(0===s){if(r<15){u=receive(r)+(1<>4;if(0===s)if(c<15){u=receive(c)+(1<>4;if(0===a){if(r<15)break;s+=16;continue}s+=r;const n=Wt[s];e.blockData[t+n]=receiveAndExtend(a);s++}};let k,R=0;const N=1===m?a[0].blocksPerLine*a[0].blocksPerColumn:C*i.mcusPerColumn;let G,M;for(;R<=N;){const i=s?Math.min(N-R,s):N;if(i>0){for(w=0;w0?"unexpected":"excessive"} MCU data, current marker is: ${k.invalid}`);t=k.offset}if(!(k.marker>=65488&&k.marker<=65495))break;t+=2}return t-l}function quantizeAndInverse(e,t,i){const a=e.quantizationTable,s=e.blockData;let r,n,g,o,c,C,h,l,Q,E,u,d,f,p,m,y,w;if(!a)throw new JpegError("missing required Quantization Table.");for(let e=0;e<64;e+=8){Q=s[t+e];E=s[t+e+1];u=s[t+e+2];d=s[t+e+3];f=s[t+e+4];p=s[t+e+5];m=s[t+e+6];y=s[t+e+7];Q*=a[e];if(E|u|d|f|p|m|y){E*=a[e+1];u*=a[e+2];d*=a[e+3];f*=a[e+4];p*=a[e+5];m*=a[e+6];y*=a[e+7];r=$t*Q+128>>8;n=$t*f+128>>8;g=u;o=m;c=Ai*(E-y)+128>>8;l=Ai*(E+y)+128>>8;C=d<<4;h=p<<4;r=r+n+1>>1;n=r-n;w=g*_t+o*zt+128>>8;g=g*zt-o*_t+128>>8;o=w;c=c+h+1>>1;h=c-h;l=l+C+1>>1;C=l-C;r=r+o+1>>1;o=r-o;n=n+g+1>>1;g=n-g;w=c*Vt+l*Zt+2048>>12;c=c*Zt-l*Vt+2048>>12;l=w;w=C*Xt+h*jt+2048>>12;C=C*jt-h*Xt+2048>>12;h=w;i[e]=r+l;i[e+7]=r-l;i[e+1]=n+h;i[e+6]=n-h;i[e+2]=g+C;i[e+5]=g-C;i[e+3]=o+c;i[e+4]=o-c}else{w=$t*Q+512>>10;i[e]=w;i[e+1]=w;i[e+2]=w;i[e+3]=w;i[e+4]=w;i[e+5]=w;i[e+6]=w;i[e+7]=w}}for(let e=0;e<8;++e){Q=i[e];E=i[e+8];u=i[e+16];d=i[e+24];f=i[e+32];p=i[e+40];m=i[e+48];y=i[e+56];if(E|u|d|f|p|m|y){r=$t*Q+2048>>12;n=$t*f+2048>>12;g=u;o=m;c=Ai*(E-y)+2048>>12;l=Ai*(E+y)+2048>>12;C=d;h=p;r=4112+(r+n+1>>1);n=r-n;w=g*_t+o*zt+2048>>12;g=g*zt-o*_t+2048>>12;o=w;c=c+h+1>>1;h=c-h;l=l+C+1>>1;C=l-C;r=r+o+1>>1;o=r-o;n=n+g+1>>1;g=n-g;w=c*Vt+l*Zt+2048>>12;c=c*Zt-l*Vt+2048>>12;l=w;w=C*Xt+h*jt+2048>>12;C=C*jt-h*Xt+2048>>12;h=w;Q=r+l;y=r-l;E=n+h;m=n-h;u=g+C;p=g-C;d=o+c;f=o-c;Q<16?Q=0:Q>=4080?Q=255:Q>>=4;E<16?E=0:E>=4080?E=255:E>>=4;u<16?u=0:u>=4080?u=255:u>>=4;d<16?d=0:d>=4080?d=255:d>>=4;f<16?f=0:f>=4080?f=255:f>>=4;p<16?p=0:p>=4080?p=255:p>>=4;m<16?m=0:m>=4080?m=255:m>>=4;y<16?y=0:y>=4080?y=255:y>>=4;s[t+e]=Q;s[t+e+8]=E;s[t+e+16]=u;s[t+e+24]=d;s[t+e+32]=f;s[t+e+40]=p;s[t+e+48]=m;s[t+e+56]=y}else{w=$t*Q+8192>>14;w=w<-2040?0:w>=2024?255:w+2056>>4;s[t+e]=w;s[t+e+8]=w;s[t+e+16]=w;s[t+e+24]=w;s[t+e+32]=w;s[t+e+40]=w;s[t+e+48]=w;s[t+e+56]=w}}}function buildComponentData(e,t){const i=t.blocksPerLine,a=t.blocksPerColumn,s=new Int16Array(64);for(let e=0;e=a)return null;const r=readUint16(e,t);if(r>=65472&&r<=65534)return{invalid:null,marker:r,offset:t};let n=readUint16(e,s);for(;!(n>=65472&&n<=65534);){if(++s>=a)return null;n=readUint16(e,s)}return{invalid:r.toString(16),marker:n,offset:s}}function prepareComponents(e){const t=Math.ceil(e.samplesPerLine/8/e.maxH),i=Math.ceil(e.scanLines/8/e.maxV);for(const a of e.components){const s=Math.ceil(Math.ceil(e.samplesPerLine/8)*a.h/e.maxH),r=Math.ceil(Math.ceil(e.scanLines/8)*a.v/e.maxV),n=t*a.h,g=64*(i*a.v)*(n+1);a.blockData=new Int16Array(g);a.blocksPerLine=s;a.blocksPerColumn=r}e.mcusPerLine=t;e.mcusPerColumn=i}function readDataBlock(e,t){const i=readUint16(e,t);let a=(t+=2)+i-2;const s=findNextFileMarker(e,a,t);if(s?.invalid){warn("readDataBlock - incorrect length, current marker is: "+s.invalid);a=s.offset}const r=e.subarray(t,a);return{appData:r,newOffset:t+=r.length}}function skipData(e,t){const i=readUint16(e,t),a=(t+=2)+i-2,s=findNextFileMarker(e,a,t);return s?.invalid?s.offset:a}class JpegImage{constructor({decodeTransform:e=null,colorTransform:t=-1}={}){this._decodeTransform=e;this._colorTransform=t}static canUseImageDecoder(e,t=-1){let i=0,a=null,s=readUint16(e,i);i+=2;if(65496!==s)throw new JpegError("SOI not found");s=readUint16(e,i);i+=2;A:for(;65497!==s;){switch(s){case 65472:case 65473:case 65474:a=e[i+7];break A;case 65535:255!==e[i]&&i--}i=skipData(e,i);s=readUint16(e,i);i+=2}return 4!==a&&(3!==a||0!==t)}parse(e,{dnlScanLines:t=null}={}){let i,a,s=0,r=null,n=null,g=0;const o=[],c=[],C=[];let h=readUint16(e,s);s+=2;if(65496!==h)throw new JpegError("SOI not found");h=readUint16(e,s);s+=2;A:for(;65497!==h;){let l,Q,E;switch(h){case 65504:case 65505:case 65506:case 65507:case 65508:case 65509:case 65510:case 65511:case 65512:case 65513:case 65514:case 65515:case 65516:case 65517:case 65518:case 65519:case 65534:const{appData:u,newOffset:d}=readDataBlock(e,s);s=d;65504===h&&74===u[0]&&70===u[1]&&73===u[2]&&70===u[3]&&0===u[4]&&(r={version:{major:u[5],minor:u[6]},densityUnits:u[7],xDensity:u[8]<<8|u[9],yDensity:u[10]<<8|u[11],thumbWidth:u[12],thumbHeight:u[13],thumbData:u.subarray(14,14+3*u[12]*u[13])});65518===h&&65===u[0]&&100===u[1]&&111===u[2]&&98===u[3]&&101===u[4]&&(n={version:u[5]<<8|u[6],flags0:u[7]<<8|u[8],flags1:u[9]<<8|u[10],transformCode:u[11]});break;case 65499:const f=readUint16(e,s);s+=2;const p=f+s-2;let m;for(;s>4){if(t>>4!=1)throw new JpegError("DQT - invalid table spec");for(Q=0;Q<64;Q++){m=Wt[Q];i[m]=readUint16(e,s);s+=2}}else for(Q=0;Q<64;Q++){m=Wt[Q];i[m]=e[s++]}o[15&t]=i}break;case 65472:case 65473:case 65474:if(i)throw new JpegError("Only single frame JPEGs supported");s+=2;i={};i.extended=65473===h;i.progressive=65474===h;i.precision=e[s++];const y=readUint16(e,s);s+=2;i.scanLines=t||y;i.samplesPerLine=readUint16(e,s);s+=2;i.components=[];i.componentIds={};const w=e[s++];let D=0,b=0;for(l=0;l>4,r=15&e[s+1];D>4?c:C)[15&t]=buildHuffmanTable(i,r)}break;case 65501:s+=2;a=readUint16(e,s);s+=2;break;case 65498:const S=1==++g&&!t;s+=2;const k=e[s++],R=[];for(l=0;l>4];r.huffmanTableAC=c[15&n];R.push(r)}const N=e[s++],G=e[s++],M=e[s++];try{s+=decodeScan(e,s,i,R,a,N,G,M>>4,15&M,S)}catch(t){if(t instanceof DNLMarkerError){warn(`${t.message} -- attempting to re-parse the JPEG image.`);return this.parse(e,{dnlScanLines:t.scanLines})}if(t instanceof EOIMarkerError){warn(`${t.message} -- ignoring the rest of the image data.`);break A}throw t}break;case 65500:s+=4;break;case 65535:255!==e[s]&&s--;break;default:const U=findNextFileMarker(e,s-2,s-3);if(U?.invalid){warn("JpegImage.parse - unexpected data, current marker is: "+U.invalid);s=U.offset;break}if(!U||s>=e.length-1){warn("JpegImage.parse - reached the end of the image data without finding an EOI marker (0xFFD9).");break A}throw new JpegError("JpegImage.parse - unknown marker: "+h.toString(16))}h=readUint16(e,s);s+=2}if(!i)throw new JpegError("JpegImage.parse - no frame data found.");this.width=i.samplesPerLine;this.height=i.scanLines;this.jfif=r;this.adobe=n;this.components=[];for(const e of i.components){const t=o[e.quantizationId];t&&(e.quantizationTable=t);this.components.push({index:e.index,output:buildComponentData(0,e),scaleX:e.h/i.maxH,scaleY:e.v/i.maxV,blocksPerLine:e.blocksPerLine,blocksPerColumn:e.blocksPerColumn})}this.numComponents=this.components.length}_getLinearizedBlockData(e,t,i=!1){const a=this.width/e,s=this.height/t;let r,n,g,o,c,C,h,l,Q,E,u,d=0;const f=this.components.length,p=e*t*f,m=new Uint8ClampedArray(p),y=new Uint32Array(e),w=4294967288;let D;for(h=0;h>8)+b[Q+1];return m}get _isColorConversionNeeded(){return this.adobe?!!this.adobe.transformCode:3===this.numComponents?0!==this._colorTransform&&(82!==this.components[0].index||71!==this.components[1].index||66!==this.components[2].index):1===this._colorTransform}_convertYccToRgb(e){let t,i,a;for(let s=0,r=e.length;s4)throw new JpegError("Unsupported color mode");const r=this._getLinearizedBlockData(e,t,s);if(1===this.numComponents&&(i||a)){const e=r.length*(i?4:3),t=new Uint8ClampedArray(e);let a=0;if(i)!function grayToRGBA(e,t){if(FeatureTest.isLittleEndian)for(let i=0,a=e.length;i0&&(e=e.subarray(t));break}return e}decodeImage(e){if(this.eof)return this.buffer;e=this.#w(e||this.bytes);const t=new JpegImage(this.jpegOptions);t.parse(e);const i=t.getData({width:this.drawWidth,height:this.drawHeight,forceRGBA:this.forceRGBA,forceRGB:this.forceRGB,isSourcePDF:!0});this.buffer=i;this.bufferLength=i.length;this.eof=!0;return this.buffer}get canAsyncDecodeImageFromBuffer(){return this.stream.isAsync}async getTransferableImage(){if(!await JpegStream.canUseImageDecoder)return null;const e=this.jpegOptions;if(e.decodeTransform)return null;let t;try{const i=this.canAsyncDecodeImageFromBuffer&&await this.stream.asyncGetBytes()||this.bytes;if(!i)return null;const a=this.#w(i);if(!JpegImage.canUseImageDecoder(a,e.colorTransform))return null;t=new ImageDecoder({data:a,type:"image/jpeg",preferAnimation:!1});return(await t.decode()).image}catch(e){warn(`getTransferableImage - failed: "${e}".`);return null}finally{t?.close()}}}var ei,ti=(ei="undefined"!=typeof document?document.currentScript?.src:void 0,function(e={}){var t,i,a=e;new Promise(((e,a)=>{t=e;i=a}));a.decode=function(e,{numComponents:t=4,isIndexedColormap:i=!1,smaskInData:s=!1}){const r=e.length,n=a._malloc(r);a.HEAPU8.set(e,n);const g=a._jp2_decode(n,r,t>0?t:0,!!i,!!s);a._free(n);if(g){const{errorMessages:e}=a;if(e){delete a.errorMessages;return e}return"Unknown error"}const{imageData:o}=a;a.imageData=null;return o};var s=Object.assign({},a),r="./this.program",quit_=(e,t)=>{throw t},n="";"undefined"!=typeof document&&document.currentScript&&(n=document.currentScript.src);ei&&(n=ei);n=n.startsWith("blob:")?"":n.substr(0,n.replace(/[?#].*/,"").lastIndexOf("/")+1);var g=a.print||console.log.bind(console),o=a.printErr||console.error.bind(console);Object.assign(a,s);s=null;a.arguments&&a.arguments;a.thisProgram&&(r=a.thisProgram);var c,C=a.wasmBinary;function tryParseAsDataURI(e){if(isDataURI(e))return function intArrayFromBase64(e){for(var t=atob(e),i=new Uint8Array(t.length),a=0;ae.startsWith(b);function instantiateSync(e,t){var i,a=function getBinarySync(e){if(e==d&&C)return new Uint8Array(C);var t=tryParseAsDataURI(e);if(t)return t;throw'sync fetching of the wasm failed: you can preload it to Module["wasmBinary"] manually, or emcc.py will do that for you when generating HTML (but not JS)'}(e);i=new WebAssembly.Module(a);return[new WebAssembly.Instance(i,t),i]}class ExitStatus{name="ExitStatus";constructor(e){this.message=`Program terminated with exit(${e})`;this.status=e}}var F,callRuntimeCallbacks=e=>{for(;e.length>0;)e.shift()(a)},S=a.noExitRuntime||!0,k=0,R={},handleException=e=>{if(e instanceof ExitStatus||"unwind"==e)return h;quit_(0,e)},keepRuntimeAlive=()=>S||k>0,_proc_exit=e=>{h=e;if(!keepRuntimeAlive()){a.onExit?.(e);u=!0}quit_(0,new ExitStatus(e))},_exit=(e,t)=>{h=e;_proc_exit(e)},callUserCallback=e=>{if(!u)try{e();(()=>{if(!keepRuntimeAlive())try{_exit(h)}catch(e){handleException(e)}})()}catch(e){handleException(e)}},growMemory=e=>{var t=(e-c.buffer.byteLength+65535)/65536|0;try{c.grow(t);updateMemoryViews();return 1}catch(e){}},N={},getEnvStrings=()=>{if(!getEnvStrings.strings){var e={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:r||"./this.program"};for(var t in N)void 0===N[t]?delete e[t]:e[t]=N[t];var i=[];for(var t in e)i.push(`${t}=${e[t]}`);getEnvStrings.strings=i}return getEnvStrings.strings},G=[null,[],[]],M="undefined"!=typeof TextDecoder?new TextDecoder:void 0,UTF8ArrayToString=(e,t=0,i=NaN)=>{for(var a=t+i,s=t;e[s]&&!(s>=a);)++s;if(s-t>16&&e.buffer&&M)return M.decode(e.subarray(t,s));for(var r="";t>10,56320|1023&c)}}else r+=String.fromCharCode((31&n)<<6|g)}else r+=String.fromCharCode(n)}return r},printChar=(e,t)=>{var i=G[e];if(0===t||10===t){(1===e?g:o)(UTF8ArrayToString(i));i.length=0}else i.push(t)},UTF8ToString=(e,t)=>e?UTF8ArrayToString(Q,e,t):"",U={m:()=>function abort(e){a.onAbort?.(e);o(e="Aborted("+e+")");u=!0;e+=". Build with -sASSERTIONS for more info.";var t=new WebAssembly.RuntimeError(e);i(t);throw t}(""),c:(e,t,i)=>Q.copyWithin(e,t,t+i),l:()=>{S=!1;k=0},n:(e,t)=>{if(R[e]){clearTimeout(R[e].id);delete R[e]}if(!t)return 0;var i=setTimeout((()=>{delete R[e];callUserCallback((()=>L(e,performance.now())))}),t);R[e]={id:i,timeout_ms:t};return 0},g:function _copy_pixels_1(e,t){e>>=2;const i=a.imageData=new Uint8ClampedArray(t),s=a.HEAP32.subarray(e,e+t);i.set(s)},f:function _copy_pixels_3(e,t,i,s){e>>=2;t>>=2;i>>=2;const r=a.imageData=new Uint8ClampedArray(3*s),n=a.HEAP32.subarray(e,e+s),g=a.HEAP32.subarray(t,t+s),o=a.HEAP32.subarray(i,i+s);for(let e=0;e>=2;t>>=2;i>>=2;s>>=2;const n=a.imageData=new Uint8ClampedArray(4*r),g=a.HEAP32.subarray(e,e+r),o=a.HEAP32.subarray(t,t+r),c=a.HEAP32.subarray(i,i+r),C=a.HEAP32.subarray(s,s+r);for(let e=0;e{var t,i,a=Q.length,s=2147483648;if((e>>>=0)>s)return!1;for(var r=1;r<=4;r*=2){var n=a*(1+.2/r);n=Math.min(n,e+100663296);var g=Math.min(s,(t=Math.max(e,n),i=65536,Math.ceil(t/i)*i));if(growMemory(g))return!0}return!1},p:(e,t)=>{var i=0;getEnvStrings().forEach(((a,s)=>{var r=t+i;E[e+4*s>>2]=r;((e,t)=>{for(var i=0;i{var i=getEnvStrings();E[e>>2]=i.length;var a=0;i.forEach((e=>a+=e.length+1));E[t>>2]=a;return 0},r:e=>52,j:function _fd_seek(e,t,i,a,s){return 70},b:(e,t,i,a)=>{for(var s=0,r=0;r>2],g=E[t+4>>2];t+=8;for(var o=0;o>2]=s;return 0},s:function _gray_to_rgba(e,t){e>>=2;const i=a.imageData=new Uint8ClampedArray(4*t),s=a.HEAP32.subarray(e,e+t);for(let e=0;e>=2;t>>=2;const s=a.imageData=new Uint8ClampedArray(4*i),r=a.HEAP32.subarray(e,e+i),n=a.HEAP32.subarray(t,t+i);for(let e=0;e>=2;t>>=2;i>>=2;const r=a.imageData=new Uint8ClampedArray(4*s),n=a.HEAP32.subarray(e,e+s),g=a.HEAP32.subarray(t,t+s),o=a.HEAP32.subarray(i,i+s);for(let e=0;e0)){!function preRun(){if(a.preRun){"function"==typeof a.preRun&&(a.preRun=[a.preRun]);for(;a.preRun.length;)e=a.preRun.shift(),f.unshift(e)}var e;callRuntimeCallbacks(f)}();if(!(y>0))if(a.setStatus){a.setStatus("Running...");setTimeout((()=>{setTimeout((()=>a.setStatus("")),1);doRun()}),1)}else doRun()}function doRun(){if(!F){F=!0;a.calledRun=!0;if(!u){!function initRuntime(){callRuntimeCallbacks(p)}();t(a);a.onRuntimeInitialized?.();!function postRun(){if(a.postRun){"function"==typeof a.postRun&&(a.postRun=[a.postRun]);for(;a.postRun.length;)e=a.postRun.shift(),m.unshift(e)}var e;callRuntimeCallbacks(m)}()}}}}if(a.preInit){"function"==typeof a.preInit&&(a.preInit=[a.preInit]);for(;a.preInit.length>0;)a.preInit.pop()()}run();return a});const ii=ti;class JpxError extends ot{constructor(e){super(e,"JpxError")}}class JpxImage{static#D=null;static decode(e,t){t||={};this.#D||=ii({warn});const i=this.#D.decode(e,t);if("string"==typeof i)throw new JpxError(i);return i}static cleanup(){this.#D=null}static parseImageProperties(e){let t=e.getByte();for(;t>=0;){const i=t;t=e.getByte();if(65361===(i<<8|t)){e.skip(4);const t=e.getInt32()>>>0,i=e.getInt32()>>>0,a=e.getInt32()>>>0,s=e.getInt32()>>>0;e.skip(16);return{width:t-a,height:i-s,bitsPerComponent:8,componentsCount:e.getUint16()}}}throw new JpxError("No size marker found in JPX stream")}}class JpxStream extends DecodeStream{constructor(e,t,i){super(t);this.stream=e;this.dict=e.dict;this.maybeLength=t;this.params=i}get bytes(){return shadow(this,"bytes",this.stream.getBytes(this.maybeLength))}ensureBuffer(e){}readBlock(e){this.decodeImage(null,e)}decodeImage(e,t){if(this.eof)return this.buffer;e||=this.bytes;this.buffer=JpxImage.decode(e,t);this.bufferLength=this.buffer.length;this.eof=!0;return this.buffer}get canAsyncDecodeImageFromBuffer(){return this.stream.isAsync}}class LZWStream extends DecodeStream{constructor(e,t,i){super(t);this.str=e;this.dict=e.dict;this.cachedData=0;this.bitsCached=0;const a=4096,s={earlyChange:i,codeLength:9,nextCode:258,dictionaryValues:new Uint8Array(a),dictionaryLengths:new Uint16Array(a),dictionaryPrevCodes:new Uint16Array(a),currentSequence:new Uint8Array(a),currentSequenceLength:0};for(let e=0;e<256;++e){s.dictionaryValues[e]=e;s.dictionaryLengths[e]=1}this.lzwState=s}readBits(e){let t=this.bitsCached,i=this.cachedData;for(;t>>t&(1<0;if(e<256){l[0]=e;Q=1}else{if(!(e>=258)){if(256===e){C=9;n=258;Q=0;continue}this.eof=!0;delete this.lzwState;break}if(e=0;t--){l[t]=g[i];i=c[i]}}else l[Q++]=l[0]}if(s){c[n]=h;o[n]=o[h]+1;g[n]=l[0];n++;C=n+r&n+r-1?C:0|Math.min(Math.log(n+r)/.6931471805599453+1,12)}h=e;E+=Q;if(a15))throw new FormatError(`Unsupported predictor: ${a}`);this.readBlock=2===a?this.readBlockTiff:this.readBlockPng;this.str=e;this.dict=e.dict;const s=this.colors=i.get("Colors")||1,r=this.bits=i.get("BPC","BitsPerComponent")||8,n=this.columns=i.get("Columns")||1;this.pixBytes=s*r+7>>3;this.rowBytes=n*s*r+7>>3;return this}readBlockTiff(){const e=this.rowBytes,t=this.bufferLength,i=this.ensureBuffer(t+e),a=this.bits,s=this.colors,r=this.str.getBytes(e);this.eof=!r.length;if(this.eof)return;let n,g=0,o=0,c=0,C=0,h=t;if(1===a&&1===s)for(n=0;n>1;e^=e>>2;e^=e>>4;g=(1&e)<<7;i[h++]=e}else if(8===a){for(n=0;n>8&255;i[h++]=255&e}}else{const e=new Uint8Array(s+1),h=(1<>c-a)&h;c-=a;o=o<=8){i[Q++]=o>>C-8&255;C-=8}}C>0&&(i[Q++]=(o<<8-C)+(g&(1<<8-C)-1))}this.bufferLength+=e}readBlockPng(){const e=this.rowBytes,t=this.pixBytes,i=this.str.getByte(),a=this.str.getBytes(e);this.eof=!a.length;if(this.eof)return;const s=this.bufferLength,r=this.ensureBuffer(s+e);let n=r.subarray(s-e,s);0===n.length&&(n=new Uint8Array(e));let g,o,c,C=s;switch(i){case 0:for(g=0;g>1)+a[g];for(;g>1)+a[g]&255;C++}break;case 4:for(g=0;g0){const e=this.str.getBytes(a);t.set(e,i);i+=a}}else{a=257-a;const s=e[1];t=this.ensureBuffer(i+a+1);for(let e=0;e>")&&this.buf1!==Bt;){if(!(this.buf1 instanceof Name)){info("Malformed dictionary: key must be a name object");this.shift();continue}const t=this.buf1.name;this.shift();if(this.buf1===Bt)break;a.set(t,this.getObj(e))}if(this.buf1===Bt){if(this.recoveryMode)return a;throw new ParserEOFException("End of file inside dictionary.")}if(isCmd(this.buf2,"stream"))return this.allowStreams?this.makeStream(a,e):a;this.shift();return a;default:return t}if(Number.isInteger(t)){if(Number.isInteger(this.buf1)&&isCmd(this.buf2,"R")){const e=Ref.get(t,this.buf1);this.shift();this.shift();return e}return t}return"string"==typeof t&&e?e.decryptString(t):t}findDefaultInlineStreamEnd(e){const{knownCommands:t}=this.lexer,i=e.pos;let a,s,r=0;for(;-1!==(a=e.getByte());)if(0===r)r=69===a?1:0;else if(1===r)r=73===a?2:0;else if(32===a||10===a||13===a){s=e.pos;const i=e.peekBytes(15),n=i.length;if(0===n)break;for(let e=0;e127))){r=0;break}}if(2!==r)continue;if(!t){warn("findDefaultInlineStreamEnd - `lexer.knownCommands` is undefined.");continue}const g=new Lexer(new Stream(i.slice()),t);g._hexStringWarn=()=>{};let o=0;for(;;){const e=g.getObj();if(e===Bt){r=0;break}if(e instanceof Cmd){const i=t[e.cmd];if(!i){r=0;break}if(i.variableArgs?o<=i.numArgs:o===i.numArgs)break;o=0}else o++}if(2===r)break}else r=0;if(-1===a){warn("findDefaultInlineStreamEnd: Reached the end of the stream without finding a valid EI marker");if(s){warn('... trying to recover by using the last "EI" occurrence.');e.skip(-(e.pos-s))}}let n=4;e.skip(-n);a=e.peekByte();e.skip(n);isWhiteSpace(a)||n--;return e.pos-n-i}findDCTDecodeInlineStreamEnd(e){const t=e.pos;let i,a,s=!1;for(;-1!==(i=e.getByte());)if(255===i){switch(e.getByte()){case 0:break;case 255:e.skip(-1);break;case 217:s=!0;break;case 192:case 193:case 194:case 195:case 197:case 198:case 199:case 201:case 202:case 203:case 205:case 206:case 207:case 196:case 204:case 218:case 219:case 220:case 221:case 222:case 223:case 224:case 225:case 226:case 227:case 228:case 229:case 230:case 231:case 232:case 233:case 234:case 235:case 236:case 237:case 238:case 239:case 254:a=e.getUint16();a>2?e.skip(a-2):e.skip(-2)}if(s)break}const r=e.pos-t;if(-1===i){warn("Inline DCTDecode image stream: EOI marker not found, searching for /EI/ instead.");e.skip(-r);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return r}findASCII85DecodeInlineStreamEnd(e){const t=e.pos;let i;for(;-1!==(i=e.getByte());)if(126===i){const t=e.pos;i=e.peekByte();for(;isWhiteSpace(i);){e.skip();i=e.peekByte()}if(62===i){e.skip();break}if(e.pos>t){const t=e.peekBytes(2);if(69===t[0]&&73===t[1])break}}const a=e.pos-t;if(-1===i){warn("Inline ASCII85Decode image stream: EOD marker not found, searching for /EI/ instead.");e.skip(-a);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return a}findASCIIHexDecodeInlineStreamEnd(e){const t=e.pos;let i;for(;-1!==(i=e.getByte())&&62!==i;);const a=e.pos-t;if(-1===i){warn("Inline ASCIIHexDecode image stream: EOD marker not found, searching for /EI/ instead.");e.skip(-a);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return a}inlineStreamSkipEI(e){let t,i=0;for(;-1!==(t=e.getByte());)if(0===i)i=69===t?1:0;else if(1===i)i=73===t?2:0;else if(2===i)break}makeInlineImage(e){const t=this.lexer,i=t.stream,a=Object.create(null);let s;for(;!isCmd(this.buf1,"ID")&&this.buf1!==Bt;){if(!(this.buf1 instanceof Name))throw new FormatError("Dictionary key must be a name object");const t=this.buf1.name;this.shift();if(this.buf1===Bt)break;a[t]=this.getObj(e)}-1!==t.beginInlineImagePos&&(s=i.pos-t.beginInlineImagePos);const r=this.xref.fetchIfRef(a.F||a.Filter);let n;if(r instanceof Name)n=r.name;else if(Array.isArray(r)){const e=this.xref.fetchIfRef(r[0]);e instanceof Name&&(n=e.name)}const g=i.pos;let o,c;switch(n){case"DCT":case"DCTDecode":o=this.findDCTDecodeInlineStreamEnd(i);break;case"A85":case"ASCII85Decode":o=this.findASCII85DecodeInlineStreamEnd(i);break;case"AHx":case"ASCIIHexDecode":o=this.findASCIIHexDecodeInlineStreamEnd(i);break;default:o=this.findDefaultInlineStreamEnd(i)}if(o<1e3&&s>0){const e=i.pos;i.pos=t.beginInlineImagePos;c=function getInlineImageCacheKey(e){const t=[],i=e.length;let a=0;for(;a=a){let a=!1;for(const e of s){const t=e.length;let s=0;for(;s=r){a=!0;break}if(s>=t){if(isWhiteSpace(n[o+g+s])){info(`Found "${bytesToString([...i,...e])}" when searching for endstream command.`);a=!0}break}}if(a){t.pos+=o;return t.pos-e}}o++}t.pos+=g}return-1}makeStream(e,t){const i=this.lexer;let a=i.stream;i.skipToNextLine();const s=a.pos-1;let r=e.get("Length");if(!Number.isInteger(r)){info(`Bad length "${r&&r.toString()}" in stream.`);r=0}a.pos=s+r;i.nextChar();if(this.tryShift()&&isCmd(this.buf2,"endstream"))this.shift();else{r=this.#b(s);if(r<0)throw new FormatError("Missing endstream command.");i.nextChar();this.shift();this.shift()}this.shift();a=a.makeSubStream(s,r,e);t&&(a=t.createStream(a,r));a=this.filter(a,e,r);a.dict=e;return a}filter(e,t,i){let a=t.get("F","Filter"),s=t.get("DP","DecodeParms");if(a instanceof Name){Array.isArray(s)&&warn("/DecodeParms should not be an Array, when /Filter is a Name.");return this.makeFilter(e,a.name,i,s)}let r=i;if(Array.isArray(a)){const t=a,i=s;for(let n=0,g=t.length;n=48&&e<=57?15&e:e>=65&&e<=70||e>=97&&e<=102?9+(15&e):-1}class Lexer{constructor(e,t=null){this.stream=e;this.nextChar();this.strBuf=[];this.knownCommands=t;this._hexStringNumWarn=0;this.beginInlineImagePos=-1}nextChar(){return this.currentChar=this.stream.getByte()}peekChar(){return this.stream.peekByte()}getNumber(){let e=this.currentChar,t=!1,i=0,a=1;if(45===e){a=-1;e=this.nextChar();45===e&&(e=this.nextChar())}else 43===e&&(e=this.nextChar());if(10===e||13===e)do{e=this.nextChar()}while(10===e||13===e);if(46===e){i=10;e=this.nextChar()}if(e<48||e>57){const t=`Invalid number: ${String.fromCharCode(e)} (charCode ${e})`;if(isWhiteSpace(e)||-1===e){info(`Lexer.getNumber - "${t}".`);return 0}throw new FormatError(t)}let s=e-48,r=0,n=1;for(;(e=this.nextChar())>=0;)if(e>=48&&e<=57){const a=e-48;if(t)r=10*r+a;else{0!==i&&(i*=10);s=10*s+a}}else if(46===e){if(0!==i)break;i=1}else if(45===e)warn("Badly formatted number: minus sign in the middle");else{if(69!==e&&101!==e)break;e=this.peekChar();if(43===e||45===e){n=45===e?-1:1;this.nextChar()}else if(e<48||e>57)break;t=!0}0!==i&&(s/=i);t&&(s*=10**(n*r));return a*s}getString(){let e=1,t=!1;const i=this.strBuf;i.length=0;let a=this.nextChar();for(;;){let s=!1;switch(0|a){case-1:warn("Unterminated string");t=!0;break;case 40:++e;i.push("(");break;case 41:if(0==--e){this.nextChar();t=!0}else i.push(")");break;case 92:a=this.nextChar();switch(a){case-1:warn("Unterminated string");t=!0;break;case 110:i.push("\n");break;case 114:i.push("\r");break;case 116:i.push("\t");break;case 98:i.push("\b");break;case 102:i.push("\f");break;case 92:case 40:case 41:i.push(String.fromCharCode(a));break;case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:let e=15&a;a=this.nextChar();s=!0;if(a>=48&&a<=55){e=(e<<3)+(15&a);a=this.nextChar();if(a>=48&&a<=55){s=!1;e=(e<<3)+(15&a)}}i.push(String.fromCharCode(e));break;case 13:10===this.peekChar()&&this.nextChar();break;case 10:break;default:i.push(String.fromCharCode(a))}break;default:i.push(String.fromCharCode(a))}if(t)break;s||(a=this.nextChar())}return i.join("")}getName(){let e,t;const i=this.strBuf;i.length=0;for(;(e=this.nextChar())>=0&&!ai[e];)if(35===e){e=this.nextChar();if(ai[e]){warn("Lexer_getName: NUMBER SIGN (#) should be followed by a hexadecimal number.");i.push("#");break}const a=toHexDigit(e);if(-1!==a){t=e;e=this.nextChar();const s=toHexDigit(e);if(-1===s){warn(`Lexer_getName: Illegal digit (${String.fromCharCode(e)}) in hexadecimal number.`);i.push("#",String.fromCharCode(t));if(ai[e])break;i.push(String.fromCharCode(e));continue}i.push(String.fromCharCode(a<<4|s))}else i.push("#",String.fromCharCode(e))}else i.push(String.fromCharCode(e));i.length>127&&warn(`Name token is longer than allowed by the spec: ${i.length}`);return Name.get(i.join(""))}_hexStringWarn(e){5!=this._hexStringNumWarn++?this._hexStringNumWarn>5||warn(`getHexString - ignoring invalid character: ${e}`):warn("getHexString - ignoring additional invalid characters.")}getHexString(){const e=this.strBuf;e.length=0;let t=this.currentChar,i=-1,a=-1;this._hexStringNumWarn=0;for(;;){if(t<0){warn("Unterminated hex string");break}if(62===t){this.nextChar();break}if(1!==ai[t]){a=toHexDigit(t);if(-1===a)this._hexStringWarn(t);else if(-1===i)i=a;else{e.push(String.fromCharCode(i<<4|a));i=-1}t=this.nextChar()}else t=this.nextChar()}-1!==i&&e.push(String.fromCharCode(i<<4));return e.join("")}getObj(){let e=!1,t=this.currentChar;for(;;){if(t<0)return Bt;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(1!==ai[t])break;t=this.nextChar()}switch(0|t){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:case 43:case 45:case 46:return this.getNumber();case 40:return this.getString();case 47:return this.getName();case 91:this.nextChar();return Cmd.get("[");case 93:this.nextChar();return Cmd.get("]");case 60:t=this.nextChar();if(60===t){this.nextChar();return Cmd.get("<<")}return this.getHexString();case 62:t=this.nextChar();if(62===t){this.nextChar();return Cmd.get(">>")}return Cmd.get(">");case 123:this.nextChar();return Cmd.get("{");case 125:this.nextChar();return Cmd.get("}");case 41:this.nextChar();throw new FormatError(`Illegal character: ${t}`)}let i=String.fromCharCode(t);if(t<32||t>127){const e=this.peekChar();if(e>=32&&e<=127){this.nextChar();return Cmd.get(i)}}const a=this.knownCommands;let s=void 0!==a?.[i];for(;(t=this.nextChar())>=0&&!ai[t];){const e=i+String.fromCharCode(t);if(s&&void 0===a[e])break;if(128===i.length)throw new FormatError(`Command token too long: ${i.length}`);i=e;s=void 0!==a?.[i]}if("true"===i)return!0;if("false"===i)return!1;if("null"===i)return null;"BI"===i&&(this.beginInlineImagePos=this.stream.pos);return Cmd.get(i)}skipToNextLine(){let e=this.currentChar;for(;e>=0;){if(13===e){e=this.nextChar();10===e&&this.nextChar();break}if(10===e){this.nextChar();break}e=this.nextChar()}}}class Linearization{static create(e){function getInt(e,t,i=!1){const a=e.get(t);if(Number.isInteger(a)&&(i?a>=0:a>0))return a;throw new Error(`The "${t}" parameter in the linearization dictionary is invalid.`)}const t=new Parser({lexer:new Lexer(e),xref:null}),i=t.getObj(),a=t.getObj(),s=t.getObj(),r=t.getObj();let n,g;if(!(Number.isInteger(i)&&Number.isInteger(a)&&isCmd(s,"obj")&&r instanceof Dict&&"number"==typeof(n=r.get("Linearized"))&&n>0))return null;if((g=getInt(r,"L"))!==e.length)throw new Error('The "L" parameter in the linearization dictionary does not equal the stream length.');return{length:g,hints:function getHints(e){const t=e.get("H");let i;if(Array.isArray(t)&&(2===(i=t.length)||4===i)){for(let e=0;e0))throw new Error(`Hint (${e}) in the linearization dictionary is invalid.`)}return t}throw new Error("Hint array in the linearization dictionary is invalid.")}(r),objectNumberFirst:getInt(r,"O"),endFirst:getInt(r,"E"),numPages:getInt(r,"N"),mainXRefEntriesOffset:getInt(r,"T"),pageFirst:r.has("P")?getInt(r,"P",!0):0}}}const si=["Adobe-GB1-UCS2","Adobe-CNS1-UCS2","Adobe-Japan1-UCS2","Adobe-Korea1-UCS2","78-EUC-H","78-EUC-V","78-H","78-RKSJ-H","78-RKSJ-V","78-V","78ms-RKSJ-H","78ms-RKSJ-V","83pv-RKSJ-H","90ms-RKSJ-H","90ms-RKSJ-V","90msp-RKSJ-H","90msp-RKSJ-V","90pv-RKSJ-H","90pv-RKSJ-V","Add-H","Add-RKSJ-H","Add-RKSJ-V","Add-V","Adobe-CNS1-0","Adobe-CNS1-1","Adobe-CNS1-2","Adobe-CNS1-3","Adobe-CNS1-4","Adobe-CNS1-5","Adobe-CNS1-6","Adobe-GB1-0","Adobe-GB1-1","Adobe-GB1-2","Adobe-GB1-3","Adobe-GB1-4","Adobe-GB1-5","Adobe-Japan1-0","Adobe-Japan1-1","Adobe-Japan1-2","Adobe-Japan1-3","Adobe-Japan1-4","Adobe-Japan1-5","Adobe-Japan1-6","Adobe-Korea1-0","Adobe-Korea1-1","Adobe-Korea1-2","B5-H","B5-V","B5pc-H","B5pc-V","CNS-EUC-H","CNS-EUC-V","CNS1-H","CNS1-V","CNS2-H","CNS2-V","ETHK-B5-H","ETHK-B5-V","ETen-B5-H","ETen-B5-V","ETenms-B5-H","ETenms-B5-V","EUC-H","EUC-V","Ext-H","Ext-RKSJ-H","Ext-RKSJ-V","Ext-V","GB-EUC-H","GB-EUC-V","GB-H","GB-V","GBK-EUC-H","GBK-EUC-V","GBK2K-H","GBK2K-V","GBKp-EUC-H","GBKp-EUC-V","GBT-EUC-H","GBT-EUC-V","GBT-H","GBT-V","GBTpc-EUC-H","GBTpc-EUC-V","GBpc-EUC-H","GBpc-EUC-V","H","HKdla-B5-H","HKdla-B5-V","HKdlb-B5-H","HKdlb-B5-V","HKgccs-B5-H","HKgccs-B5-V","HKm314-B5-H","HKm314-B5-V","HKm471-B5-H","HKm471-B5-V","HKscs-B5-H","HKscs-B5-V","Hankaku","Hiragana","KSC-EUC-H","KSC-EUC-V","KSC-H","KSC-Johab-H","KSC-Johab-V","KSC-V","KSCms-UHC-H","KSCms-UHC-HW-H","KSCms-UHC-HW-V","KSCms-UHC-V","KSCpc-EUC-H","KSCpc-EUC-V","Katakana","NWP-H","NWP-V","RKSJ-H","RKSJ-V","Roman","UniCNS-UCS2-H","UniCNS-UCS2-V","UniCNS-UTF16-H","UniCNS-UTF16-V","UniCNS-UTF32-H","UniCNS-UTF32-V","UniCNS-UTF8-H","UniCNS-UTF8-V","UniGB-UCS2-H","UniGB-UCS2-V","UniGB-UTF16-H","UniGB-UTF16-V","UniGB-UTF32-H","UniGB-UTF32-V","UniGB-UTF8-H","UniGB-UTF8-V","UniJIS-UCS2-H","UniJIS-UCS2-HW-H","UniJIS-UCS2-HW-V","UniJIS-UCS2-V","UniJIS-UTF16-H","UniJIS-UTF16-V","UniJIS-UTF32-H","UniJIS-UTF32-V","UniJIS-UTF8-H","UniJIS-UTF8-V","UniJIS2004-UTF16-H","UniJIS2004-UTF16-V","UniJIS2004-UTF32-H","UniJIS2004-UTF32-V","UniJIS2004-UTF8-H","UniJIS2004-UTF8-V","UniJISPro-UCS2-HW-V","UniJISPro-UCS2-V","UniJISPro-UTF8-V","UniJISX0213-UTF32-H","UniJISX0213-UTF32-V","UniJISX02132004-UTF32-H","UniJISX02132004-UTF32-V","UniKS-UCS2-H","UniKS-UCS2-V","UniKS-UTF16-H","UniKS-UTF16-V","UniKS-UTF32-H","UniKS-UTF32-V","UniKS-UTF8-H","UniKS-UTF8-V","V","WP-Symbol"],ri=2**24-1;class CMap{constructor(e=!1){this.codespaceRanges=[[],[],[],[]];this.numCodespaceRanges=0;this._map=[];this.name="";this.vertical=!1;this.useCMap=null;this.builtInCMap=e}addCodespaceRange(e,t,i){this.codespaceRanges[e-1].push(t,i);this.numCodespaceRanges++}mapCidRange(e,t,i){if(t-e>ri)throw new Error("mapCidRange - ignoring data above MAX_MAP_RANGE.");for(;e<=t;)this._map[e++]=i++}mapBfRange(e,t,i){if(t-e>ri)throw new Error("mapBfRange - ignoring data above MAX_MAP_RANGE.");const a=i.length-1;for(;e<=t;){this._map[e++]=i;const t=i.charCodeAt(a)+1;t>255?i=i.substring(0,a-1)+String.fromCharCode(i.charCodeAt(a-1)+1)+"\0":i=i.substring(0,a)+String.fromCharCode(t)}}mapBfRangeToArray(e,t,i){if(t-e>ri)throw new Error("mapBfRangeToArray - ignoring data above MAX_MAP_RANGE.");const a=i.length;let s=0;for(;e<=t&&s>>0;const n=s[r];for(let e=0,t=n.length;e=t&&a<=s){i.charcode=a;i.length=r+1;return}}}i.charcode=0;i.length=1}getCharCodeLength(e){const t=this.codespaceRanges;for(let i=0,a=t.length;i=s&&e<=r)return i+1}}return 1}get length(){return this._map.length}get isIdentityCMap(){if("Identity-H"!==this.name&&"Identity-V"!==this.name)return!1;if(65536!==this._map.length)return!1;for(let e=0;e<65536;e++)if(this._map[e]!==e)return!1;return!0}}class IdentityCMap extends CMap{constructor(e,t){super();this.vertical=e;this.addCodespaceRange(t,0,65535)}mapCidRange(e,t,i){unreachable("should not call mapCidRange")}mapBfRange(e,t,i){unreachable("should not call mapBfRange")}mapBfRangeToArray(e,t,i){unreachable("should not call mapBfRangeToArray")}mapOne(e,t){unreachable("should not call mapCidOne")}lookup(e){return Number.isInteger(e)&&e<=65535?e:void 0}contains(e){return Number.isInteger(e)&&e<=65535}forEach(e){for(let t=0;t<=65535;t++)e(t,t)}charCodeOf(e){return Number.isInteger(e)&&e<=65535?e:-1}getMap(){const e=new Array(65536);for(let t=0;t<=65535;t++)e[t]=t;return e}get length(){return 65536}get isIdentityCMap(){unreachable("should not access .isIdentityCMap")}}function strToInt(e){let t=0;for(let i=0;i>>0}function expectString(e){if("string"!=typeof e)throw new FormatError("Malformed CMap: expected string.")}function expectInt(e){if(!Number.isInteger(e))throw new FormatError("Malformed CMap: expected int.")}function parseBfChar(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endbfchar"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectString(i);const s=i;e.mapOne(a,s)}}function parseBfRange(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endbfrange"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectString(i);const s=strToInt(i);i=t.getObj();if(Number.isInteger(i)||"string"==typeof i){const t=Number.isInteger(i)?String.fromCharCode(i):i;e.mapBfRange(a,s,t)}else{if(!isCmd(i,"["))break;{i=t.getObj();const r=[];for(;!isCmd(i,"]")&&i!==Bt;){r.push(i);i=t.getObj()}e.mapBfRangeToArray(a,s,r)}}}throw new FormatError("Invalid bf range.")}function parseCidChar(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endcidchar"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectInt(i);const s=i;e.mapOne(a,s)}}function parseCidRange(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endcidrange"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectString(i);const s=strToInt(i);i=t.getObj();expectInt(i);const r=i;e.mapCidRange(a,s,r)}}function parseCodespaceRange(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endcodespacerange"))return;if("string"!=typeof i)break;const a=strToInt(i);i=t.getObj();if("string"!=typeof i)break;const s=strToInt(i);e.addCodespaceRange(i.length,a,s)}throw new FormatError("Invalid codespace range.")}function parseWMode(e,t){const i=t.getObj();Number.isInteger(i)&&(e.vertical=!!i)}function parseCMapName(e,t){const i=t.getObj();i instanceof Name&&(e.name=i.name)}async function parseCMap(e,t,i,a){let s,r;A:for(;;)try{const i=t.getObj();if(i===Bt)break;if(i instanceof Name){"WMode"===i.name?parseWMode(e,t):"CMapName"===i.name&&parseCMapName(e,t);s=i}else if(i instanceof Cmd)switch(i.cmd){case"endcmap":break A;case"usecmap":s instanceof Name&&(r=s.name);break;case"begincodespacerange":parseCodespaceRange(e,t);break;case"beginbfchar":parseBfChar(e,t);break;case"begincidchar":parseCidChar(e,t);break;case"beginbfrange":parseBfRange(e,t);break;case"begincidrange":parseCidRange(e,t)}}catch(e){if(e instanceof MissingDataException)throw e;warn("Invalid cMap data: "+e);continue}!a&&r&&(a=r);return a?extendCMap(e,i,a):e}async function extendCMap(e,t,i){e.useCMap=await createBuiltInCMap(i,t);if(0===e.numCodespaceRanges){const t=e.useCMap.codespaceRanges;for(let i=0;iextendCMap(s,t,e)));const r=new Lexer(new Stream(i));return parseCMap(s,r,t,null)}class CMapFactory{static async create({encoding:e,fetchBuiltInCMap:t,useCMap:i}){if(e instanceof Name)return createBuiltInCMap(e.name,t);if(e instanceof BaseStream){const a=await parseCMap(new CMap,new Lexer(e),t,i);return a.isIdentityCMap?createBuiltInCMap(a.name,t):a}throw new Error("Encoding required.")}}const ni=[".notdef","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","endash","dagger","daggerdbl","periodcentered","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","questiondown","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","ring","cedilla","hungarumlaut","ogonek","caron","emdash","AE","ordfeminine","Lslash","Oslash","OE","ordmasculine","ae","dotlessi","lslash","oslash","oe","germandbls","onesuperior","logicalnot","mu","trademark","Eth","onehalf","plusminus","Thorn","onequarter","divide","brokenbar","degree","thorn","threequarters","twosuperior","registered","minus","eth","multiply","threesuperior","copyright","Aacute","Acircumflex","Adieresis","Agrave","Aring","Atilde","Ccedilla","Eacute","Ecircumflex","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Ntilde","Oacute","Ocircumflex","Odieresis","Ograve","Otilde","Scaron","Uacute","Ucircumflex","Udieresis","Ugrave","Yacute","Ydieresis","Zcaron","aacute","acircumflex","adieresis","agrave","aring","atilde","ccedilla","eacute","ecircumflex","edieresis","egrave","iacute","icircumflex","idieresis","igrave","ntilde","oacute","ocircumflex","odieresis","ograve","otilde","scaron","uacute","ucircumflex","udieresis","ugrave","yacute","ydieresis","zcaron"],gi=[".notdef","space","exclamsmall","Hungarumlautsmall","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","fi","fl","ffi","ffl","parenleftinferior","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","exclamdownsmall","centoldstyle","Lslashsmall","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","Dotaccentsmall","Macronsmall","figuredash","hypheninferior","Ogoneksmall","Ringsmall","Cedillasmall","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],oi=[".notdef","space","dollaroldstyle","dollarsuperior","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","fi","fl","ffi","ffl","parenleftinferior","parenrightinferior","hyphensuperior","colonmonetary","onefitted","rupiah","centoldstyle","figuredash","hypheninferior","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior"],Ii=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","","asuperior","bsuperior","centsuperior","dsuperior","esuperior","","","","isuperior","","","lsuperior","msuperior","nsuperior","osuperior","","","rsuperior","ssuperior","tsuperior","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdownsmall","centoldstyle","Lslashsmall","","","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","","Dotaccentsmall","","","Macronsmall","","","figuredash","hypheninferior","","","Ogoneksmall","Ringsmall","Cedillasmall","","","","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],ci=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","centoldstyle","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","","threequartersemdash","","questionsmall","","","","","Ethsmall","","","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","","","","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hypheninferior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","asuperior","centsuperior","","","","","Aacutesmall","Agravesmall","Acircumflexsmall","Adieresissmall","Atildesmall","Aringsmall","Ccedillasmall","Eacutesmall","Egravesmall","Ecircumflexsmall","Edieresissmall","Iacutesmall","Igravesmall","Icircumflexsmall","Idieresissmall","Ntildesmall","Oacutesmall","Ogravesmall","Ocircumflexsmall","Odieresissmall","Otildesmall","Uacutesmall","Ugravesmall","Ucircumflexsmall","Udieresissmall","","eightsuperior","fourinferior","threeinferior","sixinferior","eightinferior","seveninferior","Scaronsmall","","centinferior","twoinferior","","Dieresissmall","","Caronsmall","osuperior","fiveinferior","","commainferior","periodinferior","Yacutesmall","","dollarinferior","","","Thornsmall","","nineinferior","zeroinferior","Zcaronsmall","AEsmall","Oslashsmall","questiondownsmall","oneinferior","Lslashsmall","","","","","","","Cedillasmall","","","","","","OEsmall","figuredash","hyphensuperior","","","","","exclamdownsmall","","Ydieresissmall","","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","ninesuperior","zerosuperior","","esuperior","rsuperior","tsuperior","","","isuperior","ssuperior","dsuperior","","","","","","lsuperior","Ogoneksmall","Brevesmall","Macronsmall","bsuperior","nsuperior","msuperior","commasuperior","periodsuperior","Dotaccentsmall","Ringsmall","","","",""],Ci=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","space","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron"],hi=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","","endash","dagger","daggerdbl","periodcentered","","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","","questiondown","","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","","ring","cedilla","","hungarumlaut","ogonek","caron","emdash","","","","","","","","","","","","","","","","","AE","","ordfeminine","","","","","Lslash","Oslash","OE","ordmasculine","","","","","","ae","","","","dotlessi","","","lslash","oslash","oe","germandbls","","","",""],li=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","bullet","Euro","bullet","quotesinglbase","florin","quotedblbase","ellipsis","dagger","daggerdbl","circumflex","perthousand","Scaron","guilsinglleft","OE","bullet","Zcaron","bullet","bullet","quoteleft","quoteright","quotedblleft","quotedblright","bullet","endash","emdash","tilde","trademark","scaron","guilsinglright","oe","bullet","zcaron","Ydieresis","space","exclamdown","cent","sterling","currency","yen","brokenbar","section","dieresis","copyright","ordfeminine","guillemotleft","logicalnot","hyphen","registered","macron","degree","plusminus","twosuperior","threesuperior","acute","mu","paragraph","periodcentered","cedilla","onesuperior","ordmasculine","guillemotright","onequarter","onehalf","threequarters","questiondown","Agrave","Aacute","Acircumflex","Atilde","Adieresis","Aring","AE","Ccedilla","Egrave","Eacute","Ecircumflex","Edieresis","Igrave","Iacute","Icircumflex","Idieresis","Eth","Ntilde","Ograve","Oacute","Ocircumflex","Otilde","Odieresis","multiply","Oslash","Ugrave","Uacute","Ucircumflex","Udieresis","Yacute","Thorn","germandbls","agrave","aacute","acircumflex","atilde","adieresis","aring","ae","ccedilla","egrave","eacute","ecircumflex","edieresis","igrave","iacute","icircumflex","idieresis","eth","ntilde","ograve","oacute","ocircumflex","otilde","odieresis","divide","oslash","ugrave","uacute","ucircumflex","udieresis","yacute","thorn","ydieresis"],Bi=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","universal","numbersign","existential","percent","ampersand","suchthat","parenleft","parenright","asteriskmath","plus","comma","minus","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","congruent","Alpha","Beta","Chi","Delta","Epsilon","Phi","Gamma","Eta","Iota","theta1","Kappa","Lambda","Mu","Nu","Omicron","Pi","Theta","Rho","Sigma","Tau","Upsilon","sigma1","Omega","Xi","Psi","Zeta","bracketleft","therefore","bracketright","perpendicular","underscore","radicalex","alpha","beta","chi","delta","epsilon","phi","gamma","eta","iota","phi1","kappa","lambda","mu","nu","omicron","pi","theta","rho","sigma","tau","upsilon","omega1","omega","xi","psi","zeta","braceleft","bar","braceright","similar","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","Euro","Upsilon1","minute","lessequal","fraction","infinity","florin","club","diamond","heart","spade","arrowboth","arrowleft","arrowup","arrowright","arrowdown","degree","plusminus","second","greaterequal","multiply","proportional","partialdiff","bullet","divide","notequal","equivalence","approxequal","ellipsis","arrowvertex","arrowhorizex","carriagereturn","aleph","Ifraktur","Rfraktur","weierstrass","circlemultiply","circleplus","emptyset","intersection","union","propersuperset","reflexsuperset","notsubset","propersubset","reflexsubset","element","notelement","angle","gradient","registerserif","copyrightserif","trademarkserif","product","radical","dotmath","logicalnot","logicaland","logicalor","arrowdblboth","arrowdblleft","arrowdblup","arrowdblright","arrowdbldown","lozenge","angleleft","registersans","copyrightsans","trademarksans","summation","parenlefttp","parenleftex","parenleftbt","bracketlefttp","bracketleftex","bracketleftbt","bracelefttp","braceleftmid","braceleftbt","braceex","","angleright","integral","integraltp","integralex","integralbt","parenrighttp","parenrightex","parenrightbt","bracketrighttp","bracketrightex","bracketrightbt","bracerighttp","bracerightmid","bracerightbt",""],Qi=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","a1","a2","a202","a3","a4","a5","a119","a118","a117","a11","a12","a13","a14","a15","a16","a105","a17","a18","a19","a20","a21","a22","a23","a24","a25","a26","a27","a28","a6","a7","a8","a9","a10","a29","a30","a31","a32","a33","a34","a35","a36","a37","a38","a39","a40","a41","a42","a43","a44","a45","a46","a47","a48","a49","a50","a51","a52","a53","a54","a55","a56","a57","a58","a59","a60","a61","a62","a63","a64","a65","a66","a67","a68","a69","a70","a71","a72","a73","a74","a203","a75","a204","a76","a77","a78","a79","a81","a82","a83","a84","a97","a98","a99","a100","","a89","a90","a93","a94","a91","a92","a205","a85","a206","a86","a87","a88","a95","a96","","","","","","","","","","","","","","","","","","","","a101","a102","a103","a104","a106","a107","a108","a112","a111","a110","a109","a120","a121","a122","a123","a124","a125","a126","a127","a128","a129","a130","a131","a132","a133","a134","a135","a136","a137","a138","a139","a140","a141","a142","a143","a144","a145","a146","a147","a148","a149","a150","a151","a152","a153","a154","a155","a156","a157","a158","a159","a160","a161","a163","a164","a196","a165","a192","a166","a167","a168","a169","a170","a171","a172","a173","a162","a174","a175","a176","a177","a178","a179","a193","a180","a199","a181","a200","a182","","a201","a183","a184","a197","a185","a194","a198","a186","a195","a187","a188","a189","a190","a191",""];function getEncoding(e){switch(e){case"WinAnsiEncoding":return li;case"StandardEncoding":return hi;case"MacRomanEncoding":return Ci;case"SymbolSetEncoding":return Bi;case"ZapfDingbatsEncoding":return Qi;case"ExpertEncoding":return Ii;case"MacExpertEncoding":return ci;default:return null}}const Ei=[".notdef","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","endash","dagger","daggerdbl","periodcentered","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","questiondown","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","ring","cedilla","hungarumlaut","ogonek","caron","emdash","AE","ordfeminine","Lslash","Oslash","OE","ordmasculine","ae","dotlessi","lslash","oslash","oe","germandbls","onesuperior","logicalnot","mu","trademark","Eth","onehalf","plusminus","Thorn","onequarter","divide","brokenbar","degree","thorn","threequarters","twosuperior","registered","minus","eth","multiply","threesuperior","copyright","Aacute","Acircumflex","Adieresis","Agrave","Aring","Atilde","Ccedilla","Eacute","Ecircumflex","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Ntilde","Oacute","Ocircumflex","Odieresis","Ograve","Otilde","Scaron","Uacute","Ucircumflex","Udieresis","Ugrave","Yacute","Ydieresis","Zcaron","aacute","acircumflex","adieresis","agrave","aring","atilde","ccedilla","eacute","ecircumflex","edieresis","egrave","iacute","icircumflex","idieresis","igrave","ntilde","oacute","ocircumflex","odieresis","ograve","otilde","scaron","uacute","ucircumflex","udieresis","ugrave","yacute","ydieresis","zcaron","exclamsmall","Hungarumlautsmall","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","commasuperior","threequartersemdash","periodsuperior","questionsmall","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","ffi","ffl","parenleftinferior","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","exclamdownsmall","centoldstyle","Lslashsmall","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","Dotaccentsmall","Macronsmall","figuredash","hypheninferior","Ogoneksmall","Ringsmall","Cedillasmall","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall","001.000","001.001","001.002","001.003","Black","Bold","Book","Light","Medium","Regular","Roman","Semibold"],ui=391,di=[null,{id:"hstem",min:2,stackClearing:!0,stem:!0},null,{id:"vstem",min:2,stackClearing:!0,stem:!0},{id:"vmoveto",min:1,stackClearing:!0},{id:"rlineto",min:2,resetStack:!0},{id:"hlineto",min:1,resetStack:!0},{id:"vlineto",min:1,resetStack:!0},{id:"rrcurveto",min:6,resetStack:!0},null,{id:"callsubr",min:1,undefStack:!0},{id:"return",min:0,undefStack:!0},null,null,{id:"endchar",min:0,stackClearing:!0},null,null,null,{id:"hstemhm",min:2,stackClearing:!0,stem:!0},{id:"hintmask",min:0,stackClearing:!0},{id:"cntrmask",min:0,stackClearing:!0},{id:"rmoveto",min:2,stackClearing:!0},{id:"hmoveto",min:1,stackClearing:!0},{id:"vstemhm",min:2,stackClearing:!0,stem:!0},{id:"rcurveline",min:8,resetStack:!0},{id:"rlinecurve",min:8,resetStack:!0},{id:"vvcurveto",min:4,resetStack:!0},{id:"hhcurveto",min:4,resetStack:!0},null,{id:"callgsubr",min:1,undefStack:!0},{id:"vhcurveto",min:4,resetStack:!0},{id:"hvcurveto",min:4,resetStack:!0}],fi=[null,null,null,{id:"and",min:2,stackDelta:-1},{id:"or",min:2,stackDelta:-1},{id:"not",min:1,stackDelta:0},null,null,null,{id:"abs",min:1,stackDelta:0},{id:"add",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]+e[t-1]}},{id:"sub",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]-e[t-1]}},{id:"div",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]/e[t-1]}},null,{id:"neg",min:1,stackDelta:0,stackFn(e,t){e[t-1]=-e[t-1]}},{id:"eq",min:2,stackDelta:-1},null,null,{id:"drop",min:1,stackDelta:-1},null,{id:"put",min:2,stackDelta:-2},{id:"get",min:1,stackDelta:0},{id:"ifelse",min:4,stackDelta:-3},{id:"random",min:0,stackDelta:1},{id:"mul",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]*e[t-1]}},null,{id:"sqrt",min:1,stackDelta:0},{id:"dup",min:1,stackDelta:1},{id:"exch",min:2,stackDelta:0},{id:"index",min:2,stackDelta:0},{id:"roll",min:3,stackDelta:-2},null,null,null,{id:"hflex",min:7,resetStack:!0},{id:"flex",min:13,resetStack:!0},{id:"hflex1",min:9,resetStack:!0},{id:"flex1",min:11,resetStack:!0}];class CFFParser{constructor(e,t,i){this.bytes=e.getBytes();this.properties=t;this.seacAnalysisEnabled=!!i}parse(){const e=this.properties,t=new CFF;this.cff=t;const i=this.parseHeader(),a=this.parseIndex(i.endPos),s=this.parseIndex(a.endPos),r=this.parseIndex(s.endPos),n=this.parseIndex(r.endPos),g=this.parseDict(s.obj.get(0)),o=this.createDict(CFFTopDict,g,t.strings);t.header=i.obj;t.names=this.parseNameIndex(a.obj);t.strings=this.parseStringIndex(r.obj);t.topDict=o;t.globalSubrIndex=n.obj;this.parsePrivateDict(t.topDict);t.isCIDFont=o.hasName("ROS");const c=o.getByName("CharStrings"),C=this.parseIndex(c).obj,h=o.getByName("FontMatrix");h&&(e.fontMatrix=h);const l=o.getByName("FontBBox");if(l){e.ascent=Math.max(l[3],l[1]);e.descent=Math.min(l[1],l[3]);e.ascentScaled=!0}let Q,E;if(t.isCIDFont){const e=this.parseIndex(o.getByName("FDArray")).obj;for(let i=0,a=e.count;i=t)throw new FormatError("Invalid CFF header");if(0!==i){info("cff data is shifted");e=e.subarray(i);this.bytes=e}const a=e[0],s=e[1],r=e[2],n=e[3];return{obj:new CFFHeader(a,s,r,n),endPos:r}}parseDict(e){let t=0;function parseOperand(){let i=e[t++];if(30===i)return function parseFloatOperand(){let i="";const a=15,s=["0","1","2","3","4","5","6","7","8","9",".","E","E-",null,"-"],r=e.length;for(;t>4,g=15&r;if(n===a)break;i+=s[n];if(g===a)break;i+=s[g]}return parseFloat(i)}();if(28===i){i=e[t++];i=(i<<24|e[t++]<<16)>>16;return i}if(29===i){i=e[t++];i=i<<8|e[t++];i=i<<8|e[t++];i=i<<8|e[t++];return i}if(i>=32&&i<=246)return i-139;if(i>=247&&i<=250)return 256*(i-247)+e[t++]+108;if(i>=251&&i<=254)return-256*(i-251)-e[t++]-108;warn('CFFParser_parseDict: "'+i+'" is a reserved command.');return NaN}let i=[];const a=[];t=0;const s=e.length;for(;t10)return!1;let s=e.stackSize;const r=e.stack;let n=t.length;for(let g=0;g>16;g+=2;s++}else if(14===o){if(s>=4){s-=4;if(this.seacAnalysisEnabled){e.seac=r.slice(s,s+4);return!1}}c=di[o]}else if(o>=32&&o<=246){r[s]=o-139;s++}else if(o>=247&&o<=254){r[s]=o<251?(o-247<<8)+t[g]+108:-(o-251<<8)-t[g]-108;g++;s++}else if(255===o){r[s]=(t[g]<<24|t[g+1]<<16|t[g+2]<<8|t[g+3])/65536;g+=4;s++}else if(19===o||20===o){e.hints+=s>>1;if(0===e.hints){t.copyWithin(g-1,g,-1);g-=1;n-=1;continue}g+=e.hints+7>>3;s%=2;c=di[o]}else{if(10===o||29===o){const t=10===o?i:a;if(!t){c=di[o];warn("Missing subrsIndex for "+c.id);return!1}let n=32768;t.count<1240?n=107:t.count<33900&&(n=1131);const g=r[--s]+n;if(g<0||g>=t.count||isNaN(g)){c=di[o];warn("Out of bounds subrIndex for "+c.id);return!1}e.stackSize=s;e.callDepth++;if(!this.parseCharString(e,t.get(g),i,a))return!1;e.callDepth--;s=e.stackSize;continue}if(11===o){e.stackSize=s;return!0}if(0===o&&g===t.length){t[g-1]=14;c=di[14]}else{if(9===o){t.copyWithin(g-1,g,-1);g-=1;n-=1;continue}c=di[o]}}if(c){if(c.stem){e.hints+=s>>1;if(3===o||23===o)e.hasVStems=!0;else if(e.hasVStems&&(1===o||18===o)){warn("CFF stem hints are in wrong order");t[g-1]=1===o?3:23}}if("min"in c&&!e.undefStack&&s=2&&c.stem?s%=2:s>1&&warn("Found too many parameters for stack-clearing command");s>0&&(e.width=r[s-1])}if("stackDelta"in c){"stackFn"in c&&c.stackFn(r,s);s+=c.stackDelta}else if(c.stackClearing)s=0;else if(c.resetStack){s=0;e.undefStack=!1}else if(c.undefStack){s=0;e.undefStack=!0;e.firstStackClearing=!1}}}n=s.length){warn("Invalid fd index for glyph index.");h=!1}if(h){Q=s[e].privateDict;l=Q.subrsIndex}}else t&&(l=t);h&&(h=this.parseCharString(C,o,l,i));if(null!==C.width){const e=Q.getByName("nominalWidthX");g[c]=e+C.width}else{const e=Q.getByName("defaultWidthX");g[c]=e}null!==C.seac&&(n[c]=C.seac);h||e.set(c,new Uint8Array([14]))}return{charStrings:e,seacs:n,widths:g}}emptyPrivateDictionary(e){const t=this.createDict(CFFPrivateDict,[],e.strings);e.setByKey(18,[0,0]);e.privateDict=t}parsePrivateDict(e){if(!e.hasName("Private")){this.emptyPrivateDictionary(e);return}const t=e.getByName("Private");if(!Array.isArray(t)||2!==t.length){e.removeByName("Private");return}const i=t[0],a=t[1];if(0===i||a>=this.bytes.length){this.emptyPrivateDictionary(e);return}const s=a+i,r=this.bytes.subarray(a,s),n=this.parseDict(r),g=this.createDict(CFFPrivateDict,n,e.strings);e.privateDict=g;0===g.getByName("ExpansionFactor")&&g.setByName("ExpansionFactor",.06);if(!g.getByName("Subrs"))return;const o=g.getByName("Subrs"),c=a+o;if(0===o||c>=this.bytes.length){this.emptyPrivateDictionary(e);return}const C=this.parseIndex(c);g.subrsIndex=C.obj}parseCharsets(e,t,i,a){if(0===e)return new CFFCharset(!0,yi.ISO_ADOBE,ni);if(1===e)return new CFFCharset(!0,yi.EXPERT,gi);if(2===e)return new CFFCharset(!0,yi.EXPERT_SUBSET,oi);const s=this.bytes,r=e,n=s[e++],g=[a?0:".notdef"];let o,c,C;t-=1;switch(n){case 0:for(C=0;C=65535){warn("Not enough space in charstrings to duplicate first glyph.");return}const e=this.charStrings.get(0);this.charStrings.add(e);this.isCIDFont&&this.fdSelect.fdSelect.push(this.fdSelect.fdSelect[0])}hasGlyphId(e){if(e<0||e>=this.charStrings.count)return!1;return this.charStrings.get(e).length>0}}class CFFHeader{constructor(e,t,i,a){this.major=e;this.minor=t;this.hdrSize=i;this.offSize=a}}class CFFStrings{constructor(){this.strings=[]}get(e){return e>=0&&e<=390?Ei[e]:e-ui<=this.strings.length?this.strings[e-ui]:Ei[0]}getSID(e){let t=Ei.indexOf(e);if(-1!==t)return t;t=this.strings.indexOf(e);return-1!==t?t+ui:-1}add(e){this.strings.push(e)}get count(){return this.strings.length}}class CFFIndex{constructor(){this.objects=[];this.length=0}add(e){this.length+=e.length;this.objects.push(e)}set(e,t){this.length+=t.length-this.objects[e].length;this.objects[e]=t}get(e){return this.objects[e]}get count(){return this.objects.length}}class CFFDict{constructor(e,t){this.keyToNameMap=e.keyToNameMap;this.nameToKeyMap=e.nameToKeyMap;this.defaults=e.defaults;this.types=e.types;this.opcodes=e.opcodes;this.order=e.order;this.strings=t;this.values=Object.create(null)}setByKey(e,t){if(!(e in this.keyToNameMap))return!1;if(0===t.length)return!0;for(const i of t)if(isNaN(i)){warn(`Invalid CFFDict value: "${t}" for key "${e}".`);return!0}const i=this.types[e];"num"!==i&&"sid"!==i&&"offset"!==i||(t=t[0]);this.values[e]=t;return!0}setByName(e,t){if(!(e in this.nameToKeyMap))throw new FormatError(`Invalid dictionary name "${e}"`);this.values[this.nameToKeyMap[e]]=t}hasName(e){return this.nameToKeyMap[e]in this.values}getByName(e){if(!(e in this.nameToKeyMap))throw new FormatError(`Invalid dictionary name ${e}"`);const t=this.nameToKeyMap[e];return t in this.values?this.values[t]:this.defaults[t]}removeByName(e){delete this.values[this.nameToKeyMap[e]]}static createTables(e){const t={keyToNameMap:{},nameToKeyMap:{},defaults:{},types:{},opcodes:{},order:[]};for(const i of e){const e=Array.isArray(i[0])?(i[0][0]<<8)+i[0][1]:i[0];t.keyToNameMap[e]=i[1];t.nameToKeyMap[i[1]]=e;t.types[e]=i[2];t.defaults[e]=i[3];t.opcodes[e]=Array.isArray(i[0])?i[0]:[i[0]];t.order.push(e)}return t}}const pi=[[[12,30],"ROS",["sid","sid","num"],null],[[12,20],"SyntheticBase","num",null],[0,"version","sid",null],[1,"Notice","sid",null],[[12,0],"Copyright","sid",null],[2,"FullName","sid",null],[3,"FamilyName","sid",null],[4,"Weight","sid",null],[[12,1],"isFixedPitch","num",0],[[12,2],"ItalicAngle","num",0],[[12,3],"UnderlinePosition","num",-100],[[12,4],"UnderlineThickness","num",50],[[12,5],"PaintType","num",0],[[12,6],"CharstringType","num",2],[[12,7],"FontMatrix",["num","num","num","num","num","num"],[.001,0,0,.001,0,0]],[13,"UniqueID","num",null],[5,"FontBBox",["num","num","num","num"],[0,0,0,0]],[[12,8],"StrokeWidth","num",0],[14,"XUID","array",null],[15,"charset","offset",0],[16,"Encoding","offset",0],[17,"CharStrings","offset",0],[18,"Private",["offset","offset"],null],[[12,21],"PostScript","sid",null],[[12,22],"BaseFontName","sid",null],[[12,23],"BaseFontBlend","delta",null],[[12,31],"CIDFontVersion","num",0],[[12,32],"CIDFontRevision","num",0],[[12,33],"CIDFontType","num",0],[[12,34],"CIDCount","num",8720],[[12,35],"UIDBase","num",null],[[12,37],"FDSelect","offset",null],[[12,36],"FDArray","offset",null],[[12,38],"FontName","sid",null]];class CFFTopDict extends CFFDict{static get tables(){return shadow(this,"tables",this.createTables(pi))}constructor(e){super(CFFTopDict.tables,e);this.privateDict=null}}const mi=[[6,"BlueValues","delta",null],[7,"OtherBlues","delta",null],[8,"FamilyBlues","delta",null],[9,"FamilyOtherBlues","delta",null],[[12,9],"BlueScale","num",.039625],[[12,10],"BlueShift","num",7],[[12,11],"BlueFuzz","num",1],[10,"StdHW","num",null],[11,"StdVW","num",null],[[12,12],"StemSnapH","delta",null],[[12,13],"StemSnapV","delta",null],[[12,14],"ForceBold","num",0],[[12,17],"LanguageGroup","num",0],[[12,18],"ExpansionFactor","num",.06],[[12,19],"initialRandomSeed","num",0],[20,"defaultWidthX","num",0],[21,"nominalWidthX","num",0],[19,"Subrs","offset",null]];class CFFPrivateDict extends CFFDict{static get tables(){return shadow(this,"tables",this.createTables(mi))}constructor(e){super(CFFPrivateDict.tables,e);this.subrsIndex=null}}const yi={ISO_ADOBE:0,EXPERT:1,EXPERT_SUBSET:2};class CFFCharset{constructor(e,t,i,a){this.predefined=e;this.format=t;this.charset=i;this.raw=a}}class CFFEncoding{constructor(e,t,i,a){this.predefined=e;this.format=t;this.encoding=i;this.raw=a}}class CFFFDSelect{constructor(e,t){this.format=e;this.fdSelect=t}getFDIndex(e){return e<0||e>=this.fdSelect.length?-1:this.fdSelect[e]}}class CFFOffsetTracker{constructor(){this.offsets=Object.create(null)}isTracking(e){return e in this.offsets}track(e,t){if(e in this.offsets)throw new FormatError(`Already tracking location of ${e}`);this.offsets[e]=t}offset(e){for(const t in this.offsets)this.offsets[t]+=e}setEntryLocation(e,t,i){if(!(e in this.offsets))throw new FormatError(`Not tracking location of ${e}`);const a=i.data,s=this.offsets[e];for(let e=0,i=t.length;e>24&255;a[n]=c>>16&255;a[g]=c>>8&255;a[o]=255&c}}}class CFFCompiler{constructor(e){this.cff=e}compile(){const e=this.cff,t={data:[],length:0,add(e){try{this.data.push(...e)}catch{this.data=this.data.concat(e)}this.length=this.data.length}},i=this.compileHeader(e.header);t.add(i);const a=this.compileNameIndex(e.names);t.add(a);if(e.isCIDFont&&e.topDict.hasName("FontMatrix")){const t=e.topDict.getByName("FontMatrix");e.topDict.removeByName("FontMatrix");for(const i of e.fdArray){let e=t.slice(0);i.hasName("FontMatrix")&&(e=Util.transform(e,i.getByName("FontMatrix")));i.setByName("FontMatrix",e)}}const s=e.topDict.getByName("XUID");s?.length>16&&e.topDict.removeByName("XUID");e.topDict.setByName("charset",0);let r=this.compileTopDicts([e.topDict],t.length,e.isCIDFont);t.add(r.output);const n=r.trackers[0],g=this.compileStringIndex(e.strings.strings);t.add(g);const o=this.compileIndex(e.globalSubrIndex);t.add(o);if(e.encoding&&e.topDict.hasName("Encoding"))if(e.encoding.predefined)n.setEntryLocation("Encoding",[e.encoding.format],t);else{const i=this.compileEncoding(e.encoding);n.setEntryLocation("Encoding",[t.length],t);t.add(i)}const c=this.compileCharset(e.charset,e.charStrings.count,e.strings,e.isCIDFont);n.setEntryLocation("charset",[t.length],t);t.add(c);const C=this.compileCharStrings(e.charStrings);n.setEntryLocation("CharStrings",[t.length],t);t.add(C);if(e.isCIDFont){n.setEntryLocation("FDSelect",[t.length],t);const i=this.compileFDSelect(e.fdSelect);t.add(i);r=this.compileTopDicts(e.fdArray,t.length,!0);n.setEntryLocation("FDArray",[t.length],t);t.add(r.output);const a=r.trackers;this.compilePrivateDicts(e.fdArray,a,t)}this.compilePrivateDicts([e.topDict],[n],t);t.add([0]);return t.data}encodeNumber(e){return Number.isInteger(e)?this.encodeInteger(e):this.encodeFloat(e)}static get EncodeFloatRegExp(){return shadow(this,"EncodeFloatRegExp",/\.(\d*?)(?:9{5,20}|0{5,20})\d{0,2}(?:e(.+)|$)/)}encodeFloat(e){let t=e.toString();const i=CFFCompiler.EncodeFloatRegExp.exec(t);if(i){const a=parseFloat("1e"+((i[2]?+i[2]:0)+i[1].length));t=(Math.round(e*a)/a).toString()}let a,s,r="";for(a=0,s=t.length;a=-107&&e<=107?[e+139]:e>=108&&e<=1131?[247+((e-=108)>>8),255&e]:e>=-1131&&e<=-108?[251+((e=-e-108)>>8),255&e]:e>=-32768&&e<=32767?[28,e>>8&255,255&e]:[29,e>>24&255,e>>16&255,e>>8&255,255&e];return t}compileHeader(e){return[e.major,e.minor,4,e.offSize]}compileNameIndex(e){const t=new CFFIndex;for(const i of e){const e=Math.min(i.length,127);let a=new Array(e);for(let t=0;t"~"||"["===e||"]"===e||"("===e||")"===e||"{"===e||"}"===e||"<"===e||">"===e||"/"===e||"%"===e)&&(e="_");a[t]=e}a=a.join("");""===a&&(a="Bad_Font_Name");t.add(stringToBytes(a))}return this.compileIndex(t)}compileTopDicts(e,t,i){const a=[];let s=new CFFIndex;for(const r of e){if(i){r.removeByName("CIDFontVersion");r.removeByName("CIDFontRevision");r.removeByName("CIDFontType");r.removeByName("CIDCount");r.removeByName("UIDBase")}const e=new CFFOffsetTracker,n=this.compileDict(r,e);a.push(e);s.add(n);e.offset(t)}s=this.compileIndex(s,a);return{trackers:a,output:s}}compilePrivateDicts(e,t,i){for(let a=0,s=e.length;a>8&255,255&r]);else{s=new Uint8Array(1+2*r);s[0]=0;let t=0;const a=e.charset.length;let n=!1;for(let r=1;r>8&255;s[r+1]=255&g}}return this.compileTypedArray(s)}compileEncoding(e){return this.compileTypedArray(e.raw)}compileFDSelect(e){const t=e.format;let i,a;switch(t){case 0:i=new Uint8Array(1+e.fdSelect.length);i[0]=t;for(a=0;a>8&255,255&s,r];for(a=1;a>8&255,255&a,t);r=t}}const g=(n.length-3)/3;n[1]=g>>8&255;n[2]=255&g;n.push(a>>8&255,255&a);i=new Uint8Array(n)}return this.compileTypedArray(i)}compileTypedArray(e){return Array.from(e)}compileIndex(e,t=[]){const i=e.objects,a=i.length;if(0===a)return[0,0];const s=[a>>8&255,255&a];let r,n,g=1;for(r=0;r>8&255,255&o):3===n?s.push(o>>16&255,o>>8&255,255&o):s.push(o>>>24&255,o>>16&255,o>>8&255,255&o);i[r]&&(o+=i[r].length)}for(r=0;r=5&&t<=7))return-1;a=e.substring(1)}if(a===a.toUpperCase()){i=parseInt(a,16);if(i>=0)return i}}return-1}const Fi=[[0,127],[128,255],[256,383],[384,591],[592,687,7424,7551,7552,7615],[688,767,42752,42783],[768,879,7616,7679],[880,1023],[11392,11519],[1024,1279,1280,1327,11744,11775,42560,42655],[1328,1423],[1424,1535],[42240,42559],[1536,1791,1872,1919],[1984,2047],[2304,2431],[2432,2559],[2560,2687],[2688,2815],[2816,2943],[2944,3071],[3072,3199],[3200,3327],[3328,3455],[3584,3711],[3712,3839],[4256,4351,11520,11567],[6912,7039],[4352,4607],[7680,7935,11360,11391,42784,43007],[7936,8191],[8192,8303,11776,11903],[8304,8351],[8352,8399],[8400,8447],[8448,8527],[8528,8591],[8592,8703,10224,10239,10496,10623,11008,11263],[8704,8959,10752,11007,10176,10223,10624,10751],[8960,9215],[9216,9279],[9280,9311],[9312,9471],[9472,9599],[9600,9631],[9632,9727],[9728,9983],[9984,10175],[12288,12351],[12352,12447],[12448,12543,12784,12799],[12544,12591,12704,12735],[12592,12687],[43072,43135],[12800,13055],[13056,13311],[44032,55215],[55296,57343],[67840,67871],[19968,40959,11904,12031,12032,12255,12272,12287,13312,19903,131072,173791,12688,12703],[57344,63743],[12736,12783,63744,64255,194560,195103],[64256,64335],[64336,65023],[65056,65071],[65040,65055],[65104,65135],[65136,65279],[65280,65519],[65520,65535],[3840,4095],[1792,1871],[1920,1983],[3456,3583],[4096,4255],[4608,4991,4992,5023,11648,11743],[5024,5119],[5120,5759],[5760,5791],[5792,5887],[6016,6143],[6144,6319],[10240,10495],[40960,42127],[5888,5919,5920,5951,5952,5983,5984,6015],[66304,66351],[66352,66383],[66560,66639],[118784,119039,119040,119295,119296,119375],[119808,120831],[1044480,1048573],[65024,65039,917760,917999],[917504,917631],[6400,6479],[6480,6527],[6528,6623],[6656,6687],[11264,11359],[11568,11647],[19904,19967],[43008,43055],[65536,65663,65664,65791,65792,65855],[65856,65935],[66432,66463],[66464,66527],[66640,66687],[66688,66735],[67584,67647],[68096,68191],[119552,119647],[73728,74751,74752,74879],[119648,119679],[7040,7103],[7168,7247],[7248,7295],[43136,43231],[43264,43311],[43312,43359],[43520,43615],[65936,65999],[66e3,66047],[66208,66271,66176,66207,67872,67903],[127024,127135,126976,127023]];function getUnicodeRangeFor(e,t=-1){if(-1!==t){const i=Fi[t];for(let a=0,s=i.length;a=i[a]&&e<=i[a+1])return t}for(let t=0,i=Fi.length;t=i[a]&&e<=i[a+1])return t}return-1}const Si=new RegExp("^(\\s)|(\\p{Mn})|(\\p{Cf})$","u"),ki=new Map;const Ri=!0,Ni=1,Gi=2,Mi=4,xi=32,Hi=[".notdef",".null","nonmarkingreturn","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","nonbreakingspace","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron","Lslash","lslash","Scaron","scaron","Zcaron","zcaron","brokenbar","Eth","eth","Yacute","yacute","Thorn","thorn","minus","multiply","onesuperior","twosuperior","threesuperior","onehalf","onequarter","threequarters","franc","Gbreve","gbreve","Idotaccent","Scedilla","scedilla","Cacute","cacute","Ccaron","ccaron","dcroat"];function recoverGlyphName(e,t){if(void 0!==t[e])return e;const i=getUnicodeForGlyph(e,t);if(-1!==i)for(const e in t)if(t[e]===i)return e;info("Unable to recover a standard glyph name for: "+e);return e}function type1FontGlyphMapping(e,t,i){const a=Object.create(null);let s,r,n;const g=!!(e.flags&Mi);if(e.isInternalFont){n=t;for(r=0;r=0?s:0}}else if(e.baseEncodingName){n=getEncoding(e.baseEncodingName);for(r=0;r=0?s:0}}else if(g)for(r in t)a[r]=t[r];else{n=hi;for(r=0;r=0?s:0}}const o=e.differences;let c;if(o)for(r in o){const e=o[r];s=i.indexOf(e);if(-1===s){c||(c=wi());const t=recoverGlyphName(e,c);t!==e&&(s=i.indexOf(t))}a[r]=s>=0?s:0}return a}function normalizeFontName(e){return e.replaceAll(/[,_]/g,"-").replaceAll(/\s/g,"")}const Ji=getLookupTableFactory((e=>{e[8211]=65074;e[8212]=65073;e[8229]=65072;e[8230]=65049;e[12289]=65041;e[12290]=65042;e[12296]=65087;e[12297]=65088;e[12298]=65085;e[12299]=65086;e[12300]=65089;e[12301]=65090;e[12302]=65091;e[12303]=65092;e[12304]=65083;e[12305]=65084;e[12308]=65081;e[12309]=65082;e[12310]=65047;e[12311]=65048;e[65103]=65076;e[65281]=65045;e[65288]=65077;e[65289]=65078;e[65292]=65040;e[65306]=65043;e[65307]=65044;e[65311]=65046;e[65339]=65095;e[65341]=65096;e[65343]=65075;e[65371]=65079;e[65373]=65080})),Yi=getLookupTableFactory((function(e){e["Times-Roman"]="Times-Roman";e.Helvetica="Helvetica";e.Courier="Courier";e.Symbol="Symbol";e["Times-Bold"]="Times-Bold";e["Helvetica-Bold"]="Helvetica-Bold";e["Courier-Bold"]="Courier-Bold";e.ZapfDingbats="ZapfDingbats";e["Times-Italic"]="Times-Italic";e["Helvetica-Oblique"]="Helvetica-Oblique";e["Courier-Oblique"]="Courier-Oblique";e["Times-BoldItalic"]="Times-BoldItalic";e["Helvetica-BoldOblique"]="Helvetica-BoldOblique";e["Courier-BoldOblique"]="Courier-BoldOblique";e.ArialNarrow="Helvetica";e["ArialNarrow-Bold"]="Helvetica-Bold";e["ArialNarrow-BoldItalic"]="Helvetica-BoldOblique";e["ArialNarrow-Italic"]="Helvetica-Oblique";e.ArialBlack="Helvetica";e["ArialBlack-Bold"]="Helvetica-Bold";e["ArialBlack-BoldItalic"]="Helvetica-BoldOblique";e["ArialBlack-Italic"]="Helvetica-Oblique";e["Arial-Black"]="Helvetica";e["Arial-Black-Bold"]="Helvetica-Bold";e["Arial-Black-BoldItalic"]="Helvetica-BoldOblique";e["Arial-Black-Italic"]="Helvetica-Oblique";e.Arial="Helvetica";e["Arial-Bold"]="Helvetica-Bold";e["Arial-BoldItalic"]="Helvetica-BoldOblique";e["Arial-Italic"]="Helvetica-Oblique";e.ArialMT="Helvetica";e["Arial-BoldItalicMT"]="Helvetica-BoldOblique";e["Arial-BoldMT"]="Helvetica-Bold";e["Arial-ItalicMT"]="Helvetica-Oblique";e["Arial-BoldItalicMT-BoldItalic"]="Helvetica-BoldOblique";e["Arial-BoldMT-Bold"]="Helvetica-Bold";e["Arial-ItalicMT-Italic"]="Helvetica-Oblique";e.ArialUnicodeMS="Helvetica";e["ArialUnicodeMS-Bold"]="Helvetica-Bold";e["ArialUnicodeMS-BoldItalic"]="Helvetica-BoldOblique";e["ArialUnicodeMS-Italic"]="Helvetica-Oblique";e["Courier-BoldItalic"]="Courier-BoldOblique";e["Courier-Italic"]="Courier-Oblique";e.CourierNew="Courier";e["CourierNew-Bold"]="Courier-Bold";e["CourierNew-BoldItalic"]="Courier-BoldOblique";e["CourierNew-Italic"]="Courier-Oblique";e["CourierNewPS-BoldItalicMT"]="Courier-BoldOblique";e["CourierNewPS-BoldMT"]="Courier-Bold";e["CourierNewPS-ItalicMT"]="Courier-Oblique";e.CourierNewPSMT="Courier";e["Helvetica-BoldItalic"]="Helvetica-BoldOblique";e["Helvetica-Italic"]="Helvetica-Oblique";e["HelveticaLTStd-Bold"]="Helvetica-Bold";e["Symbol-Bold"]="Symbol";e["Symbol-BoldItalic"]="Symbol";e["Symbol-Italic"]="Symbol";e.TimesNewRoman="Times-Roman";e["TimesNewRoman-Bold"]="Times-Bold";e["TimesNewRoman-BoldItalic"]="Times-BoldItalic";e["TimesNewRoman-Italic"]="Times-Italic";e.TimesNewRomanPS="Times-Roman";e["TimesNewRomanPS-Bold"]="Times-Bold";e["TimesNewRomanPS-BoldItalic"]="Times-BoldItalic";e["TimesNewRomanPS-BoldItalicMT"]="Times-BoldItalic";e["TimesNewRomanPS-BoldMT"]="Times-Bold";e["TimesNewRomanPS-Italic"]="Times-Italic";e["TimesNewRomanPS-ItalicMT"]="Times-Italic";e.TimesNewRomanPSMT="Times-Roman";e["TimesNewRomanPSMT-Bold"]="Times-Bold";e["TimesNewRomanPSMT-BoldItalic"]="Times-BoldItalic";e["TimesNewRomanPSMT-Italic"]="Times-Italic"})),vi=getLookupTableFactory((function(e){e.Courier="FoxitFixed.pfb";e["Courier-Bold"]="FoxitFixedBold.pfb";e["Courier-BoldOblique"]="FoxitFixedBoldItalic.pfb";e["Courier-Oblique"]="FoxitFixedItalic.pfb";e.Helvetica="LiberationSans-Regular.ttf";e["Helvetica-Bold"]="LiberationSans-Bold.ttf";e["Helvetica-BoldOblique"]="LiberationSans-BoldItalic.ttf";e["Helvetica-Oblique"]="LiberationSans-Italic.ttf";e["Times-Roman"]="FoxitSerif.pfb";e["Times-Bold"]="FoxitSerifBold.pfb";e["Times-BoldItalic"]="FoxitSerifBoldItalic.pfb";e["Times-Italic"]="FoxitSerifItalic.pfb";e.Symbol="FoxitSymbol.pfb";e.ZapfDingbats="FoxitDingbats.pfb";e["LiberationSans-Regular"]="LiberationSans-Regular.ttf";e["LiberationSans-Bold"]="LiberationSans-Bold.ttf";e["LiberationSans-Italic"]="LiberationSans-Italic.ttf";e["LiberationSans-BoldItalic"]="LiberationSans-BoldItalic.ttf"})),Ki=getLookupTableFactory((function(e){e.Calibri="Helvetica";e["Calibri-Bold"]="Helvetica-Bold";e["Calibri-BoldItalic"]="Helvetica-BoldOblique";e["Calibri-Italic"]="Helvetica-Oblique";e.CenturyGothic="Helvetica";e["CenturyGothic-Bold"]="Helvetica-Bold";e["CenturyGothic-BoldItalic"]="Helvetica-BoldOblique";e["CenturyGothic-Italic"]="Helvetica-Oblique";e.ComicSansMS="Comic Sans MS";e["ComicSansMS-Bold"]="Comic Sans MS-Bold";e["ComicSansMS-BoldItalic"]="Comic Sans MS-BoldItalic";e["ComicSansMS-Italic"]="Comic Sans MS-Italic";e.GillSansMT="Helvetica";e["GillSansMT-Bold"]="Helvetica-Bold";e["GillSansMT-BoldItalic"]="Helvetica-BoldOblique";e["GillSansMT-Italic"]="Helvetica-Oblique";e.Impact="Helvetica";e["ItcSymbol-Bold"]="Helvetica-Bold";e["ItcSymbol-BoldItalic"]="Helvetica-BoldOblique";e["ItcSymbol-Book"]="Helvetica";e["ItcSymbol-BookItalic"]="Helvetica-Oblique";e["ItcSymbol-Medium"]="Helvetica";e["ItcSymbol-MediumItalic"]="Helvetica-Oblique";e.LucidaConsole="Courier";e["LucidaConsole-Bold"]="Courier-Bold";e["LucidaConsole-BoldItalic"]="Courier-BoldOblique";e["LucidaConsole-Italic"]="Courier-Oblique";e["LucidaSans-Demi"]="Helvetica-Bold";e["MS-Gothic"]="MS Gothic";e["MS-Gothic-Bold"]="MS Gothic-Bold";e["MS-Gothic-BoldItalic"]="MS Gothic-BoldItalic";e["MS-Gothic-Italic"]="MS Gothic-Italic";e["MS-Mincho"]="MS Mincho";e["MS-Mincho-Bold"]="MS Mincho-Bold";e["MS-Mincho-BoldItalic"]="MS Mincho-BoldItalic";e["MS-Mincho-Italic"]="MS Mincho-Italic";e["MS-PGothic"]="MS PGothic";e["MS-PGothic-Bold"]="MS PGothic-Bold";e["MS-PGothic-BoldItalic"]="MS PGothic-BoldItalic";e["MS-PGothic-Italic"]="MS PGothic-Italic";e["MS-PMincho"]="MS PMincho";e["MS-PMincho-Bold"]="MS PMincho-Bold";e["MS-PMincho-BoldItalic"]="MS PMincho-BoldItalic";e["MS-PMincho-Italic"]="MS PMincho-Italic";e.NuptialScript="Times-Italic";e.SegoeUISymbol="Helvetica"})),Ti=getLookupTableFactory((function(e){e["Adobe Jenson"]=!0;e["Adobe Text"]=!0;e.Albertus=!0;e.Aldus=!0;e.Alexandria=!0;e.Algerian=!0;e["American Typewriter"]=!0;e.Antiqua=!0;e.Apex=!0;e.Arno=!0;e.Aster=!0;e.Aurora=!0;e.Baskerville=!0;e.Bell=!0;e.Bembo=!0;e["Bembo Schoolbook"]=!0;e.Benguiat=!0;e["Berkeley Old Style"]=!0;e["Bernhard Modern"]=!0;e["Berthold City"]=!0;e.Bodoni=!0;e["Bauer Bodoni"]=!0;e["Book Antiqua"]=!0;e.Bookman=!0;e["Bordeaux Roman"]=!0;e["Californian FB"]=!0;e.Calisto=!0;e.Calvert=!0;e.Capitals=!0;e.Cambria=!0;e.Cartier=!0;e.Caslon=!0;e.Catull=!0;e.Centaur=!0;e["Century Old Style"]=!0;e["Century Schoolbook"]=!0;e.Chaparral=!0;e["Charis SIL"]=!0;e.Cheltenham=!0;e["Cholla Slab"]=!0;e.Clarendon=!0;e.Clearface=!0;e.Cochin=!0;e.Colonna=!0;e["Computer Modern"]=!0;e["Concrete Roman"]=!0;e.Constantia=!0;e["Cooper Black"]=!0;e.Corona=!0;e.Ecotype=!0;e.Egyptienne=!0;e.Elephant=!0;e.Excelsior=!0;e.Fairfield=!0;e["FF Scala"]=!0;e.Folkard=!0;e.Footlight=!0;e.FreeSerif=!0;e["Friz Quadrata"]=!0;e.Garamond=!0;e.Gentium=!0;e.Georgia=!0;e.Gloucester=!0;e["Goudy Old Style"]=!0;e["Goudy Schoolbook"]=!0;e["Goudy Pro Font"]=!0;e.Granjon=!0;e["Guardian Egyptian"]=!0;e.Heather=!0;e.Hercules=!0;e["High Tower Text"]=!0;e.Hiroshige=!0;e["Hoefler Text"]=!0;e["Humana Serif"]=!0;e.Imprint=!0;e["Ionic No. 5"]=!0;e.Janson=!0;e.Joanna=!0;e.Korinna=!0;e.Lexicon=!0;e.LiberationSerif=!0;e["Liberation Serif"]=!0;e["Linux Libertine"]=!0;e.Literaturnaya=!0;e.Lucida=!0;e["Lucida Bright"]=!0;e.Melior=!0;e.Memphis=!0;e.Miller=!0;e.Minion=!0;e.Modern=!0;e["Mona Lisa"]=!0;e["Mrs Eaves"]=!0;e["MS Serif"]=!0;e["Museo Slab"]=!0;e["New York"]=!0;e["Nimbus Roman"]=!0;e["NPS Rawlinson Roadway"]=!0;e.NuptialScript=!0;e.Palatino=!0;e.Perpetua=!0;e.Plantin=!0;e["Plantin Schoolbook"]=!0;e.Playbill=!0;e["Poor Richard"]=!0;e["Rawlinson Roadway"]=!0;e.Renault=!0;e.Requiem=!0;e.Rockwell=!0;e.Roman=!0;e["Rotis Serif"]=!0;e.Sabon=!0;e.Scala=!0;e.Seagull=!0;e.Sistina=!0;e.Souvenir=!0;e.STIX=!0;e["Stone Informal"]=!0;e["Stone Serif"]=!0;e.Sylfaen=!0;e.Times=!0;e.Trajan=!0;e["Trinité"]=!0;e["Trump Mediaeval"]=!0;e.Utopia=!0;e["Vale Type"]=!0;e["Bitstream Vera"]=!0;e["Vera Serif"]=!0;e.Versailles=!0;e.Wanted=!0;e.Weiss=!0;e["Wide Latin"]=!0;e.Windsor=!0;e.XITS=!0})),qi=getLookupTableFactory((function(e){e.Dingbats=!0;e.Symbol=!0;e.ZapfDingbats=!0;e.Wingdings=!0;e["Wingdings-Bold"]=!0;e["Wingdings-Regular"]=!0})),Oi=getLookupTableFactory((function(e){e[2]=10;e[3]=32;e[4]=33;e[5]=34;e[6]=35;e[7]=36;e[8]=37;e[9]=38;e[10]=39;e[11]=40;e[12]=41;e[13]=42;e[14]=43;e[15]=44;e[16]=45;e[17]=46;e[18]=47;e[19]=48;e[20]=49;e[21]=50;e[22]=51;e[23]=52;e[24]=53;e[25]=54;e[26]=55;e[27]=56;e[28]=57;e[29]=58;e[30]=894;e[31]=60;e[32]=61;e[33]=62;e[34]=63;e[35]=64;e[36]=65;e[37]=66;e[38]=67;e[39]=68;e[40]=69;e[41]=70;e[42]=71;e[43]=72;e[44]=73;e[45]=74;e[46]=75;e[47]=76;e[48]=77;e[49]=78;e[50]=79;e[51]=80;e[52]=81;e[53]=82;e[54]=83;e[55]=84;e[56]=85;e[57]=86;e[58]=87;e[59]=88;e[60]=89;e[61]=90;e[62]=91;e[63]=92;e[64]=93;e[65]=94;e[66]=95;e[67]=96;e[68]=97;e[69]=98;e[70]=99;e[71]=100;e[72]=101;e[73]=102;e[74]=103;e[75]=104;e[76]=105;e[77]=106;e[78]=107;e[79]=108;e[80]=109;e[81]=110;e[82]=111;e[83]=112;e[84]=113;e[85]=114;e[86]=115;e[87]=116;e[88]=117;e[89]=118;e[90]=119;e[91]=120;e[92]=121;e[93]=122;e[94]=123;e[95]=124;e[96]=125;e[97]=126;e[98]=196;e[99]=197;e[100]=199;e[101]=201;e[102]=209;e[103]=214;e[104]=220;e[105]=225;e[106]=224;e[107]=226;e[108]=228;e[109]=227;e[110]=229;e[111]=231;e[112]=233;e[113]=232;e[114]=234;e[115]=235;e[116]=237;e[117]=236;e[118]=238;e[119]=239;e[120]=241;e[121]=243;e[122]=242;e[123]=244;e[124]=246;e[125]=245;e[126]=250;e[127]=249;e[128]=251;e[129]=252;e[130]=8224;e[131]=176;e[132]=162;e[133]=163;e[134]=167;e[135]=8226;e[136]=182;e[137]=223;e[138]=174;e[139]=169;e[140]=8482;e[141]=180;e[142]=168;e[143]=8800;e[144]=198;e[145]=216;e[146]=8734;e[147]=177;e[148]=8804;e[149]=8805;e[150]=165;e[151]=181;e[152]=8706;e[153]=8721;e[154]=8719;e[156]=8747;e[157]=170;e[158]=186;e[159]=8486;e[160]=230;e[161]=248;e[162]=191;e[163]=161;e[164]=172;e[165]=8730;e[166]=402;e[167]=8776;e[168]=8710;e[169]=171;e[170]=187;e[171]=8230;e[179]=8220;e[180]=8221;e[181]=8216;e[182]=8217;e[200]=193;e[203]=205;e[207]=211;e[210]=218;e[223]=711;e[224]=321;e[225]=322;e[226]=352;e[227]=353;e[228]=381;e[229]=382;e[233]=221;e[234]=253;e[252]=263;e[253]=268;e[254]=269;e[258]=258;e[260]=260;e[261]=261;e[265]=280;e[266]=281;e[267]=282;e[268]=283;e[269]=313;e[275]=323;e[276]=324;e[278]=328;e[283]=344;e[284]=345;e[285]=346;e[286]=347;e[292]=367;e[295]=377;e[296]=378;e[298]=380;e[305]=963;e[306]=964;e[307]=966;e[308]=8215;e[309]=8252;e[310]=8319;e[311]=8359;e[312]=8592;e[313]=8593;e[337]=9552;e[493]=1039;e[494]=1040;e[672]=1488;e[673]=1489;e[674]=1490;e[675]=1491;e[676]=1492;e[677]=1493;e[678]=1494;e[679]=1495;e[680]=1496;e[681]=1497;e[682]=1498;e[683]=1499;e[684]=1500;e[685]=1501;e[686]=1502;e[687]=1503;e[688]=1504;e[689]=1505;e[690]=1506;e[691]=1507;e[692]=1508;e[693]=1509;e[694]=1510;e[695]=1511;e[696]=1512;e[697]=1513;e[698]=1514;e[705]=1524;e[706]=8362;e[710]=64288;e[711]=64298;e[759]=1617;e[761]=1776;e[763]=1778;e[775]=1652;e[777]=1764;e[778]=1780;e[779]=1781;e[780]=1782;e[782]=771;e[783]=64726;e[786]=8363;e[788]=8532;e[790]=768;e[791]=769;e[792]=768;e[795]=803;e[797]=64336;e[798]=64337;e[799]=64342;e[800]=64343;e[801]=64344;e[802]=64345;e[803]=64362;e[804]=64363;e[805]=64364;e[2424]=7821;e[2425]=7822;e[2426]=7823;e[2427]=7824;e[2428]=7825;e[2429]=7826;e[2430]=7827;e[2433]=7682;e[2678]=8045;e[2679]=8046;e[2830]=1552;e[2838]=686;e[2840]=751;e[2842]=753;e[2843]=754;e[2844]=755;e[2846]=757;e[2856]=767;e[2857]=848;e[2858]=849;e[2862]=853;e[2863]=854;e[2864]=855;e[2865]=861;e[2866]=862;e[2906]=7460;e[2908]=7462;e[2909]=7463;e[2910]=7464;e[2912]=7466;e[2913]=7467;e[2914]=7468;e[2916]=7470;e[2917]=7471;e[2918]=7472;e[2920]=7474;e[2921]=7475;e[2922]=7476;e[2924]=7478;e[2925]=7479;e[2926]=7480;e[2928]=7482;e[2929]=7483;e[2930]=7484;e[2932]=7486;e[2933]=7487;e[2934]=7488;e[2936]=7490;e[2937]=7491;e[2938]=7492;e[2940]=7494;e[2941]=7495;e[2942]=7496;e[2944]=7498;e[2946]=7500;e[2948]=7502;e[2950]=7504;e[2951]=7505;e[2952]=7506;e[2954]=7508;e[2955]=7509;e[2956]=7510;e[2958]=7512;e[2959]=7513;e[2960]=7514;e[2962]=7516;e[2963]=7517;e[2964]=7518;e[2966]=7520;e[2967]=7521;e[2968]=7522;e[2970]=7524;e[2971]=7525;e[2972]=7526;e[2974]=7528;e[2975]=7529;e[2976]=7530;e[2978]=1537;e[2979]=1538;e[2980]=1539;e[2982]=1549;e[2983]=1551;e[2984]=1552;e[2986]=1554;e[2987]=1555;e[2988]=1556;e[2990]=1623;e[2991]=1624;e[2995]=1775;e[2999]=1791;e[3002]=64290;e[3003]=64291;e[3004]=64292;e[3006]=64294;e[3007]=64295;e[3008]=64296;e[3011]=1900;e[3014]=8223;e[3015]=8244;e[3017]=7532;e[3018]=7533;e[3019]=7534;e[3075]=7590;e[3076]=7591;e[3079]=7594;e[3080]=7595;e[3083]=7598;e[3084]=7599;e[3087]=7602;e[3088]=7603;e[3091]=7606;e[3092]=7607;e[3095]=7610;e[3096]=7611;e[3099]=7614;e[3100]=7615;e[3103]=7618;e[3104]=7619;e[3107]=8337;e[3108]=8338;e[3116]=1884;e[3119]=1885;e[3120]=1885;e[3123]=1886;e[3124]=1886;e[3127]=1887;e[3128]=1887;e[3131]=1888;e[3132]=1888;e[3135]=1889;e[3136]=1889;e[3139]=1890;e[3140]=1890;e[3143]=1891;e[3144]=1891;e[3147]=1892;e[3148]=1892;e[3153]=580;e[3154]=581;e[3157]=584;e[3158]=585;e[3161]=588;e[3162]=589;e[3165]=891;e[3166]=892;e[3169]=1274;e[3170]=1275;e[3173]=1278;e[3174]=1279;e[3181]=7622;e[3182]=7623;e[3282]=11799;e[3316]=578;e[3379]=42785;e[3393]=1159;e[3416]=8377})),Pi=getLookupTableFactory((function(e){e[227]=322;e[264]=261;e[291]=346})),Wi=getLookupTableFactory((function(e){e[1]=32;e[4]=65;e[5]=192;e[6]=193;e[9]=196;e[17]=66;e[18]=67;e[21]=268;e[24]=68;e[28]=69;e[29]=200;e[30]=201;e[32]=282;e[38]=70;e[39]=71;e[44]=72;e[47]=73;e[48]=204;e[49]=205;e[58]=74;e[60]=75;e[62]=76;e[68]=77;e[69]=78;e[75]=79;e[76]=210;e[80]=214;e[87]=80;e[89]=81;e[90]=82;e[92]=344;e[94]=83;e[97]=352;e[100]=84;e[104]=85;e[109]=220;e[115]=86;e[116]=87;e[121]=88;e[122]=89;e[124]=221;e[127]=90;e[129]=381;e[258]=97;e[259]=224;e[260]=225;e[263]=228;e[268]=261;e[271]=98;e[272]=99;e[273]=263;e[275]=269;e[282]=100;e[286]=101;e[287]=232;e[288]=233;e[290]=283;e[295]=281;e[296]=102;e[336]=103;e[346]=104;e[349]=105;e[350]=236;e[351]=237;e[361]=106;e[364]=107;e[367]=108;e[371]=322;e[373]=109;e[374]=110;e[381]=111;e[382]=242;e[383]=243;e[386]=246;e[393]=112;e[395]=113;e[396]=114;e[398]=345;e[400]=115;e[401]=347;e[403]=353;e[410]=116;e[437]=117;e[442]=252;e[448]=118;e[449]=119;e[454]=120;e[455]=121;e[457]=253;e[460]=122;e[462]=382;e[463]=380;e[853]=44;e[855]=58;e[856]=46;e[876]=47;e[878]=45;e[882]=45;e[894]=40;e[895]=41;e[896]=91;e[897]=93;e[923]=64;e[1004]=48;e[1005]=49;e[1006]=50;e[1007]=51;e[1008]=52;e[1009]=53;e[1010]=54;e[1011]=55;e[1012]=56;e[1013]=57;e[1081]=37;e[1085]=43;e[1086]=45}));function getStandardFontName(e){const t=normalizeFontName(e);return Yi()[t]}function isKnownFontName(e){const t=normalizeFontName(e);return!!(Yi()[t]||Ki()[t]||Ti()[t]||qi()[t])}class ToUnicodeMap{constructor(e=[]){this._map=e}get length(){return this._map.length}forEach(e){for(const t in this._map)e(t,this._map[t].codePointAt(0))}has(e){return void 0!==this._map[e]}get(e){return this._map[e]}charCodeOf(e){const t=this._map;if(t.length<=65536)return t.indexOf(e);for(const i in t)if(t[i]===e)return 0|i;return-1}amend(e){for(const t in e)this._map[t]=e[t]}}class IdentityToUnicodeMap{constructor(e,t){this.firstChar=e;this.lastChar=t}get length(){return this.lastChar+1-this.firstChar}forEach(e){for(let t=this.firstChar,i=this.lastChar;t<=i;t++)e(t,t)}has(e){return this.firstChar<=e&&e<=this.lastChar}get(e){if(this.firstChar<=e&&e<=this.lastChar)return String.fromCharCode(e)}charCodeOf(e){return Number.isInteger(e)&&e>=this.firstChar&&e<=this.lastChar?e:-1}amend(e){unreachable("Should not call amend()")}}class CFFFont{constructor(e,t){this.properties=t;const i=new CFFParser(e,t,Ri);this.cff=i.parse();this.cff.duplicateFirstGlyph();const a=new CFFCompiler(this.cff);this.seacs=this.cff.seacs;try{this.data=a.compile()}catch{warn("Failed to compile font "+t.loadedName);this.data=e}this._createBuiltInEncoding()}get numGlyphs(){return this.cff.charStrings.count}getCharset(){return this.cff.charset.charset}getGlyphMapping(){const e=this.cff,t=this.properties,{cidToGidMap:i,cMap:a}=t,s=e.charset.charset;let r,n;if(t.composite){let t,g;if(i?.length>0){t=Object.create(null);for(let e=0,a=i.length;e=0){const a=i[t];a&&(s[e]=a)}}s.length>0&&(this.properties.builtInEncoding=s)}}function getUint32(e,t){return(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0}function getUint16(e,t){return e[t]<<8|e[t+1]}function getInt16(e,t){return(e[t]<<24|e[t+1]<<16)>>16}function getInt8(e,t){return e[t]<<24>>24}function getFloat214(e,t){return getInt16(e,t)/16384}function getSubroutineBias(e){const t=e.length;let i=32768;t<1240?i=107:t<33900&&(i=1131);return i}function parseCmap(e,t,i){const a=1===getUint16(e,t+2)?getUint32(e,t+8):getUint32(e,t+16),s=getUint16(e,t+a);let r,n,g;if(4===s){getUint16(e,t+a+2);const i=getUint16(e,t+a+6)>>1;n=t+a+14;r=[];for(g=0;g>1;i0;)C.push({flags:r})}for(i=0;i>1;p=!0;break;case 4:n+=s.pop();moveTo(r,n);p=!0;break;case 5:for(;s.length>0;){r+=s.shift();n+=s.shift();lineTo(r,n)}break;case 6:for(;s.length>0;){r+=s.shift();lineTo(r,n);if(0===s.length)break;n+=s.shift();lineTo(r,n)}break;case 7:for(;s.length>0;){n+=s.shift();lineTo(r,n);if(0===s.length)break;r+=s.shift();lineTo(r,n)}break;case 8:for(;s.length>0;){c=r+s.shift();h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+s.shift();bezierCurveTo(c,h,C,l,r,n)}break;case 10:d=s.pop();f=null;if(i.isCFFCIDFont){const e=i.fdSelect.getFDIndex(a);if(e>=0&&eMath.abs(n-t)?r+=s.shift():n+=s.shift();bezierCurveTo(c,h,C,l,r,n);break;default:throw new FormatError(`unknown operator: 12 ${m}`)}break;case 14:if(s.length>=4){const e=s.pop(),a=s.pop();n=s.pop();r=s.pop();t.save();t.translate(r,n);let g=lookupCmap(i.cmap,String.fromCharCode(i.glyphNameMap[hi[e]]));compileCharString(i.glyphs[g.glyphId],t,i,g.glyphId);t.restore();g=lookupCmap(i.cmap,String.fromCharCode(i.glyphNameMap[hi[a]]));compileCharString(i.glyphs[g.glyphId],t,i,g.glyphId)}return;case 19:case 20:g+=s.length>>1;o+=g+7>>3;p=!0;break;case 21:n+=s.pop();r+=s.pop();moveTo(r,n);p=!0;break;case 22:r+=s.pop();moveTo(r,n);p=!0;break;case 24:for(;s.length>2;){c=r+s.shift();h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+s.shift();bezierCurveTo(c,h,C,l,r,n)}r+=s.shift();n+=s.shift();lineTo(r,n);break;case 25:for(;s.length>6;){r+=s.shift();n+=s.shift();lineTo(r,n)}c=r+s.shift();h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+s.shift();bezierCurveTo(c,h,C,l,r,n);break;case 26:s.length%2&&(r+=s.shift());for(;s.length>0;){c=r;h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C;n=l+s.shift();bezierCurveTo(c,h,C,l,r,n)}break;case 27:s.length%2&&(n+=s.shift());for(;s.length>0;){c=r+s.shift();h=n;C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l;bezierCurveTo(c,h,C,l,r,n)}break;case 28:s.push((e[o]<<24|e[o+1]<<16)>>16);o+=2;break;case 29:d=s.pop()+i.gsubrsBias;f=i.gsubrs[d];f&&parse(f);break;case 30:for(;s.length>0;){c=r;h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n);if(0===s.length)break;c=r+s.shift();h=n;C=c+s.shift();l=h+s.shift();n=l+s.shift();r=C+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n)}break;case 31:for(;s.length>0;){c=r+s.shift();h=n;C=c+s.shift();l=h+s.shift();n=l+s.shift();r=C+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n);if(0===s.length)break;c=r;h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n)}break;default:if(m<32)throw new FormatError(`unknown operator: ${m}`);if(m<247)s.push(m-139);else if(m<251)s.push(256*(m-247)+e[o++]+108);else if(m<255)s.push(256*-(m-251)-e[o++]-108);else{s.push((e[o]<<24|e[o+1]<<16|e[o+2]<<8|e[o+3])/65536);o+=4}}p&&(s.length=0)}}(e)}class Commands{cmds=[];transformStack=[];currentTransform=[1,0,0,1,0,0];add(e,t){if(t){const[i,a,s,r,n,g]=this.currentTransform;for(let e=0,o=t.length;e=0&&e2*getUint16(e,t)}const r=[];let n=s(t,0);for(let i=a;ie+(t.getSize()+3&-4)),0)}write(){const e=this.getSize(),t=new DataView(new ArrayBuffer(e)),i=e>131070,a=i?4:2,s=new DataView(new ArrayBuffer((this.glyphs.length+1)*a));i?s.setUint32(0,0):s.setUint16(0,0);let r=0,n=0;for(const e of this.glyphs){r+=e.write(r,t);r=r+3&-4;n+=a;i?s.setUint32(n,r):s.setUint16(n,r>>1)}return{isLocationLong:i,loca:new Uint8Array(s.buffer),glyf:new Uint8Array(t.buffer)}}scale(e){for(let t=0,i=this.glyphs.length;te+t.getSize()),0);return this.header.getSize()+e}write(e,t){if(!this.header)return 0;const i=e;e+=this.header.write(e,t);if(this.simple)e+=this.simple.write(e,t);else for(const i of this.composites)e+=i.write(e,t);return e-i}scale(e){if(!this.header)return;const t=(this.header.xMin+this.header.xMax)/2;this.header.scale(t,e);if(this.simple)this.simple.scale(t,e);else for(const i of this.composites)i.scale(t,e)}}class GlyphHeader{constructor({numberOfContours:e,xMin:t,yMin:i,xMax:a,yMax:s}){this.numberOfContours=e;this.xMin=t;this.yMin=i;this.xMax=a;this.yMax=s}static parse(e,t){return[10,new GlyphHeader({numberOfContours:t.getInt16(e),xMin:t.getInt16(e+2),yMin:t.getInt16(e+4),xMax:t.getInt16(e+6),yMax:t.getInt16(e+8)})]}getSize(){return 10}write(e,t){t.setInt16(e,this.numberOfContours);t.setInt16(e+2,this.xMin);t.setInt16(e+4,this.yMin);t.setInt16(e+6,this.xMax);t.setInt16(e+8,this.yMax);return 10}scale(e,t){this.xMin=Math.round(e+(this.xMin-e)*t);this.xMax=Math.round(e+(this.xMax-e)*t)}}class Contour{constructor({flags:e,xCoordinates:t,yCoordinates:i}){this.xCoordinates=t;this.yCoordinates=i;this.flags=e}}class SimpleGlyph{constructor({contours:e,instructions:t}){this.contours=e;this.instructions=t}static parse(e,t,i){const a=[];for(let s=0;s255?e+=2:g>0&&(e+=1);t=r;g=Math.abs(n-i);g>255?e+=2:g>0&&(e+=1);i=n}}return e}write(e,t){const i=e,a=[],s=[],r=[];let n=0,g=0;for(const i of this.contours){for(let e=0,t=i.xCoordinates.length;e=0?18:2;a.push(e)}else a.push(c)}n=o;const C=i.yCoordinates[e];c=C-g;if(0===c){t|=32;s.push(0)}else{const e=Math.abs(c);if(e<=255){t|=c>=0?36:4;s.push(e)}else s.push(c)}g=C;r.push(t)}t.setUint16(e,a.length-1);e+=2}t.setUint16(e,this.instructions.length);e+=2;if(this.instructions.length){new Uint8Array(t.buffer,0,t.buffer.byteLength).set(this.instructions,e);e+=this.instructions.length}for(const i of r)t.setUint8(e++,i);for(let i=0,s=a.length;i=-128&&this.argument1<=127&&this.argument2>=-128&&this.argument2<=127||(e+=2):this.argument1>=0&&this.argument1<=255&&this.argument2>=0&&this.argument2<=255||(e+=2);return e}write(e,t){const i=e;2&this.flags?this.argument1>=-128&&this.argument1<=127&&this.argument2>=-128&&this.argument2<=127||(this.flags|=1):this.argument1>=0&&this.argument1<=255&&this.argument2>=0&&this.argument2<=255||(this.flags|=1);t.setUint16(e,this.flags);t.setUint16(e+2,this.glyphIndex);e+=4;if(1&this.flags){if(2&this.flags){t.setInt16(e,this.argument1);t.setInt16(e+2,this.argument2)}else{t.setUint16(e,this.argument1);t.setUint16(e+2,this.argument2)}e+=4}else{t.setUint8(e,this.argument1);t.setUint8(e+1,this.argument2);e+=2}if(256&this.flags){t.setUint16(e,this.instructions.length);e+=2;if(this.instructions.length){new Uint8Array(t.buffer,0,t.buffer.byteLength).set(this.instructions,e);e+=this.instructions.length}}return e-i}scale(e,t){}}function writeInt16(e,t,i){e[t]=i>>8&255;e[t+1]=255&i}function writeInt32(e,t,i){e[t]=i>>24&255;e[t+1]=i>>16&255;e[t+2]=i>>8&255;e[t+3]=255&i}function writeData(e,t,i){if(i instanceof Uint8Array)e.set(i,t);else if("string"==typeof i)for(let a=0,s=i.length;ai;){i<<=1;a++}const s=i*t;return{range:s,entry:a,rangeShift:t*e-s}}toArray(){let e=this.sfnt;const t=this.tables,i=Object.keys(t);i.sort();const a=i.length;let s,r,n,g,o,c=12+16*a;const C=[c];for(s=0;s>>0;C.push(c)}const h=new Uint8Array(c);for(s=0;s>>0}writeInt32(h,c+4,e);writeInt32(h,c+8,C[s]);writeInt32(h,c+12,t[o].length);c+=16}return h}addTable(e,t){if(e in this.tables)throw new Error("Table "+e+" already exists");this.tables[e]=t}}const Zi=[4],Vi=[5],zi=[6],_i=[7],$i=[8],Aa=[12,35],ea=[14],ta=[21],ia=[22],aa=[30],sa=[31];class Type1CharString{constructor(){this.width=0;this.lsb=0;this.flexing=!1;this.output=[];this.stack=[]}convert(e,t,i){const a=e.length;let s,r,n,g=!1;for(let o=0;oa)return!0;const s=a-e;for(let e=s;e>8&255,255&t);else{t=65536*t|0;this.output.push(255,t>>24&255,t>>16&255,t>>8&255,255&t)}}this.output.push(...t);i?this.stack.splice(s,e):this.stack.length=0;return!1}}function isHexDigit(e){return e>=48&&e<=57||e>=65&&e<=70||e>=97&&e<=102}function decrypt(e,t,i){if(i>=e.length)return new Uint8Array(0);let a,s,r=0|t;for(a=0;a>8;r=52845*(t+r)+22719&65535}return g}function isSpecial(e){return 47===e||91===e||93===e||123===e||125===e||40===e||41===e}class Type1Parser{constructor(e,t,i){if(t){const t=e.getBytes(),i=!((isHexDigit(t[0])||isWhiteSpace(t[0]))&&isHexDigit(t[1])&&isHexDigit(t[2])&&isHexDigit(t[3])&&isHexDigit(t[4])&&isHexDigit(t[5])&&isHexDigit(t[6])&&isHexDigit(t[7]));e=new Stream(i?decrypt(t,55665,4):function decryptAscii(e,t,i){let a=0|t;const s=e.length,r=new Uint8Array(s>>>1);let n,g;for(n=0,g=0;n>8;a=52845*(e+a)+22719&65535}}return r.slice(i,g)}(t,55665,4))}this.seacAnalysisEnabled=!!i;this.stream=e;this.nextChar()}readNumberArray(){this.getToken();const e=[];for(;;){const t=this.getToken();if(null===t||"]"===t||"}"===t)break;e.push(parseFloat(t||0))}return e}readNumber(){const e=this.getToken();return parseFloat(e||0)}readInt(){const e=this.getToken();return 0|parseInt(e||0,10)}readBoolean(){return"true"===this.getToken()?1:0}nextChar(){return this.currentChar=this.stream.getByte()}prevChar(){this.stream.skip(-2);return this.currentChar=this.stream.getByte()}getToken(){let e=!1,t=this.currentChar;for(;;){if(-1===t)return null;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(!isWhiteSpace(t))break;t=this.nextChar()}if(isSpecial(t)){this.nextChar();return String.fromCharCode(t)}let i="";do{i+=String.fromCharCode(t);t=this.nextChar()}while(t>=0&&!isWhiteSpace(t)&&!isSpecial(t));return i}readCharStrings(e,t){return-1===t?e:decrypt(e,4330,t)}extractFontProgram(e){const t=this.stream,i=[],a=[],s=Object.create(null);s.lenIV=4;const r={subrs:[],charstrings:[],properties:{privateData:s}};let n,g,o,c;for(;null!==(n=this.getToken());)if("/"===n){n=this.getToken();switch(n){case"CharStrings":this.getToken();this.getToken();this.getToken();this.getToken();for(;;){n=this.getToken();if(null===n||"end"===n)break;if("/"!==n)continue;const e=this.getToken();g=this.readInt();this.getToken();o=g>0?t.getBytes(g):new Uint8Array(0);c=r.properties.privateData.lenIV;const i=this.readCharStrings(o,c);this.nextChar();n=this.getToken();"noaccess"===n?this.getToken():"/"===n&&this.prevChar();a.push({glyph:e,encoded:i})}break;case"Subrs":this.readInt();this.getToken();for(;"dup"===this.getToken();){const e=this.readInt();g=this.readInt();this.getToken();o=g>0?t.getBytes(g):new Uint8Array(0);c=r.properties.privateData.lenIV;const a=this.readCharStrings(o,c);this.nextChar();n=this.getToken();"noaccess"===n&&this.getToken();i[e]=a}break;case"BlueValues":case"OtherBlues":case"FamilyBlues":case"FamilyOtherBlues":const e=this.readNumberArray();e.length>0&&e.length,0;break;case"StemSnapH":case"StemSnapV":r.properties.privateData[n]=this.readNumberArray();break;case"StdHW":case"StdVW":r.properties.privateData[n]=this.readNumberArray()[0];break;case"BlueShift":case"lenIV":case"BlueFuzz":case"BlueScale":case"LanguageGroup":r.properties.privateData[n]=this.readNumber();break;case"ExpansionFactor":r.properties.privateData[n]=this.readNumber()||.06;break;case"ForceBold":r.properties.privateData[n]=this.readBoolean()}}for(const{encoded:t,glyph:s}of a){const a=new Type1CharString,n=a.convert(t,i,this.seacAnalysisEnabled);let g=a.output;n&&(g=[14]);const o={glyphName:s,charstring:g,width:a.width,lsb:a.lsb,seac:a.seac};".notdef"===s?r.charstrings.unshift(o):r.charstrings.push(o);if(e.builtInEncoding){const t=e.builtInEncoding.indexOf(s);t>-1&&void 0===e.widths[t]&&t>=e.firstChar&&t<=e.lastChar&&(e.widths[t]=a.width)}}return r}extractFontHeader(e){let t;for(;null!==(t=this.getToken());)if("/"===t){t=this.getToken();switch(t){case"FontMatrix":const i=this.readNumberArray();e.fontMatrix=i;break;case"Encoding":const a=this.getToken();let s;if(/^\d+$/.test(a)){s=[];const e=0|parseInt(a,10);this.getToken();for(let i=0;i=s){n+=i;for(;n=0&&(a[e]=s)}}return type1FontGlyphMapping(e,a,i)}hasGlyphId(e){if(e<0||e>=this.numGlyphs)return!1;if(0===e)return!0;return this.charstrings[e-1].charstring.length>0}getSeacs(e){const t=[];for(let i=0,a=e.length;i0;e--)t[e]-=t[e-1];Q.setByName(e,t)}r.topDict.privateDict=Q;const u=new CFFIndex;for(C=0,h=a.length;C0&&e.toUnicode.amend(t)}class fonts_Glyph{constructor(e,t,i,a,s,r,n,g,o){this.originalCharCode=e;this.fontChar=t;this.unicode=i;this.accent=a;this.width=s;this.vmetric=r;this.operatorListId=n;this.isSpace=g;this.isInFont=o}get category(){return shadow(this,"category",function getCharUnicodeCategory(e){const t=ki.get(e);if(t)return t;const i=e.match(Si),a={isWhitespace:!!i?.[1],isZeroWidthDiacritic:!!i?.[2],isInvisibleFormatMark:!!i?.[3]};ki.set(e,a);return a}(this.unicode),!0)}}function int16(e,t){return(e<<8)+t}function writeSignedInt16(e,t,i){e[t+1]=i;e[t]=i>>>8}function signedInt16(e,t){const i=(e<<8)+t;return 32768&i?i-65536:i}function string16(e){return String.fromCharCode(e>>8&255,255&e)}function safeString16(e){e>32767?e=32767:e<-32768&&(e=-32768);return String.fromCharCode(e>>8&255,255&e)}function isTrueTypeCollectionFile(e){return"ttcf"===bytesToString(e.peekBytes(4))}function getFontFileType(e,{type:t,subtype:i,composite:a}){let s,r;if(function isTrueTypeFile(e){const t=e.peekBytes(4);return 65536===readUint32(t,0)||"true"===bytesToString(t)}(e)||isTrueTypeCollectionFile(e))s=a?"CIDFontType2":"TrueType";else if(function isOpenTypeFile(e){return"OTTO"===bytesToString(e.peekBytes(4))}(e))s=a?"CIDFontType2":"OpenType";else if(function isType1File(e){const t=e.peekBytes(2);return 37===t[0]&&33===t[1]||128===t[0]&&1===t[1]}(e))s=a?"CIDFontType0":"MMType1"===t?"MMType1":"Type1";else if(function isCFFFile(e){const t=e.peekBytes(4);return t[0]>=1&&t[3]>=1&&t[3]<=4}(e))if(a){s="CIDFontType0";r="CIDFontType0C"}else{s="MMType1"===t?"MMType1":"Type1";r="Type1C"}else{warn("getFontFileType: Unable to detect correct font file Type/Subtype.");s=t;r=i}return[s,r]}function applyStandardFontGlyphMap(e,t){for(const i in t)e[+i]=t[i]}function buildToFontChar(e,t,i){const a=[];let s;for(let i=0,r=e.length;iC){o++;if(o>=ra.length){warn("Ran out of space in font private use area.");break}c=ra[o][0];C=ra[o][1]}const E=c++;0===Q&&(Q=i);let u=a.get(l);"string"==typeof u&&(u=u.codePointAt(0));if(u&&!(h=u,ra[0][0]<=h&&h<=ra[0][1]||ra[1][0]<=h&&h<=ra[1][1])&&!g.has(Q)){r.set(u,Q);g.add(Q)}s[E]=Q;n[l]=E}var h;return{toFontChar:n,charCodeToGlyphId:s,toUnicodeExtraMap:r,nextAvailableFontCharCode:c}}function createCmapTable(e,t,i){const a=function getRanges(e,t,i){const a=[];for(const t in e)e[t]>=i||a.push({fontCharCode:0|t,glyphId:e[t]});if(t)for(const[e,s]of t)s>=i||a.push({fontCharCode:e,glyphId:s});0===a.length&&a.push({fontCharCode:0,glyphId:0});a.sort((function fontGetRangesSort(e,t){return e.fontCharCode-t.fontCharCode}));const s=[],r=a.length;for(let e=0;e65535?2:1;let r,n,g,o,c="\0\0"+string16(s)+"\0\0"+string32(4+8*s);for(r=a.length-1;r>=0&&!(a[r][0]<=65535);--r);const C=r+1;a[r][0]<65535&&65535===a[r][1]&&(a[r][1]=65534);const h=a[r][1]<65535?1:0,l=C+h,Q=OpenTypeFileBuilder.getSearchParams(l,2);let E,u,d,f,p="",m="",y="",w="",D="",b=0;for(r=0,n=C;r0){m+="ÿÿ";p+="ÿÿ";y+="\0";w+="\0\0"}const F="\0\0"+string16(2*l)+string16(Q.range)+string16(Q.entry)+string16(Q.rangeShift)+m+"\0\0"+p+y+w+D;let S="",k="";if(s>1){c+="\0\0\n"+string32(4+8*s+4+F.length);S="";for(r=0,n=a.length;re||!g)&&(g=e);o 123 are reserved for internal usage");n|=1<65535&&(o=65535)}else{g=0;o=255}const C=e.bbox||[0,0,0,0],h=i.unitsPerEm||(e.fontMatrix?1/Math.max(...e.fontMatrix.slice(0,4).map(Math.abs)):1e3),l=e.ascentScaled?1:h/na,Q=i.ascent||Math.round(l*(e.ascent||C[3]));let E=i.descent||Math.round(l*(e.descent||C[1]));E>0&&e.descent>0&&C[1]<0&&(E=-E);const u=i.yMax||Q,d=-i.yMin||-E;return"\0$ô\0\0\0Š»\0\0\0ŒŠ»\0\0ß\x001\0\0\0\0"+String.fromCharCode(e.fixedPitch?9:0)+"\0\0\0\0\0\0"+string32(a)+string32(s)+string32(r)+string32(n)+"*21*"+string16(e.italicAngle?1:0)+string16(g||e.firstChar)+string16(o||e.lastChar)+string16(Q)+string16(E)+"\0d"+string16(u)+string16(d)+"\0\0\0\0\0\0\0\0"+string16(e.xHeight)+string16(e.capHeight)+string16(0)+string16(g||e.firstChar)+"\0"}function createPostTable(e){return"\0\0\0"+string32(Math.floor(65536*e.italicAngle))+"\0\0\0\0"+string32(e.fixedPitch?1:0)+"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}function createPostscriptName(e){return e.replaceAll(/[^\x21-\x7E]|[[\](){}<>/%]/g,"").slice(0,63)}function createNameTable(e,t){t||(t=[[],[]]);const i=[t[0][0]||"Original licence",t[0][1]||e,t[0][2]||"Unknown",t[0][3]||"uniqueID",t[0][4]||e,t[0][5]||"Version 0.11",t[0][6]||createPostscriptName(e),t[0][7]||"Unknown",t[0][8]||"Unknown",t[0][9]||"Unknown"],a=[];let s,r,n,g,o;for(s=0,r=i.length;s0;if((n||g)&&"CIDFontType2"===i&&this.cidEncoding.startsWith("Identity-")){const i=e.cidToGidMap,a=[];applyStandardFontGlyphMap(a,Oi());/Arial-?Black/i.test(t)?applyStandardFontGlyphMap(a,Pi()):/Calibri/i.test(t)&&applyStandardFontGlyphMap(a,Wi());if(i){for(const e in a){const t=a[e];void 0!==i[t]&&(a[+e]=i[t])}i.length!==this.toUnicode.length&&e.hasIncludedToUnicodeMap&&this.toUnicode instanceof IdentityToUnicodeMap&&this.toUnicode.forEach((function(e,t){const s=a[e];void 0===i[s]&&(a[+e]=t)}))}this.toUnicode instanceof IdentityToUnicodeMap||this.toUnicode.forEach((function(e,t){a[+e]=t}));this.toFontChar=a;this.toUnicode=new ToUnicodeMap(a)}else if(/Symbol/i.test(a))this.toFontChar=buildToFontChar(Bi,wi(),this.differences);else if(/Dingbats/i.test(a))this.toFontChar=buildToFontChar(Qi,Di(),this.differences);else if(n||g){const e=buildToFontChar(this.defaultEncoding,wi(),this.differences);"CIDFontType2"!==i||this.cidEncoding.startsWith("Identity-")||this.toUnicode instanceof IdentityToUnicodeMap||this.toUnicode.forEach((function(t,i){e[+t]=i}));this.toFontChar=e}else{const e=wi(),i=[];this.toUnicode.forEach(((t,a)=>{if(!this.composite){const i=getUnicodeForGlyph(this.differences[t]||this.defaultEncoding[t],e);-1!==i&&(a=i)}i[+t]=a}));this.composite&&this.toUnicode instanceof IdentityToUnicodeMap&&/Tahoma|Verdana/i.test(t)&&applyStandardFontGlyphMap(i,Oi());this.toFontChar=i}amendFallbackToUnicode(e);this.loadedName=a.split("-",1)[0]}checkAndRepair(e,t,i){const a=["OS/2","cmap","head","hhea","hmtx","maxp","name","post","loca","glyf","fpgm","prep","cvt ","CFF "];function readTables(e,t){const i=Object.create(null);i["OS/2"]=null;i.cmap=null;i.head=null;i.hhea=null;i.hmtx=null;i.maxp=null;i.name=null;i.post=null;for(let s=0;s>>0,a=e.getInt32()>>>0,s=e.getInt32()>>>0,r=e.pos;e.pos=e.start||0;e.skip(a);const n=e.getBytes(s);e.pos=r;if("head"===t){n[8]=n[9]=n[10]=n[11]=0;n[17]|=32}return{tag:t,checksum:i,length:s,offset:a,data:n}}function readOpenTypeHeader(e){return{version:e.getString(4),numTables:e.getUint16(),searchRange:e.getUint16(),entrySelector:e.getUint16(),rangeShift:e.getUint16()}}function sanitizeGlyph(e,t,i,a,s,r){const n={length:0,sizeOfInstructions:0};if(t<0||t>=e.length||i>e.length||i-t<=12)return n;const g=e.subarray(t,i),o=signedInt16(g[2],g[3]),c=signedInt16(g[4],g[5]),C=signedInt16(g[6],g[7]),h=signedInt16(g[8],g[9]);if(o>C){writeSignedInt16(g,2,C);writeSignedInt16(g,6,o)}if(c>h){writeSignedInt16(g,4,h);writeSignedInt16(g,8,c)}const l=signedInt16(g[0],g[1]);if(l<0){if(l<-1)return n;a.set(g,s);n.length=g.length;return n}let Q,E=10,u=0;for(Q=0;Qg.length)return n;if(!r&&f>0){a.set(g.subarray(0,d),s);a.set([0,0],s+d);a.set(g.subarray(p,y),s+d+2);y-=f;g.length-y>3&&(y=y+3&-4);n.length=y;return n}if(g.length-y>3){y=y+3&-4;a.set(g.subarray(0,y),s);n.length=y;return n}a.set(g,s);n.length=g.length;return n}function readNameTable(e){const i=(t.start||0)+e.offset;t.pos=i;const a=[[],[]],s=[],r=e.length,n=i+r;if(0!==t.getUint16()||r<6)return[a,s];const g=t.getUint16(),o=t.getUint16();let c,C;for(c=0;cn)continue;t.pos=r;const g=e.name;if(e.encoding){let i="";for(let a=0,s=e.length;a0&&(c+=e-1)}}else{if(d||p){warn("TT: nested FDEFs not allowed");u=!0}d=!0;h=c;n=l.pop();t.functionsDefined[n]={data:o,i:c}}else if(!d&&!p){n=l.at(-1);if(isNaN(n))info("TT: CALL empty stack (or invalid entry).");else{t.functionsUsed[n]=!0;if(n in t.functionsStackDeltas){const e=l.length+t.functionsStackDeltas[n];if(e<0){warn("TT: CALL invalid functions stack delta.");t.hintsValid=!1;return}l.length=e}else if(n in t.functionsDefined&&!E.includes(n)){Q.push({data:o,i:c,stackTop:l.length-1});E.push(n);g=t.functionsDefined[n];if(!g){warn("TT: CALL non-existent function");t.hintsValid=!1;return}o=g.data;c=g.i}}}if(!d&&!p){let t=0;e<=142?t=s[e]:e>=192&&e<=223?t=-1:e>=224&&(t=-2);if(e>=113&&e<=117){a=l.pop();isNaN(a)||(t=2*-a)}for(;t<0&&l.length>0;){l.pop();t++}for(;t>0;){l.push(NaN);t--}}}t.tooComplexToFollowFunctions=u;const m=[o];c>o.length&&m.push(new Uint8Array(c-o.length));if(h>C){warn("TT: complementing a missing function tail");m.push(new Uint8Array([34,45]))}!function foldTTTable(e,t){if(t.length>1){let i,a,s=0;for(i=0,a=t.length;i>>0,r=[];for(let t=0;t>>0);const n={ttcTag:t,majorVersion:i,minorVersion:a,numFonts:s,offsetTable:r};switch(i){case 1:return n;case 2:n.dsigTag=e.getInt32()>>>0;n.dsigLength=e.getInt32()>>>0;n.dsigOffset=e.getInt32()>>>0;return n}throw new FormatError(`Invalid TrueType Collection majorVersion: ${i}.`)}(e),s=t.split("+");let r;for(let n=0;n0||!(i.cMap instanceof IdentityCMap));if("OTTO"===r.version&&!t||!n.head||!n.hhea||!n.maxp||!n.post){o=new Stream(n["CFF "].data);g=new CFFFont(o,i);adjustWidths(i);return this.convert(e,g,i)}delete n.glyf;delete n.loca;delete n.fpgm;delete n.prep;delete n["cvt "];this.isOpenType=!0}if(!n.maxp)throw new FormatError('Required "maxp" table is not found');t.pos=(t.start||0)+n.maxp.offset;let C=t.getInt32();const h=t.getUint16();if(65536!==C&&20480!==C){if(6===n.maxp.length)C=20480;else{if(!(n.maxp.length>=32))throw new FormatError('"maxp" table has a wrong version number');C=65536}!function writeUint32(e,t,i){e[t+3]=255&i;e[t+2]=i>>>8;e[t+1]=i>>>16;e[t]=i>>>24}(n.maxp.data,0,C)}if(i.scaleFactors?.length===h&&c){const{scaleFactors:e}=i,t=int16(n.head.data[50],n.head.data[51]),a=new GlyfTable({glyfTable:n.glyf.data,isGlyphLocationsLong:t,locaTable:n.loca.data,numGlyphs:h});a.scale(e);const{glyf:s,loca:r,isLocationLong:g}=a.write();n.glyf.data=s;n.loca.data=r;if(g!==!!t){n.head.data[50]=0;n.head.data[51]=g?1:0}const o=n.hmtx.data;for(let t=0;t>8&255;o[i+1]=255&a;writeSignedInt16(o,i+2,Math.round(e[t]*signedInt16(o[i+2],o[i+3])))}}let l=h+1,Q=!0;if(l>65535){Q=!1;l=h;warn("Not enough space in glyfs to duplicate first glyph.")}let E=0,u=0;if(C>=65536&&n.maxp.length>=32){t.pos+=8;if(t.getUint16()>2){n.maxp.data[14]=0;n.maxp.data[15]=2}t.pos+=4;E=t.getUint16();t.pos+=4;u=t.getUint16()}n.maxp.data[4]=l>>8;n.maxp.data[5]=255&l;const d=function sanitizeTTPrograms(e,t,i,a){const s={functionsDefined:[],functionsUsed:[],functionsStackDeltas:[],tooComplexToFollowFunctions:!1,hintsValid:!0};e&&sanitizeTTProgram(e,s);t&&sanitizeTTProgram(t,s);e&&function checkInvalidFunctions(e,t){if(!e.tooComplexToFollowFunctions)if(e.functionsDefined.length>t){warn("TT: more functions defined than expected");e.hintsValid=!1}else for(let i=0,a=e.functionsUsed.length;it){warn("TT: invalid function id: "+i);e.hintsValid=!1;return}if(e.functionsUsed[i]&&!e.functionsDefined[i]){warn("TT: undefined function: "+i);e.hintsValid=!1;return}}}(s,a);if(i&&1&i.length){const e=new Uint8Array(i.length+1);e.set(i.data);i.data=e}return s.hintsValid}(n.fpgm,n.prep,n["cvt "],E);if(!d){delete n.fpgm;delete n.prep;delete n["cvt "]}!function sanitizeMetrics(e,t,i,a,s,r){if(!t){i&&(i.data=null);return}e.pos=(e.start||0)+t.offset;e.pos+=4;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;const n=e.getUint16();e.pos+=8;e.pos+=2;let g=e.getUint16();if(0!==n){if(!(2&int16(a.data[44],a.data[45]))){t.data[22]=0;t.data[23]=0}}if(g>s){info(`The numOfMetrics (${g}) should not be greater than the numGlyphs (${s}).`);g=s;t.data[34]=(65280&g)>>8;t.data[35]=255&g}const o=s-g-(i.length-4*g>>1);if(o>0){const e=new Uint8Array(i.length+2*o);e.set(i.data);if(r){e[i.length]=i.data[2];e[i.length+1]=i.data[3]}i.data=e}}(t,n.hhea,n.hmtx,n.head,l,Q);if(!n.head)throw new FormatError('Required "head" table is not found');!function sanitizeHead(e,t,i){const a=e.data,s=function int32(e,t,i,a){return(e<<24)+(t<<16)+(i<<8)+a}(a[0],a[1],a[2],a[3]);if(s>>16!=1){info("Attempting to fix invalid version in head table: "+s);a[0]=0;a[1]=1;a[2]=0;a[3]=0}const r=int16(a[50],a[51]);if(r<0||r>1){info("Attempting to fix invalid indexToLocFormat in head table: "+r);const e=t+1;if(i===e<<1){a[50]=0;a[51]=0}else{if(i!==e<<2)throw new FormatError("Could not fix indexToLocFormat: "+r);a[50]=0;a[51]=1}}}(n.head,h,c?n.loca.length:0);let f=Object.create(null);if(c){const e=int16(n.head.data[50],n.head.data[51]),t=function sanitizeGlyphLocations(e,t,i,a,s,r,n){let g,o,c;if(a){g=4;o=function fontItemDecodeLong(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]};c=function fontItemEncodeLong(e,t,i){e[t]=i>>>24&255;e[t+1]=i>>16&255;e[t+2]=i>>8&255;e[t+3]=255&i}}else{g=2;o=function fontItemDecode(e,t){return e[t]<<9|e[t+1]<<1};c=function fontItemEncode(e,t,i){e[t]=i>>9&255;e[t+1]=i>>1&255}}const C=r?i+1:i,h=g*(1+C),l=new Uint8Array(h);l.set(e.data.subarray(0,h));e.data=l;const Q=t.data,E=Q.length,u=new Uint8Array(E);let d,f;const p=[];for(d=0,f=0;dE&&(e=E);p.push({index:d,offset:e,endOffset:0})}p.sort(((e,t)=>e.offset-t.offset));for(d=0;de.index-t.index));for(d=0;dn&&(n=e.sizeOfInstructions);w+=t;c(l,f,w)}if(0===w){const e=new Uint8Array([0,1,0,0,0,0,0,0,0,0,0,0,0,0,49,0]);for(d=0,f=g;di+w)t.data=u.subarray(0,i+w);else{t.data=new Uint8Array(i+w);t.data.set(u.subarray(0,w))}t.data.set(u.subarray(0,i),w);c(e.data,l.length-g,w+i)}else t.data=u.subarray(0,w);return{missingGlyphs:y,maxSizeOfInstructions:n}}(n.loca,n.glyf,h,e,d,Q,u);f=t.missingGlyphs;if(C>=65536&&n.maxp.length>=32){n.maxp.data[26]=t.maxSizeOfInstructions>>8;n.maxp.data[27]=255&t.maxSizeOfInstructions}}if(!n.hhea)throw new FormatError('Required "hhea" table is not found');if(0===n.hhea.data[10]&&0===n.hhea.data[11]){n.hhea.data[10]=255;n.hhea.data[11]=255}const p={unitsPerEm:int16(n.head.data[18],n.head.data[19]),yMax:signedInt16(n.head.data[42],n.head.data[43]),yMin:signedInt16(n.head.data[38],n.head.data[39]),ascent:signedInt16(n.hhea.data[4],n.hhea.data[5]),descent:signedInt16(n.hhea.data[6],n.hhea.data[7]),lineGap:signedInt16(n.hhea.data[8],n.hhea.data[9])};this.ascent=p.ascent/p.unitsPerEm;this.descent=p.descent/p.unitsPerEm;this.lineGap=p.lineGap/p.unitsPerEm;if(this.cssFontInfo?.lineHeight){this.lineHeight=this.cssFontInfo.metrics.lineHeight;this.lineGap=this.cssFontInfo.metrics.lineGap}else this.lineHeight=this.ascent-this.descent+this.lineGap;n.post&&function readPostScriptTable(e,i,a){const s=(t.start||0)+e.offset;t.pos=s;const r=s+e.length,n=t.getInt32();t.skip(28);let g,o,c=!0;switch(n){case 65536:g=Hi;break;case 131072:const e=t.getUint16();if(e!==a){c=!1;break}const s=[];for(o=0;o=32768){c=!1;break}s.push(e)}if(!c)break;const C=[],h=[];for(;t.pos65535)throw new FormatError("Max size of CID is 65,535");let s=-1;t?s=a:void 0!==e[a]&&(s=e[a]);s>=0&&s>>0;let C=!1;if(g?.platformId!==s||g?.encodingId!==r){if(0!==s||0!==r&&1!==r&&3!==r)if(1===s&&0===r)C=!0;else if(3!==s||1!==r||!a&&g){if(i&&3===s&&0===r){C=!0;let i=!0;if(e>3;e.push(a);i=Math.max(a,i)}const a=[];for(let e=0;e<=i;e++)a.push({firstCode:t.getUint16(),entryCount:t.getUint16(),idDelta:signedInt16(t.getByte(),t.getByte()),idRangePos:t.pos+t.getUint16()});for(let i=0;i<256;i++)if(0===e[i]){t.pos=a[0].idRangePos+2*i;Q=t.getUint16();h.push({charCode:i,glyphId:Q})}else{const s=a[e[i]];for(l=0;l>1;t.skip(6);const i=[];let a;for(a=0;a>1)-(e-a);s.offsetIndex=n;g=Math.max(g,n+s.end-s.start+1)}else s.offsetIndex=-1}const o=[];for(l=0;l>>0;for(l=0;l>>0,i=t.getInt32()>>>0;let a=t.getInt32()>>>0;for(let t=e;t<=i;t++)h.push({charCode:t,glyphId:a++})}}}h.sort((function(e,t){return e.charCode-t.charCode}));for(let e=1;e=61440&&t<=61695&&(t&=255);m[t]=e.glyphId}else for(const e of r)m[e.charCode]=e.glyphId;if(i.glyphNames&&(g.length||this.differences.length))for(let e=0;e<256;++e){if(!o&&void 0!==m[e])continue;const t=this.differences[e]||g[e];if(!t)continue;const a=i.glyphNames.indexOf(t);a>0&&hasGlyph(a)&&(m[e]=a)}}0===m.length&&(m[0]=0);let y=l-1;Q||(y=0);if(!i.cssFontInfo){const e=adjustMapping(m,hasGlyph,y,this.toUnicode);this.toFontChar=e.toFontChar;n.cmap={tag:"cmap",data:createCmapTable(e.charCodeToGlyphId,e.toUnicodeExtraMap,l)};n["OS/2"]&&function validateOS2Table(e,t){t.pos=(t.start||0)+e.offset;const i=t.getUint16();t.skip(60);const a=t.getUint16();if(i<4&&768&a)return!1;if(t.getUint16()>t.getUint16())return!1;t.skip(6);if(0===t.getUint16())return!1;e.data[8]=e.data[9]=0;return!0}(n["OS/2"],t)||(n["OS/2"]={tag:"OS/2",data:createOS2Table(i,e.charCodeToGlyphId,p)})}if(!c)try{o=new Stream(n["CFF "].data);g=new CFFParser(o,i,Ri).parse();g.duplicateFirstGlyph();const e=new CFFCompiler(g);n["CFF "].data=e.compile()}catch{warn("Failed to compile font "+i.loadedName)}if(n.name){const[t,a]=readNameTable(n.name);n.name.data=createNameTable(e,t);this.psName=t[0][6]||null;i.composite||function adjustTrueTypeToUnicode(e,t,i){if(e.isInternalFont)return;if(e.hasIncludedToUnicodeMap)return;if(e.hasEncoding)return;if(e.toUnicode instanceof IdentityToUnicodeMap)return;if(!t)return;if(0===i.length)return;if(e.defaultEncoding===li)return;for(const e of i)if(!isWinNameRecord(e))return;const a=li,s=[],r=wi();for(const e in a){const t=a[e];if(""===t)continue;const i=r[t];void 0!==i&&(s[e]=String.fromCharCode(i))}s.length>0&&e.toUnicode.amend(s)}(i,this.isSymbolicFont,a)}else n.name={tag:"name",data:createNameTable(this.name)};const w=new OpenTypeFileBuilder(r.version);for(const e in n)w.addTable(e,n[e].data);return w.toArray()}convert(e,t,i){i.fixedPitch=!1;i.builtInEncoding&&function adjustType1ToUnicode(e,t){if(e.isInternalFont)return;if(e.hasIncludedToUnicodeMap)return;if(t===e.defaultEncoding)return;if(e.toUnicode instanceof IdentityToUnicodeMap)return;const i=[],a=wi();for(const s in t){if(e.hasEncoding&&(e.baseEncodingName||void 0!==e.differences[s]))continue;const r=getUnicodeForGlyph(t[s],a);-1!==r&&(i[s]=String.fromCharCode(r))}i.length>0&&e.toUnicode.amend(i)}(i,i.builtInEncoding);let s=1;t instanceof CFFFont&&(s=t.numGlyphs-1);const r=t.getGlyphMapping(i);let n=null,g=r,o=null;if(!i.cssFontInfo){n=adjustMapping(r,t.hasGlyphId.bind(t),s,this.toUnicode);this.toFontChar=n.toFontChar;g=n.charCodeToGlyphId;o=n.toUnicodeExtraMap}const c=t.numGlyphs;function getCharCodes(e,t){let i=null;for(const a in e)t===e[a]&&(i||=[]).push(0|a);return i}function createCharCode(e,t){for(const i in e)if(t===e[i])return 0|i;n.charCodeToGlyphId[n.nextAvailableFontCharCode]=t;return n.nextAvailableFontCharCode++}const C=t.seacs;if(n&&C?.length){const e=i.fontMatrix||a,s=t.getCharset(),g=Object.create(null);for(let t in C){t|=0;const i=C[t],a=hi[i[2]],o=hi[i[3]],c=s.indexOf(a),h=s.indexOf(o);if(c<0||h<0)continue;const l={x:i[0]*e[0]+i[1]*e[2]+e[4],y:i[0]*e[1]+i[1]*e[3]+e[5]},Q=getCharCodes(r,t);if(Q)for(const e of Q){const t=n.charCodeToGlyphId,i=createCharCode(t,c),a=createCharCode(t,h);g[e]={baseFontCharCode:i,accentFontCharCode:a,accentOffset:l}}}i.seacMap=g}const h=i.fontMatrix?1/Math.max(...i.fontMatrix.slice(0,4).map(Math.abs)):1e3,l=new OpenTypeFileBuilder("OTTO");l.addTable("CFF ",t.data);l.addTable("OS/2",createOS2Table(i,g));l.addTable("cmap",createCmapTable(g,o,c));l.addTable("head","\0\0\0\0\0\0\0\0\0\0_<õ\0\0"+safeString16(h)+"\0\0\0\0ž\v~'\0\0\0\0ž\v~'\0\0"+safeString16(i.descent)+"ÿ"+safeString16(i.ascent)+string16(i.italicAngle?2:0)+"\0\0\0\0\0\0\0");l.addTable("hhea","\0\0\0"+safeString16(i.ascent)+safeString16(i.descent)+"\0\0ÿÿ\0\0\0\0\0\0"+safeString16(i.capHeight)+safeString16(Math.tan(i.italicAngle)*i.xHeight)+"\0\0\0\0\0\0\0\0\0\0\0\0"+string16(c));l.addTable("hmtx",function fontFieldsHmtx(){const e=t.charstrings,i=t.cff?t.cff.widths:null;let a="\0\0\0\0";for(let t=1,s=c;t=65520&&e<=65535?0:e>=62976&&e<=63743?bi()[e]||e:173===e?45:e}(i)}this.isType3Font&&(s=i);let C=null;if(this.seacMap?.[e]){c=!0;const t=this.seacMap[e];i=t.baseFontCharCode;C={fontChar:String.fromCodePoint(t.accentFontCharCode),offset:t.accentOffset}}let h="";"number"==typeof i&&(i<=1114111?h=String.fromCodePoint(i):warn(`charToGlyph - invalid fontCharCode: ${i}`));if(this.missingFile&&this.vertical&&1===h.length){const e=Ji()[h.charCodeAt(0)];e&&(h=o=String.fromCharCode(e))}r=new fonts_Glyph(e,h,o,C,a,g,s,t,c);return this._glyphCache[e]=r}charsToGlyphs(e){let t=this._charsCache[e];if(t)return t;t=[];if(this.cMap){const i=Object.create(null),a=e.length;let s=0;for(;st.length%2==1,a=this.toUnicode instanceof IdentityToUnicodeMap?e=>this.toUnicode.charCodeOf(e):e=>this.toUnicode.charCodeOf(String.fromCodePoint(e));for(let s=0,r=e.length;s55295&&(r<57344||r>65533)&&s++;if(this.toUnicode){const e=a(r);if(-1!==e){if(hasCurrentBufErrors()){t.push(i.join(""));i.length=0}for(let t=(this.cMap?this.cMap.getCharCodeLength(e):1)-1;t>=0;t--)i.push(String.fromCharCode(e>>8*t&255));continue}}if(!hasCurrentBufErrors()){t.push(i.join(""));i.length=0}i.push(String.fromCodePoint(r))}t.push(i.join(""));return t}}class ErrorFont{constructor(e){this.error=e;this.loadedName="g_font_error";this.missingFile=!0}charsToGlyphs(){return[]}encodeString(e){return[e]}exportData(e=!1){return{error:this.error}}}const Ia=2,ca=3,Ca=4,ha=5,la=6,Ba=7;class Pattern{constructor(){unreachable("Cannot initialize Pattern.")}static parseShading(e,t,i,a,s){const r=e instanceof BaseStream?e.dict:e,n=r.get("ShadingType");try{switch(n){case Ia:case ca:return new RadialAxialShading(r,t,i,a,s);case Ca:case ha:case la:case Ba:return new MeshShading(e,t,i,a,s);default:throw new FormatError("Unsupported ShadingType: "+n)}}catch(e){if(e instanceof MissingDataException)throw e;warn(e);return new DummyShading}}}class BaseShading{static SMALL_NUMBER=1e-6;getIR(){unreachable("Abstract method `getIR` called.")}}class RadialAxialShading extends BaseShading{constructor(e,t,i,a,s){super();this.shadingType=e.get("ShadingType");let r=0;this.shadingType===Ia?r=4:this.shadingType===ca&&(r=6);this.coordsArr=e.getArray("Coords");if(!isNumberArray(this.coordsArr,r))throw new FormatError("RadialAxialShading: Invalid /Coords array.");const n=ColorSpace.parse({cs:e.getRaw("CS")||e.getRaw("ColorSpace"),xref:t,resources:i,pdfFunctionFactory:a,localColorSpaceCache:s});this.bbox=lookupNormalRect(e.getArray("BBox"),null);let g=0,o=1;const c=e.getArray("Domain");isNumberArray(c,2)&&([g,o]=c);let C=!1,h=!1;const l=e.getArray("Extend");(function isBooleanArray(e,t){return Array.isArray(e)&&(null===t||e.length===t)&&e.every((e=>"boolean"==typeof e))})(l,2)&&([C,h]=l);if(!(this.shadingType!==ca||C&&h)){const[e,t,i,a,s,r]=this.coordsArr,n=Math.hypot(e-a,t-s);i<=r+n&&r<=i+n&&warn("Unsupported radial gradient.")}this.extendStart=C;this.extendEnd=h;const Q=e.getRaw("Function"),E=a.createFromArray(Q),u=(o-g)/840,d=this.colorStops=[];if(g>=o||u<=0){info("Bad shading domain.");return}const f=new Float32Array(n.numComps),p=new Float32Array(1);let m,y=0;p[0]=g;E(p,0,f,0);let w=n.getRgb(f,0);const D=Util.makeHexColor(w[0],w[1],w[2]);d.push([0,D]);let b=1;p[0]=g+u;E(p,0,f,0);let F=n.getRgb(f,0),S=F[0]-w[0]+1,k=F[1]-w[1]+1,R=F[2]-w[2]+1,N=F[0]-w[0]-1,G=F[1]-w[1]-1,M=F[2]-w[2]-1;for(let e=2;e<840;e++){p[0]=g+e*u;E(p,0,f,0);m=n.getRgb(f,0);const t=e-y;S=Math.min(S,(m[0]-w[0]+1)/t);k=Math.min(k,(m[1]-w[1]+1)/t);R=Math.min(R,(m[2]-w[2]+1)/t);N=Math.max(N,(m[0]-w[0]-1)/t);G=Math.max(G,(m[1]-w[1]-1)/t);M=Math.max(M,(m[2]-w[2]-1)/t);if(!(N<=S&&G<=k&&M<=R)){const e=Util.makeHexColor(F[0],F[1],F[2]);d.push([b/840,e]);S=m[0]-F[0]+1;k=m[1]-F[1]+1;R=m[2]-F[2]+1;N=m[0]-F[0]-1;G=m[1]-F[1]-1;M=m[2]-F[2]-1;y=b;w=F}b=e;F=m}const U=Util.makeHexColor(F[0],F[1],F[2]);d.push([1,U]);let x="transparent";if(e.has("Background")){m=n.getRgb(e.get("Background"),0);x=Util.makeHexColor(m[0],m[1],m[2])}if(!C){d.unshift([0,x]);d[1][0]+=BaseShading.SMALL_NUMBER}if(!h){d.at(-1)[0]-=BaseShading.SMALL_NUMBER;d.push([1,x])}this.colorStops=d}getIR(){const{coordsArr:e,shadingType:t}=this;let i,a,s,r,n;if(t===Ia){a=[e[0],e[1]];s=[e[2],e[3]];r=null;n=null;i="axial"}else if(t===ca){a=[e[0],e[1]];s=[e[3],e[4]];r=e[2];n=e[5];i="radial"}else unreachable(`getPattern type unknown: ${t}`);return["RadialAxial",i,this.bbox,this.colorStops,a,s,r,n]}}class MeshStreamReader{constructor(e,t){this.stream=e;this.context=t;this.buffer=0;this.bufferLength=0;const i=t.numComps;this.tmpCompsBuf=new Float32Array(i);const a=t.colorSpace.numComps;this.tmpCsCompsBuf=t.colorFn?new Float32Array(a):this.tmpCompsBuf}get hasData(){if(this.stream.end)return this.stream.pos0)return!0;const e=this.stream.getByte();if(e<0)return!1;this.buffer=e;this.bufferLength=8;return!0}readBits(e){let t=this.buffer,i=this.bufferLength;if(32===e){if(0===i)return(this.stream.getByte()<<24|this.stream.getByte()<<16|this.stream.getByte()<<8|this.stream.getByte())>>>0;t=t<<24|this.stream.getByte()<<16|this.stream.getByte()<<8|this.stream.getByte();const e=this.stream.getByte();this.buffer=e&(1<>i)>>>0}if(8===e&&0===i)return this.stream.getByte();for(;i>i}align(){this.buffer=0;this.bufferLength=0}readFlag(){return this.readBits(this.context.bitsPerFlag)}readCoordinate(){const e=this.context.bitsPerCoordinate,t=this.readBits(e),i=this.readBits(e),a=this.context.decode,s=e<32?1/((1<r?r:e;t=t>n?n:t;i=ie*s[t])):i;let n,g=-2;const o=[];for(const[e,t]of a.map(((e,t)=>[e,t])).sort((([e],[t])=>e-t)))if(-1!==e)if(e===g+1){n.push(r[t]);g+=1}else{g=e;n=[r[t]];o.push(e,n)}return o}(e),i=new Dict(null);i.set("BaseFont",Name.get(e));i.set("Type",Name.get("Font"));i.set("Subtype",Name.get("CIDFontType2"));i.set("Encoding",Name.get("Identity-H"));i.set("CIDToGIDMap",Name.get("Identity"));i.set("W",t);i.set("FirstChar",t[0]);i.set("LastChar",t.at(-2)+t.at(-1).length-1);const a=new Dict(null);i.set("FontDescriptor",a);const s=new Dict(null);s.set("Ordering","Identity");s.set("Registry","Adobe");s.set("Supplement",0);i.set("CIDSystemInfo",s);return i}class PostScriptParser{constructor(e){this.lexer=e;this.operators=[];this.token=null;this.prev=null}nextToken(){this.prev=this.token;this.token=this.lexer.getToken()}accept(e){if(this.token.type===e){this.nextToken();return!0}return!1}expect(e){if(this.accept(e))return!0;throw new FormatError(`Unexpected symbol: found ${this.token.type} expected ${e}.`)}parse(){this.nextToken();this.expect(as.LBRACE);this.parseBlock();this.expect(as.RBRACE);return this.operators}parseBlock(){for(;;)if(this.accept(as.NUMBER))this.operators.push(this.prev.value);else if(this.accept(as.OPERATOR))this.operators.push(this.prev.value);else{if(!this.accept(as.LBRACE))return;this.parseCondition()}}parseCondition(){const e=this.operators.length;this.operators.push(null,null);this.parseBlock();this.expect(as.RBRACE);if(this.accept(as.IF)){this.operators[e]=this.operators.length;this.operators[e+1]="jz"}else{if(!this.accept(as.LBRACE))throw new FormatError("PS Function: error parsing conditional.");{const t=this.operators.length;this.operators.push(null,null);const i=this.operators.length;this.parseBlock();this.expect(as.RBRACE);this.expect(as.IFELSE);this.operators[t]=this.operators.length;this.operators[t+1]="j";this.operators[e]=i;this.operators[e+1]="jz"}}}}const as={LBRACE:0,RBRACE:1,NUMBER:2,OPERATOR:3,IF:4,IFELSE:5};class PostScriptToken{static get opCache(){return shadow(this,"opCache",Object.create(null))}constructor(e,t){this.type=e;this.value=t}static getOperator(e){return PostScriptToken.opCache[e]||=new PostScriptToken(as.OPERATOR,e)}static get LBRACE(){return shadow(this,"LBRACE",new PostScriptToken(as.LBRACE,"{"))}static get RBRACE(){return shadow(this,"RBRACE",new PostScriptToken(as.RBRACE,"}"))}static get IF(){return shadow(this,"IF",new PostScriptToken(as.IF,"IF"))}static get IFELSE(){return shadow(this,"IFELSE",new PostScriptToken(as.IFELSE,"IFELSE"))}}class PostScriptLexer{constructor(e){this.stream=e;this.nextChar();this.strBuf=[]}nextChar(){return this.currentChar=this.stream.getByte()}getToken(){let e=!1,t=this.currentChar;for(;;){if(t<0)return Bt;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(!isWhiteSpace(t))break;t=this.nextChar()}switch(0|t){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:case 43:case 45:case 46:return new PostScriptToken(as.NUMBER,this.getNumber());case 123:this.nextChar();return PostScriptToken.LBRACE;case 125:this.nextChar();return PostScriptToken.RBRACE}const i=this.strBuf;i.length=0;i[0]=String.fromCharCode(t);for(;(t=this.nextChar())>=0&&(t>=65&&t<=90||t>=97&&t<=122);)i.push(String.fromCharCode(t));const a=i.join("");switch(a.toLowerCase()){case"if":return PostScriptToken.IF;case"ifelse":return PostScriptToken.IFELSE;default:return PostScriptToken.getOperator(a)}}getNumber(){let e=this.currentChar;const t=this.strBuf;t.length=0;t[0]=String.fromCharCode(e);for(;(e=this.nextChar())>=0&&(e>=48&&e<=57||45===e||46===e);)t.push(String.fromCharCode(e));const i=parseFloat(t.join(""));if(isNaN(i))throw new FormatError(`Invalid floating point number: ${i}`);return i}}class BaseLocalCache{constructor(e){this._onlyRefs=!0===e?.onlyRefs;if(!this._onlyRefs){this._nameRefMap=new Map;this._imageMap=new Map}this._imageCache=new RefSetCache}getByName(e){this._onlyRefs&&unreachable("Should not call `getByName` method.");const t=this._nameRefMap.get(e);return t?this.getByRef(t):this._imageMap.get(e)||null}getByRef(e){return this._imageCache.get(e)||null}set(e,t,i){unreachable("Abstract method `set` called.")}}class LocalImageCache extends BaseLocalCache{set(e,t=null,i){if("string"!=typeof e)throw new Error('LocalImageCache.set - expected "name" argument.');if(t){if(this._imageCache.has(t))return;this._nameRefMap.set(e,t);this._imageCache.put(t,i)}else this._imageMap.has(e)||this._imageMap.set(e,i)}}class LocalColorSpaceCache extends BaseLocalCache{set(e=null,t=null,i){if("string"!=typeof e&&!t)throw new Error('LocalColorSpaceCache.set - expected "name" and/or "ref" argument.');if(t){if(this._imageCache.has(t))return;null!==e&&this._nameRefMap.set(e,t);this._imageCache.put(t,i)}else this._imageMap.has(e)||this._imageMap.set(e,i)}}class LocalFunctionCache extends BaseLocalCache{constructor(e){super({onlyRefs:!0})}set(e=null,t,i){if(!t)throw new Error('LocalFunctionCache.set - expected "ref" argument.');this._imageCache.has(t)||this._imageCache.put(t,i)}}class LocalGStateCache extends BaseLocalCache{set(e,t=null,i){if("string"!=typeof e)throw new Error('LocalGStateCache.set - expected "name" argument.');if(t){if(this._imageCache.has(t))return;this._nameRefMap.set(e,t);this._imageCache.put(t,i)}else this._imageMap.has(e)||this._imageMap.set(e,i)}}class LocalTilingPatternCache extends BaseLocalCache{constructor(e){super({onlyRefs:!0})}set(e=null,t,i){if(!t)throw new Error('LocalTilingPatternCache.set - expected "ref" argument.');this._imageCache.has(t)||this._imageCache.put(t,i)}}class RegionalImageCache extends BaseLocalCache{constructor(e){super({onlyRefs:!0})}set(e=null,t,i){if(!t)throw new Error('RegionalImageCache.set - expected "ref" argument.');this._imageCache.has(t)||this._imageCache.put(t,i)}}class GlobalImageCache{static NUM_PAGES_THRESHOLD=2;static MIN_IMAGES_TO_CACHE=10;static MAX_BYTE_SIZE=5e7;#F=new RefSet;constructor(){this._refCache=new RefSetCache;this._imageCache=new RefSetCache}get#S(){let e=0;for(const t of this._imageCache)e+=t.byteSize;return e}get#k(){return!(this._imageCache.size+e)):null}class PDFFunction{static getSampleArray(e,t,i,a){let s,r,n=1;for(s=0,r=e.length;s>o)*C;c&=(1<i?e=i:e0&&(l=r[h-1]);let Q=a[1];h>1,c=s.length>>1,C=new PostScriptEvaluator(g),h=Object.create(null);let l=8192;const Q=new Float32Array(c);return function constructPostScriptFn(e,t,i,a){let s,n,g="";const E=Q;for(s=0;se&&(n=e)}d[s]=n}if(l>0){l--;h[g]=d}i.set(d,a)}}}function isPDFFunction(e){let t;if(e instanceof Dict)t=e;else{if(!(e instanceof BaseStream))return!1;t=e.dict}return t.has("FunctionType")}class PostScriptStack{static MAX_STACK_SIZE=100;constructor(e){this.stack=e?Array.from(e):[]}push(e){if(this.stack.length>=PostScriptStack.MAX_STACK_SIZE)throw new Error("PostScript function stack overflow.");this.stack.push(e)}pop(){if(this.stack.length<=0)throw new Error("PostScript function stack underflow.");return this.stack.pop()}copy(e){if(this.stack.length+e>=PostScriptStack.MAX_STACK_SIZE)throw new Error("PostScript function stack overflow.");const t=this.stack;for(let i=t.length-e,a=e-1;a>=0;a--,i++)t.push(t[i])}index(e){this.push(this.stack[this.stack.length-e-1])}roll(e,t){const i=this.stack,a=i.length-e,s=i.length-1,r=a+(t-Math.floor(t/e)*e);for(let e=a,t=s;e0?t.push(n<>g);break;case"ceiling":n=t.pop();t.push(Math.ceil(n));break;case"copy":n=t.pop();t.copy(n);break;case"cos":n=t.pop();t.push(Math.cos(n%360/180*Math.PI));break;case"cvi":n=0|t.pop();t.push(n);break;case"cvr":break;case"div":g=t.pop();n=t.pop();t.push(n/g);break;case"dup":t.copy(1);break;case"eq":g=t.pop();n=t.pop();t.push(n===g);break;case"exch":t.roll(2,1);break;case"exp":g=t.pop();n=t.pop();t.push(n**g);break;case"false":t.push(!1);break;case"floor":n=t.pop();t.push(Math.floor(n));break;case"ge":g=t.pop();n=t.pop();t.push(n>=g);break;case"gt":g=t.pop();n=t.pop();t.push(n>g);break;case"idiv":g=t.pop();n=t.pop();t.push(n/g|0);break;case"index":n=t.pop();t.index(n);break;case"le":g=t.pop();n=t.pop();t.push(n<=g);break;case"ln":n=t.pop();t.push(Math.log(n));break;case"log":n=t.pop();t.push(Math.log10(n));break;case"lt":g=t.pop();n=t.pop();t.push(n=t?new AstLiteral(t):e.max<=t?e:new AstMin(e,t)}class PostScriptCompiler{compile(e,t,i){const a=[],s=[],r=t.length>>1,n=i.length>>1;let g,o,c,C,h,l,Q,E,u=0;for(let e=0;et.min){g.unshift("Math.max(",r,", ");g.push(")")}if(n4){a=!0;t=0}else{a=!1;t=1}const o=[];for(r=0;r=0&&"ET"===gs[e];--e)gs[e]="EN";for(let e=r+1;e0&&(t=gs[r-1]);let i=h;e+1E&&isOdd(E)&&(d=E)}for(E=u;E>=d;--E){let e=-1;for(r=0,n=o.length;r=0){reverseValues(ns,e,r);e=-1}}else e<0&&(e=r);e>=0&&reverseValues(ns,e,o.length)}for(r=0,n=ns.length;r"!==e||(ns[r]="")}return createBidiText(ns.join(""),a)}const os={style:"normal",weight:"normal"},Is={style:"normal",weight:"bold"},cs={style:"italic",weight:"normal"},Cs={style:"italic",weight:"bold"},hs=new Map([["Times-Roman",{local:["Times New Roman","Times-Roman","Times","Liberation Serif","Nimbus Roman","Nimbus Roman L","Tinos","Thorndale","TeX Gyre Termes","FreeSerif","Linux Libertine O","Libertinus Serif","DejaVu Serif","Bitstream Vera Serif","Ubuntu"],style:os,ultimate:"serif"}],["Times-Bold",{alias:"Times-Roman",style:Is,ultimate:"serif"}],["Times-Italic",{alias:"Times-Roman",style:cs,ultimate:"serif"}],["Times-BoldItalic",{alias:"Times-Roman",style:Cs,ultimate:"serif"}],["Helvetica",{local:["Helvetica","Helvetica Neue","Arial","Arial Nova","Liberation Sans","Arimo","Nimbus Sans","Nimbus Sans L","A030","TeX Gyre Heros","FreeSans","DejaVu Sans","Albany","Bitstream Vera Sans","Arial Unicode MS","Microsoft Sans Serif","Apple Symbols","Cantarell"],path:"LiberationSans-Regular.ttf",style:os,ultimate:"sans-serif"}],["Helvetica-Bold",{alias:"Helvetica",path:"LiberationSans-Bold.ttf",style:Is,ultimate:"sans-serif"}],["Helvetica-Oblique",{alias:"Helvetica",path:"LiberationSans-Italic.ttf",style:cs,ultimate:"sans-serif"}],["Helvetica-BoldOblique",{alias:"Helvetica",path:"LiberationSans-BoldItalic.ttf",style:Cs,ultimate:"sans-serif"}],["Courier",{local:["Courier","Courier New","Liberation Mono","Nimbus Mono","Nimbus Mono L","Cousine","Cumberland","TeX Gyre Cursor","FreeMono","Linux Libertine Mono O","Libertinus Mono"],style:os,ultimate:"monospace"}],["Courier-Bold",{alias:"Courier",style:Is,ultimate:"monospace"}],["Courier-Oblique",{alias:"Courier",style:cs,ultimate:"monospace"}],["Courier-BoldOblique",{alias:"Courier",style:Cs,ultimate:"monospace"}],["ArialBlack",{local:["Arial Black"],style:{style:"normal",weight:"900"},fallback:"Helvetica-Bold"}],["ArialBlack-Bold",{alias:"ArialBlack"}],["ArialBlack-Italic",{alias:"ArialBlack",style:{style:"italic",weight:"900"},fallback:"Helvetica-BoldOblique"}],["ArialBlack-BoldItalic",{alias:"ArialBlack-Italic"}],["ArialNarrow",{local:["Arial Narrow","Liberation Sans Narrow","Helvetica Condensed","Nimbus Sans Narrow","TeX Gyre Heros Cn"],style:os,fallback:"Helvetica"}],["ArialNarrow-Bold",{alias:"ArialNarrow",style:Is,fallback:"Helvetica-Bold"}],["ArialNarrow-Italic",{alias:"ArialNarrow",style:cs,fallback:"Helvetica-Oblique"}],["ArialNarrow-BoldItalic",{alias:"ArialNarrow",style:Cs,fallback:"Helvetica-BoldOblique"}],["Calibri",{local:["Calibri","Carlito"],style:os,fallback:"Helvetica"}],["Calibri-Bold",{alias:"Calibri",style:Is,fallback:"Helvetica-Bold"}],["Calibri-Italic",{alias:"Calibri",style:cs,fallback:"Helvetica-Oblique"}],["Calibri-BoldItalic",{alias:"Calibri",style:Cs,fallback:"Helvetica-BoldOblique"}],["Wingdings",{local:["Wingdings","URW Dingbats"],style:os}],["Wingdings-Regular",{alias:"Wingdings"}],["Wingdings-Bold",{alias:"Wingdings"}]]),ls=new Map([["Arial-Black","ArialBlack"]]);function getFamilyName(e){const t=new Set(["thin","extralight","ultralight","demilight","semilight","light","book","regular","normal","medium","demibold","semibold","bold","extrabold","ultrabold","black","heavy","extrablack","ultrablack","roman","italic","oblique","ultracondensed","extracondensed","condensed","semicondensed","normal","semiexpanded","expanded","extraexpanded","ultraexpanded","bolditalic"]);return e.split(/[- ,+]+/g).filter((e=>!t.has(e.toLowerCase()))).join(" ")}function generateFont({alias:e,local:t,path:i,fallback:a,style:s,ultimate:r},n,g,o=!0,c=!0,C=""){const h={style:null,ultimate:null};if(t){const e=C?` ${C}`:"";for(const i of t)n.push(`local(${i}${e})`)}if(e){const t=hs.get(e),r=C||function getStyleToAppend(e){switch(e){case Is:return"Bold";case cs:return"Italic";case Cs:return"Bold Italic";default:if("bold"===e?.weight)return"Bold";if("italic"===e?.style)return"Italic"}return""}(s);Object.assign(h,generateFont(t,n,g,o&&!a,c&&!i,r))}s&&(h.style=s);r&&(h.ultimate=r);if(o&&a){const e=hs.get(a),{ultimate:t}=generateFont(e,n,g,o,c&&!i,C);h.ultimate||=t}c&&i&&g&&n.push(`url(${g}${i})`);return h}function getFontSubstitution(e,t,i,a,s,r){if(a.startsWith("InvalidPDFjsFont_"))return null;"TrueType"!==r&&"Type1"!==r||!/^[A-Z]{6}\+/.test(a)||(a=a.slice(7));const n=a=normalizeFontName(a);let g=e.get(n);if(g)return g;let o=hs.get(a);if(!o)for(const[e,t]of ls)if(a.startsWith(e)){a=`${t}${a.substring(e.length)}`;o=hs.get(a);break}let c=!1;if(!o){o=hs.get(s);c=!0}const C=`${t.getDocId()}_s${t.createFontId()}`;if(!o){if(!validateFontName(a)){warn(`Cannot substitute the font because of its name: ${a}`);e.set(n,null);return null}const t=/bold/gi.test(a),i=/oblique|italic/gi.test(a),s=t&&i&&Cs||t&&Is||i&&cs||os;g={css:`"${getFamilyName(a)}",${C}`,guessFallback:!0,loadedName:C,baseFontName:a,src:`local(${a})`,style:s};e.set(n,g);return g}const h=[];c&&validateFontName(a)&&h.push(`local(${a})`);const{style:l,ultimate:Q}=generateFont(o,h,i),E=null===Q,u=E?"":`,${Q}`;g={css:`"${getFamilyName(a)}",${C}${u}`,guessFallback:E,loadedName:C,baseFontName:a,src:h.join(","),style:l};e.set(n,g);return g}class ImageResizer{static#R=2048;static#y=FeatureTest.isImageDecoderSupported;constructor(e,t){this._imgData=e;this._isMask=t}static get canUseImageDecoder(){return shadow(this,"canUseImageDecoder",this.#y?ImageDecoder.isTypeSupported("image/bmp"):Promise.resolve(!1))}static needsToBeResized(e,t){if(e<=this.#R&&t<=this.#R)return!1;const{MAX_DIM:i}=this;if(e>i||t>i)return!0;const a=e*t;if(this._hasMaxArea)return a>this.MAX_AREA;if(a(this.MAX_AREA=this.#R**2)}static get MAX_DIM(){return shadow(this,"MAX_DIM",this._guessMax(2048,65537,0,1))}static get MAX_AREA(){this._hasMaxArea=!0;return shadow(this,"MAX_AREA",this._guessMax(this.#R,this.MAX_DIM,128,0)**2)}static set MAX_AREA(e){if(e>=0){this._hasMaxArea=!0;shadow(this,"MAX_AREA",e)}}static setOptions({canvasMaxAreaInBytes:e=-1,isImageDecoderSupported:t=!1}){this._hasMaxArea||(this.MAX_AREA=e>>2);this.#y=t}static _areGoodDims(e,t){try{const i=new OffscreenCanvas(e,t),a=i.getContext("2d");a.fillRect(0,0,1,1);const s=a.getImageData(0,0,1,1).data[3];i.width=i.height=1;return 0!==s}catch{return!1}}static _guessMax(e,t,i,a){for(;e+i+1pt){const e=this.#N();if(e)return e}const a=this._encodeBMP();let s,r;if(await ImageResizer.canUseImageDecoder){s=new ImageDecoder({data:a,type:"image/bmp",preferAnimation:!1,transfer:[a.buffer]});r=s.decode().catch((e=>{warn(`BMP image decoding failed: ${e}`);return createImageBitmap(new Blob([this._encodeBMP().buffer],{type:"image/bmp"}))})).finally((()=>{s.close()}))}else r=createImageBitmap(new Blob([a.buffer],{type:"image/bmp"}));const{MAX_AREA:n,MAX_DIM:g}=ImageResizer,o=Math.max(t/g,i/g,Math.sqrt(t*i/n)),c=Math.max(o,2),C=Math.round(10*(o+1.25))/10/c,h=Math.floor(Math.log2(C)),l=new Array(h+2).fill(2);l[0]=c;l.splice(-1,1,C/(1<>n,o=a>>n;let c,C=a;try{c=new Uint8Array(r)}catch{let e=Math.floor(Math.log2(r+1));for(;;)try{c=new Uint8Array(2**e-1);break}catch{e-=1}C=Math.floor((2**e-1)/(4*i));const t=i*C*4;t>n;e>3,n=i+3&-4;if(i!==n){const e=new Uint8Array(n*t);let a=0;for(let r=0,g=t*i;r>>8;t[i++]=255&s}}}else{if(!ArrayBuffer.isView(e))throw new Error("Invalid data format, must be a string or TypedArray.");t=e.slice();i=t.byteLength}const a=i>>2,s=i-4*a,r=new Uint32Array(t.buffer,0,a);let n=0,g=0,o=this.h1,c=this.h2;const C=3432918353,h=461845907,l=11601,Q=13715;for(let e=0;e>>17;n=n*h&Qs|n*Q&Es;o^=n;o=o<<13|o>>>19;o=5*o+3864292196}else{g=r[e];g=g*C&Qs|g*l&Es;g=g<<15|g>>>17;g=g*h&Qs|g*Q&Es;c^=g;c=c<<13|c>>>19;c=5*c+3864292196}n=0;switch(s){case 3:n^=t[4*a+2]<<16;case 2:n^=t[4*a+1]<<8;case 1:n^=t[4*a];n=n*C&Qs|n*l&Es;n=n<<15|n>>>17;n=n*h&Qs|n*Q&Es;1&a?o^=n:c^=n}this.h1=o;this.h2=c}hexdigest(){let e=this.h1,t=this.h2;e^=t>>>1;e=3981806797*e&Qs|36045*e&Es;t=4283543511*t&Qs|(2950163797*(t<<16|e>>>16)&Qs)>>>16;e^=t>>>1;e=444984403*e&Qs|60499*e&Es;t=3301882366*t&Qs|(3120437893*(t<<16|e>>>16)&Qs)>>>16;e^=t>>>1;return(e>>>0).toString(16).padStart(8,"0")+(t>>>0).toString(16).padStart(8,"0")}}function addState(e,t,i,a,s){let r=e;for(let e=0,i=t.length-1;e1e3){c=Math.max(c,l);Q+=h+2;l=0;h=0}C.push({transform:t,x:l,y:Q,w:i.width,h:i.height});l+=i.width+2;h=Math.max(h,i.height)}const E=Math.max(c,l)+1,u=Q+h+1,d=new Uint8Array(E*u*4),f=E<<2;for(let e=0;e=0;){t[r-4]=t[r];t[r-3]=t[r+1];t[r-2]=t[r+2];t[r-1]=t[r+3];t[r+i]=t[r+i-4];t[r+i+1]=t[r+i-3];t[r+i+2]=t[r+i-2];t[r+i+3]=t[r+i-1];r-=f}}const p={width:E,height:u};if(e.isOffscreenCanvasSupported){const e=new OffscreenCanvas(E,u);e.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(d.buffer),E,u),0,0);p.bitmap=e.transferToImageBitmap();p.data=null}else{p.kind=S;p.data=d}i.splice(r,4*o,$e);a.splice(r,4*o,[p,C]);return r+1}));addState(us,[MA,xA,Ze,UA],null,(function iterateImageMaskGroup(e,t){const i=e.fnArray,a=(t-(e.iCurr-3))%4;switch(a){case 0:return i[t]===MA;case 1:return i[t]===xA;case 2:return i[t]===Ze;case 3:return i[t]===UA}throw new Error(`iterateImageMaskGroup - invalid pos: ${a}`)}),(function foundImageMaskGroup(e,t){const i=e.fnArray,a=e.argsArray,s=e.iCurr,r=s-3,n=s-2,g=s-1;let o=Math.floor((t-r)/4);if(o<10)return t-(t-r)%4;let c,C,h=!1;const l=a[g][0],Q=a[n][0],E=a[n][1],u=a[n][2],d=a[n][3];if(E===u){h=!0;c=n+4;let e=g+4;for(let t=1;t=4&&i[r-4]===i[n]&&i[r-3]===i[g]&&i[r-2]===i[o]&&i[r-1]===i[c]&&a[r-4][0]===C&&a[r-4][1]===h){l++;Q-=5}let E=Q+4;for(let e=1;e=i)break}a=(a||us)[e[t]];if(a&&!Array.isArray(a)){r.iCurr=t;t++;if(!a.checkFn||(0,a.checkFn)(r)){s=a;a=null}else a=null}else t++}this.state=a;this.match=s;this.lastProcessed=t}flush(){for(;this.match;){const e=this.queue.fnArray.length;this.lastProcessed=(0,this.match.processFn)(this.context,e);this.match=null;this.state=null;this._optimize()}}reset(){this.state=null;this.match=null;this.lastProcessed=0}}class OperatorList{static CHUNK_SIZE=1e3;static CHUNK_SIZE_ABOUT=this.CHUNK_SIZE-5;constructor(e=0,t){this._streamSink=t;this.fnArray=[];this.argsArray=[];this.optimizer=!t||e&E?new NullOptimizer(this):new QueueOptimizer(this);this.dependencies=new Set;this._totalLength=0;this.weight=0;this._resolved=t?null:Promise.resolve()}set isOffscreenCanvasSupported(e){this.optimizer.isOffscreenCanvasSupported=e}get length(){return this.argsArray.length}get ready(){return this._resolved||this._streamSink.ready}get totalLength(){return this._totalLength+this.length}addOp(e,t){this.optimizer.push(e,t);this.weight++;this._streamSink&&(this.weight>=OperatorList.CHUNK_SIZE||this.weight>=OperatorList.CHUNK_SIZE_ABOUT&&(e===UA||e===ee))&&this.flush()}addImageOps(e,t,i,a=!1){if(a){this.addOp(MA);this.addOp(GA,[[["SMask",!1]]])}void 0!==i&&this.addOp(Ye,["OC",i]);this.addOp(e,t);void 0!==i&&this.addOp(ve,[]);a&&this.addOp(UA)}addDependency(e){if(!this.dependencies.has(e)){this.dependencies.add(e);this.addOp(wA,[e])}}addDependencies(e){for(const t of e)this.addDependency(t)}addOpList(e){if(e instanceof OperatorList){for(const t of e.dependencies)this.dependencies.add(t);for(let t=0,i=e.length;ta&&(e=a);return e}function resizeImageMask(e,t,i,a,s,r){const n=s*r;let g;g=t<=8?new Uint8Array(n):t<=16?new Uint16Array(n):new Uint32Array(n);const o=i/s,c=a/r;let C,h,l,Q,E=0;const u=new Uint16Array(s),d=i;for(C=0;C0&&Number.isInteger(i.height)&&i.height>0&&(i.width!==l||i.height!==Q)){warn("PDFImage - using the Width/Height of the image data, rather than the image dictionary.");l=i.width;Q=i.height}if(l<1||Q<1)throw new FormatError(`Invalid image width: ${l} or height: ${Q}`);this.width=l;this.height=Q;this.interpolate=c.get("I","Interpolate");this.imageMask=c.get("IM","ImageMask")||!1;this.matte=c.get("Matte")||!1;let E=i.bitsPerComponent;if(!E){E=c.get("BPC","BitsPerComponent");if(!E){if(!this.imageMask)throw new FormatError(`Bits per component missing in image: ${this.imageMask}`);E=1}}this.bpc=E;if(!this.imageMask){let s=c.getRaw("CS")||c.getRaw("ColorSpace");const r=!!s;if(r)this.jpxDecoderOptions?.smaskInData&&(s=Name.get("DeviceRGBA"));else if(this.jpxDecoderOptions)s=Name.get("DeviceRGBA");else switch(i.numComps){case 1:s=Name.get("DeviceGray");break;case 3:s=Name.get("DeviceRGB");break;case 4:s=Name.get("DeviceCMYK");break;default:throw new Error(`Images with ${i.numComps} color components not supported.`)}this.colorSpace=ColorSpace.parse({cs:s,xref:e,resources:a?t:null,pdfFunctionFactory:g,localColorSpaceCache:o});this.numComps=this.colorSpace.numComps;if(this.jpxDecoderOptions){this.jpxDecoderOptions.numComponents=r?this.numComp:0;this.jpxDecoderOptions.isIndexedColormap="Indexed"===this.colorSpace.name}}this.decode=c.getArray("D","Decode");this.needsDecode=!1;if(this.decode&&(this.colorSpace&&!this.colorSpace.isDefaultDecode(this.decode,E)||n&&!ColorSpace.isDefaultDecode(this.decode,1))){this.needsDecode=!0;const e=(1<>3)*i,g=e.byteLength;let o,c;if(!a||s&&!(n===g))if(s){o=new Uint8Array(n);o.set(e);o.fill(255,g)}else o=new Uint8Array(e);else o=e;if(s)for(c=0;c>7&1;n[l+1]=h>>6&1;n[l+2]=h>>5&1;n[l+3]=h>>4&1;n[l+4]=h>>3&1;n[l+5]=h>>2&1;n[l+6]=h>>1&1;n[l+7]=1&h;l+=8}if(l>=1}}}}else{let i=0;h=0;for(l=0,C=r;l>a;s<0?s=0:s>c&&(s=c);n[l]=s;h&=(1<n[a+1]){t=255;break}}g[C]=t}}}if(g)for(C=0,l=3,h=t*a;C>3,C=t&&ImageResizer.needsToBeResized(i,a);if(!this.smask&&!this.mask&&"DeviceRGBA"===this.colorSpace.name){s.kind=S;const e=s.data=await this.getImageBytes(g*n*4,{});return t?C?ImageResizer.createImage(s,!1):this.createBitmap(S,i,a,e):s}if(!e){let e;"DeviceGray"===this.colorSpace.name&&1===o?e=b:"DeviceRGB"!==this.colorSpace.name||8!==o||this.needsDecode||(e=F);if(e&&!this.smask&&!this.mask&&i===n&&a===g){const r=await this.#G(n,g);if(r)return r;const o=await this.getImageBytes(g*c,{});if(t)return C?ImageResizer.createImage({data:o,kind:e,width:i,height:a,interpolate:this.interpolate},this.needsDecode):this.createBitmap(e,n,g,o);s.kind=e;s.data=o;if(this.needsDecode){assert(e===b,"PDFImage.createImageData: The image must be grayscale.");const t=s.data;for(let e=0,i=t.length;e>3,n=await this.getImageBytes(a*r,{internal:!0}),g=this.getComponents(n);let o,c;if(1===s){c=i*a;if(this.needsDecode)for(o=0;o0&&t.args[0].count++}class TimeSlotManager{static TIME_SLOT_DURATION_MS=20;static CHECK_TIME_EVERY=100;constructor(){this.reset()}check(){if(++this.checkedh){const e="Image exceeded maximum allowed size and was removed.";if(this.options.ignoreErrors){warn(e);return}throw new Error(e)}let l;g.has("OC")&&(l=await this.parseMarkedContentProps(g.get("OC"),e));let Q,E;if(g.get("IM","ImageMask")||!1){const e=g.get("I","Interpolate"),i=c+7>>3,n=t.getBytes(i*C),h=g.getArray("D","Decode");if(this.parsingType3Font){Q=PDFImage.createRawMask({imgArray:n,width:c,height:C,imageIsFromDecodeStream:t instanceof DecodeStream,inverseDecode:h?.[0]>0,interpolate:e});Q.cached=!!s;E=[Q];a.addImageOps(Ze,E,l);if(s){const e={fn:Ze,args:E,optionalContent:l};r.set(s,o,e);o&&this._regionalImageCache.set(null,o,e)}return}Q=await PDFImage.createMask({imgArray:n,width:c,height:C,imageIsFromDecodeStream:t instanceof DecodeStream,inverseDecode:h?.[0]>0,interpolate:e,isOffscreenCanvasSupported:this.options.isOffscreenCanvasSupported});if(Q.isSingleOpaquePixel){a.addImageOps(tt,[],l);if(s){const e={fn:tt,args:[],optionalContent:l};r.set(s,o,e);o&&this._regionalImageCache.set(null,o,e)}return}const u=`mask_${this.idFactory.createObjId()}`;a.addDependency(u);Q.dataLen=Q.bitmap?Q.width*Q.height*4:Q.data.length;this._sendImgData(u,Q);E=[{data:u,width:Q.width,height:Q.height,interpolate:Q.interpolate,count:1}];a.addImageOps(Ze,E,l);if(s){const e={objId:u,fn:Ze,args:E,optionalContent:l};r.set(s,o,e);o&&this._regionalImageCache.set(null,o,e)}return}const u=g.has("SMask")||g.has("Mask");if(i&&c+C<200&&!u){try{const s=new PDFImage({xref:this.xref,res:e,image:t,isInline:i,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:n});Q=await s.createImageData(!0,!1);a.isOffscreenCanvasSupported=this.options.isOffscreenCanvasSupported;a.addImageOps(_e,[Q],l)}catch(e){const t=`Unable to decode inline image: "${e}".`;if(!this.options.ignoreErrors)throw new Error(t);warn(t)}return}let d=`img_${this.idFactory.createObjId()}`,f=!1;if(this.parsingType3Font)d=`${this.idFactory.getDocId()}_type3_${d}`;else if(s&&o){f=this.globalImageCache.shouldCache(o,this.pageIndex);if(f){assert(!i,"Cannot cache an inline image globally.");d=`${this.idFactory.getDocId()}_${d}`}}a.addDependency(d);E=[d,c,C];a.addImageOps(ze,E,l,u);if(f){if(this.globalImageCache.hasDecodeFailed(o)){this.globalImageCache.setData(o,{objId:d,fn:ze,args:E,optionalContent:l,hasMask:u,byteSize:0});this._sendImgData(d,null,f);return}if(c*C>25e4||u){const e=await this.handler.sendWithPromise("commonobj",[d,"CopyLocalImage",{imageRef:o}]);if(e){this.globalImageCache.setData(o,{objId:d,fn:ze,args:E,optionalContent:l,hasMask:u,byteSize:0});this.globalImageCache.addByteSize(o,e);return}}}PDFImage.buildImage({xref:this.xref,res:e,image:t,isInline:i,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:n}).then((async e=>{Q=await e.createImageData(!1,this.options.isOffscreenCanvasSupported);Q.dataLen=Q.bitmap?Q.width*Q.height*4:Q.data.length;Q.ref=o;f&&this.globalImageCache.addByteSize(o,Q.dataLen);return this._sendImgData(d,Q,f)})).catch((e=>{warn(`Unable to decode image "${d}": "${e}".`);o&&this.globalImageCache.addDecodeFailed(o);return this._sendImgData(d,null,f)}));if(s){const e={objId:d,fn:ze,args:E,optionalContent:l,hasMask:u};r.set(s,o,e);if(o){this._regionalImageCache.set(null,o,e);f&&this.globalImageCache.setData(o,{objId:d,fn:ze,args:E,optionalContent:l,hasMask:u,byteSize:0})}}}handleSMask(e,t,i,a,s,r){const n=e.get("G"),g={subtype:e.get("S").name,backdrop:e.get("BC")},o=e.get("TR");if(isPDFFunction(o)){const e=this._pdfFunctionFactory.create(o),t=new Uint8Array(256),i=new Float32Array(1);for(let a=0;a<256;a++){i[0]=a/255;e(i,0,i,0);t[a]=255*i[0]|0}g.transferMap=t}return this.buildFormXObject(t,n,g,i,a,s.state.clone(),r)}handleTransferFunction(e){let t;if(Array.isArray(e))t=e;else{if(!isPDFFunction(e))return null;t=[e]}const i=[];let a=0,s=0;for(const e of t){const t=this.xref.fetchIfRef(e);a++;if(isName(t,"Identity")){i.push(null);continue}if(!isPDFFunction(t))return null;const r=this._pdfFunctionFactory.create(t),n=new Uint8Array(256),g=new Float32Array(1);for(let e=0;e<256;e++){g[0]=e/255;r(g,0,g,0);n[e]=255*g[0]|0}i.push(n);s++}return 1!==a&&4!==a||0===s?null:i}handleTilingType(e,t,i,a,s,r,n,g){const o=new OperatorList,c=Dict.merge({xref:this.xref,dictArray:[s.get("Resources"),i]});return this.getOperatorList({stream:a,task:n,resources:c,operatorList:o}).then((function(){const i=o.getIR(),a=getTilingPatternIR(i,s,t);r.addDependencies(o.dependencies);r.addOp(e,a);s.objId&&g.set(null,s.objId,{operatorListIR:i,dict:s})})).catch((e=>{if(!(e instanceof AbortException)){if(!this.options.ignoreErrors)throw e;warn(`handleTilingType - ignoring pattern: "${e}".`)}}))}async handleSetFont(e,t,i,a,s,r,n=null,g=null){const o=t?.[0]instanceof Name?t[0].name:null;let c=await this.loadFont(o,i,e,n,g);if(c.font.isType3Font)try{await c.loadType3Data(this,e,s);a.addDependencies(c.type3Dependencies)}catch(e){c=new TranslatedFont({loadedName:"g_font_error",font:new ErrorFont(`Type3 font load error: ${e}`),dict:c.font,evaluatorOptions:this.options})}r.font=c.font;c.send(this.handler);return c.loadedName}handleText(e,t){const i=t.font,a=i.charsToGlyphs(e);if(i.data){(!!(t.textRenderingMode&D)||"Pattern"===t.fillColorSpace.name||i.disableFontFace||this.options.disableFontFace)&&PartialEvaluator.buildFontPaths(i,a,this.handler,this.options)}return a}ensureStateFont(e){if(e.font)return;const t=new FormatError("Missing setFont (Tf) operator before text rendering operator.");if(!this.options.ignoreErrors)throw t;warn(`ensureStateFont: "${t}".`)}async setGState({resources:e,gState:t,operatorList:i,cacheKey:a,task:s,stateManager:r,localGStateCache:n,localColorSpaceCache:g}){const o=t.objId;let c=!0;const C=[];let h=Promise.resolve();for(const a of t.getKeys()){const n=t.get(a);switch(a){case"Type":break;case"LW":case"LC":case"LJ":case"ML":case"D":case"RI":case"FL":case"CA":case"ca":C.push([a,n]);break;case"Font":c=!1;h=h.then((()=>this.handleSetFont(e,null,n[0],i,s,r.state).then((function(e){i.addDependency(e);C.push([a,[e,n[1]]])}))));break;case"BM":C.push([a,normalizeBlendMode(n)]);break;case"SMask":if(isName(n,"None")){C.push([a,!1]);break}if(n instanceof Dict){c=!1;h=h.then((()=>this.handleSMask(n,e,i,s,r,g)));C.push([a,!0])}else warn("Unsupported SMask type");break;case"TR":const t=this.handleTransferFunction(n);C.push([a,t]);break;case"OP":case"op":case"OPM":case"BG":case"BG2":case"UCR":case"UCR2":case"TR2":case"HT":case"SM":case"SA":case"AIS":case"TK":info("graphic state operator "+a);break;default:info("Unknown graphic state operator "+a)}}await h;C.length>0&&i.addOp(GA,[C]);c&&n.set(a,o,C)}loadFont(e,t,i,a=null,s=null){const errorFont=async()=>new TranslatedFont({loadedName:"g_font_error",font:new ErrorFont(`Font "${e}" is not available.`),dict:t,evaluatorOptions:this.options});let r;if(t)t instanceof Ref&&(r=t);else{const t=i.get("Font");t&&(r=t.getRaw(e))}if(r){if(this.type3FontRefs?.has(r))return errorFont();if(this.fontCache.has(r))return this.fontCache.get(r);try{t=this.xref.fetchIfRef(r)}catch(e){warn(`loadFont - lookup failed: "${e}".`)}}if(!(t instanceof Dict)){if(!this.options.ignoreErrors&&!this.parsingType3Font){warn(`Font "${e}" is not available.`);return errorFont()}warn(`Font "${e}" is not available -- attempting to fallback to a default font.`);t=a||PartialEvaluator.fallbackFontDict}if(t.cacheKey&&this.fontCache.has(t.cacheKey))return this.fontCache.get(t.cacheKey);const{promise:n,resolve:g}=Promise.withResolvers();let o;try{o=this.preEvaluateFont(t);o.cssFontInfo=s}catch(e){warn(`loadFont - preEvaluateFont failed: "${e}".`);return errorFont()}const{descriptor:c,hash:C}=o,h=r instanceof Ref;let l;if(C&&c instanceof Dict){const e=c.fontAliases||=Object.create(null);if(e[C]){const t=e[C].aliasRef;if(h&&t&&this.fontCache.has(t)){this.fontCache.putAlias(r,t);return this.fontCache.get(r)}}else e[C]={fontID:this.idFactory.createFontId()};h&&(e[C].aliasRef=r);l=e[C].fontID}else l=this.idFactory.createFontId();assert(l?.startsWith("f"),'The "fontID" must be (correctly) defined.');if(h)this.fontCache.put(r,n);else{t.cacheKey=`cacheKey_${l}`;this.fontCache.put(t.cacheKey,n)}t.loadedName=`${this.idFactory.getDocId()}_${l}`;this.translateFont(o).then((e=>{g(new TranslatedFont({loadedName:t.loadedName,font:e,dict:t,evaluatorOptions:this.options}))})).catch((e=>{warn(`loadFont - translateFont failed: "${e}".`);g(new TranslatedFont({loadedName:t.loadedName,font:new ErrorFont(e instanceof Error?e.message:e),dict:t,evaluatorOptions:this.options}))}));return n}buildPath(e,t,i,a=!1){const s=e.length-1;i||(i=[]);if(s<0||e.fnArray[s]!==it){if(a){warn(`Encountered path operator "${t}" inside of a text object.`);e.addOp(MA,null)}let s;switch(t){case TA:const e=i[0]+i[2],t=i[1]+i[3];s=[Math.min(i[0],e),Math.min(i[1],t),Math.max(i[0],e),Math.max(i[1],t)];break;case LA:case HA:s=[i[0],i[1],i[0],i[1]];break;default:s=[1/0,1/0,-1/0,-1/0]}e.addOp(it,[[t],i,s]);a&&e.addOp(UA,null)}else{const a=e.argsArray[s];a[0].push(t);a[1].push(...i);const r=a[2];switch(t){case TA:const e=i[0]+i[2],t=i[1]+i[3];r[0]=Math.min(r[0],i[0],e);r[1]=Math.min(r[1],i[1],t);r[2]=Math.max(r[2],i[0],e);r[3]=Math.max(r[3],i[1],t);break;case LA:case HA:r[0]=Math.min(r[0],i[0]);r[1]=Math.min(r[1],i[1]);r[2]=Math.max(r[2],i[0]);r[3]=Math.max(r[3],i[1])}}}parseColorSpace({cs:e,resources:t,localColorSpaceCache:i}){return ColorSpace.parseAsync({cs:e,xref:this.xref,resources:t,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:i}).catch((e=>{if(e instanceof AbortException)return null;if(this.options.ignoreErrors){warn(`parseColorSpace - ignoring ColorSpace: "${e}".`);return null}throw e}))}parseShading({shading:e,resources:t,localColorSpaceCache:i,localShadingPatternCache:a}){let s,r=a.get(e);if(r)return r;try{s=Pattern.parseShading(e,this.xref,t,this._pdfFunctionFactory,i).getIR()}catch(t){if(t instanceof AbortException)return null;if(this.options.ignoreErrors){warn(`parseShading - ignoring shading: "${t}".`);a.set(e,null);return null}throw t}r=`pattern_${this.idFactory.createObjId()}`;this.parsingType3Font&&(r=`${this.idFactory.getDocId()}_type3_${r}`);a.set(e,r);this.parsingType3Font?this.handler.send("commonobj",[r,"Pattern",s]):this.handler.send("obj",[r,this.pageIndex,"Pattern",s]);return r}handleColorN(e,t,i,a,s,r,n,g,o,c){const C=i.pop();if(C instanceof Name){const h=s.getRaw(C.name),l=h instanceof Ref&&o.getByRef(h);if(l)try{const s=a.base?a.base.getRgb(i,0):null,r=getTilingPatternIR(l.operatorListIR,l.dict,s);e.addOp(t,r);return}catch{}const Q=this.xref.fetchIfRef(h);if(Q){const s=Q instanceof BaseStream?Q.dict:Q,C=s.get("PatternType");if(C===fs){const g=a.base?a.base.getRgb(i,0):null;return this.handleTilingType(t,g,r,Q,s,e,n,o)}if(C===ps){const i=s.get("Shading"),a=this.parseShading({shading:i,resources:r,localColorSpaceCache:g,localShadingPatternCache:c});if(a){const i=lookupMatrix(s.getArray("Matrix"),null);e.addOp(t,["Shading",a,i])}return}throw new FormatError(`Unknown PatternType: ${C}`)}}throw new FormatError(`Unknown PatternName: ${C}`)}_parseVisibilityExpression(e,t,i){if(++t>10){warn("Visibility expression is too deeply nested");return}const a=e.length,s=this.xref.fetchIfRef(e[0]);if(!(a<2)&&s instanceof Name){switch(s.name){case"And":case"Or":case"Not":i.push(s.name);break;default:warn(`Invalid operator ${s.name} in visibility expression`);return}for(let s=1;s0)return{type:"OCMD",expression:t}}const t=i.get("OCGs");if(Array.isArray(t)||t instanceof Dict){const e=[];if(Array.isArray(t))for(const i of t)e.push(i.toString());else e.push(t.objId);return{type:a,ids:e,policy:i.get("P")instanceof Name?i.get("P").name:null,expression:null}}if(t instanceof Ref)return{type:a,id:t.toString()}}return null}getOperatorList({stream:e,task:t,resources:i,operatorList:a,initialState:s=null,fallbackFontDict:r=null}){i||=Dict.empty;s||=new EvalState;if(!a)throw new Error('getOperatorList: missing "operatorList" parameter');const n=this,g=this.xref;let o=!1;const c=new LocalImageCache,C=new LocalColorSpaceCache,h=new LocalGStateCache,l=new LocalTilingPatternCache,Q=new Map,E=i.get("XObject")||Dict.empty,u=i.get("Pattern")||Dict.empty,d=new StateManager(s),f=new EvaluatorPreprocessor(e,g,d),p=new TimeSlotManager;function closePendingRestoreOPS(e){for(let e=0,t=f.savedStatesDepth;e0&&a.addOp(GA,[t]);e=null;continue}}next(new Promise((function(e,s){if(!S)throw new FormatError("GState must be referred to by name.");const r=i.get("ExtGState");if(!(r instanceof Dict))throw new FormatError("ExtGState should be a dictionary.");const g=r.get(F);if(!(g instanceof Dict))throw new FormatError("GState should be a dictionary.");n.setGState({resources:i,gState:g,operatorList:a,cacheKey:F,task:t,stateManager:d,localGStateCache:h,localColorSpaceCache:C}).then(e,s)})).catch((function(e){if(!(e instanceof AbortException)){if(!n.options.ignoreErrors)throw e;warn(`getOperatorList - ignoring ExtGState: "${e}".`)}})));return;case LA:case HA:case JA:case YA:case vA:case KA:case TA:n.buildPath(a,s,e,o);continue;case Le:case He:case Ke:case Te:continue;case Ye:if(!(e[0]instanceof Name)){warn(`Expected name for beginMarkedContentProps arg0=${e[0]}`);a.addOp(Ye,["OC",null]);continue}if("OC"===e[0].name){next(n.parseMarkedContentProps(e[1],i).then((e=>{a.addOp(Ye,["OC",e])})).catch((e=>{if(!(e instanceof AbortException)){if(!n.options.ignoreErrors)throw e;warn(`getOperatorList - ignoring beginMarkedContentProps: "${e}".`);a.addOp(Ye,["OC",null])}})));return}e=[e[0].name,e[1]instanceof Dict?e[1].get("MCID"):null];break;default:if(null!==e){for(w=0,D=e.length;w{if(!(e instanceof AbortException)){if(!this.options.ignoreErrors)throw e;warn(`getOperatorList - ignoring errors during "${t.name}" task: "${e}".`);closePendingRestoreOPS()}}))}getTextContent({stream:e,task:t,resources:s,stateManager:r=null,includeMarkedContent:n=!1,sink:g,seenStyles:o=new Set,viewBox:c,lang:C=null,markedContentData:h=null,disableNormalization:l=!1,keepWhiteSpace:Q=!1}){s||=Dict.empty;r||=new StateManager(new TextState);n&&(h||={level:0});const E={items:[],styles:Object.create(null),lang:C},u={initialized:!1,str:[],totalWidth:0,totalHeight:0,width:0,height:0,vertical:!1,prevTransform:null,textAdvanceScale:0,spaceInFlowMin:0,spaceInFlowMax:0,trackingSpaceMin:1/0,negativeSpaceMax:-1/0,notASpace:-1/0,transform:null,fontName:null,hasEOL:!1},d=[" "," "];let f=0;function saveLastChar(e){const t=(f+1)%2,i=" "!==d[f]&&" "===d[t];d[f]=e;f=t;return!Q&&i}function shouldAddWhitepsace(){return!Q&&" "!==d[f]&&" "===d[(f+1)%2]}function resetLastChars(){d[0]=d[1]=" ";f=0}const p=this,m=this.xref,y=[];let w=null;const D=new LocalImageCache,b=new LocalGStateCache,F=new EvaluatorPreprocessor(e,m,r);let S;function pushWhitespace({width:e=0,height:t=0,transform:i=u.prevTransform,fontName:a=u.fontName}){E.items.push({str:" ",dir:"ltr",width:e,height:t,transform:i,fontName:a,hasEOL:!1})}function getCurrentTextTransform(){const e=S.font,t=[S.fontSize*S.textHScale,0,0,S.fontSize,0,S.textRise];if(e.isType3Font&&(S.fontSize<=1||e.isCharBBox)&&!isArrayEqual(S.fontMatrix,a)){const i=e.bbox[3]-e.bbox[1];i>0&&(t[3]*=i*S.fontMatrix[3])}return Util.transform(S.ctm,Util.transform(S.textMatrix,t))}function ensureTextContentItem(){if(u.initialized)return u;const{font:e,loadedName:t}=S;if(!o.has(t)){o.add(t);E.styles[t]={fontFamily:e.fallbackName,ascent:e.ascent,descent:e.descent,vertical:e.vertical};if(p.options.fontExtraProperties&&e.systemFontInfo){const i=E.styles[t];i.fontSubstitution=e.systemFontInfo.css;i.fontSubstitutionLoadedName=e.systemFontInfo.loadedName}}u.fontName=t;const i=u.transform=getCurrentTextTransform();if(e.vertical){u.width=u.totalWidth=Math.hypot(i[0],i[1]);u.height=u.totalHeight=0;u.vertical=!0}else{u.width=u.totalWidth=0;u.height=u.totalHeight=Math.hypot(i[2],i[3]);u.vertical=!1}const a=Math.hypot(S.textLineMatrix[0],S.textLineMatrix[1]),s=Math.hypot(S.ctm[0],S.ctm[1]);u.textAdvanceScale=s*a;const{fontSize:r}=S;u.trackingSpaceMin=.102*r;u.notASpace=.03*r;u.negativeSpaceMax=-.2*r;u.spaceInFlowMin=.102*r;u.spaceInFlowMax=.6*r;u.hasEOL=!1;u.initialized=!0;return u}function updateAdvanceScale(){if(!u.initialized)return;const e=Math.hypot(S.textLineMatrix[0],S.textLineMatrix[1]),t=Math.hypot(S.ctm[0],S.ctm[1])*e;if(t!==u.textAdvanceScale){if(u.vertical){u.totalHeight+=u.height*u.textAdvanceScale;u.height=0}else{u.totalWidth+=u.width*u.textAdvanceScale;u.width=0}u.textAdvanceScale=t}}function runBidiTransform(e){let t=e.str.join("");l||(t=function normalizeUnicode(e){if(!Ct){Ct=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;ht=new Map([["ſt","ſt"]])}return e.replaceAll(Ct,((e,t,i)=>t?t.normalize("NFKC"):ht.get(i)))}(t));const i=bidi(t,-1,e.vertical);return{str:i.str,dir:i.dir,width:Math.abs(e.totalWidth),height:Math.abs(e.totalHeight),transform:e.transform,fontName:e.fontName,hasEOL:e.hasEOL}}async function handleSetFont(e,i){const r=await p.loadFont(e,i,s);if(r.font.isType3Font)try{await r.loadType3Data(p,s,t)}catch{}S.loadedName=r.loadedName;S.font=r.font;S.fontMatrix=r.font.fontMatrix||a}function applyInverseRotation(e,t,i){const a=Math.hypot(i[0],i[1]);return[(i[0]*e+i[1]*t)/a,(i[2]*e+i[3]*t)/a]}function compareWithLastPosition(e){const t=getCurrentTextTransform();let i=t[4],a=t[5];if(S.font?.vertical){if(ic[2]||a+ec[3])return!1}else if(i+ec[2]||ac[3])return!1;if(!S.font||!u.prevTransform)return!0;let s=u.prevTransform[4],r=u.prevTransform[5];if(s===i&&r===a)return!0;let n=-1;t[0]&&0===t[1]&&0===t[2]?n=t[0]>0?0:180:t[1]&&0===t[0]&&0===t[3]&&(n=t[1]>0?90:270);switch(n){case 0:break;case 90:[i,a]=[a,i];[s,r]=[r,s];break;case 180:[i,a,s,r]=[-i,-a,-s,-r];break;case 270:[i,a]=[-a,-i];[s,r]=[-r,-s];break;default:[i,a]=applyInverseRotation(i,a,t);[s,r]=applyInverseRotation(s,r,u.prevTransform)}if(S.font.vertical){const e=(r-a)/u.textAdvanceScale,t=i-s,n=Math.sign(u.height);if(e.5*u.width){appendEOL();return!0}resetLastChars();flushTextContentItem();return!0}if(Math.abs(t)>u.width){appendEOL();return!0}e<=n*u.notASpace&&resetLastChars();if(e<=n*u.trackingSpaceMin)if(shouldAddWhitepsace()){resetLastChars();flushTextContentItem();pushWhitespace({height:Math.abs(e)})}else u.height+=e;else if(!addFakeSpaces(e,u.prevTransform,n))if(0===u.str.length){resetLastChars();pushWhitespace({height:Math.abs(e)})}else u.height+=e;Math.abs(t)>.25*u.width&&flushTextContentItem();return!0}const g=(i-s)/u.textAdvanceScale,o=a-r,C=Math.sign(u.width);if(g.5*u.height){appendEOL();return!0}resetLastChars();flushTextContentItem();return!0}if(Math.abs(o)>u.height){appendEOL();return!0}g<=C*u.notASpace&&resetLastChars();if(g<=C*u.trackingSpaceMin)if(shouldAddWhitepsace()){resetLastChars();flushTextContentItem();pushWhitespace({width:Math.abs(g)})}else u.width+=g;else if(!addFakeSpaces(g,u.prevTransform,C))if(0===u.str.length){resetLastChars();pushWhitespace({width:Math.abs(g)})}else u.width+=g;Math.abs(o)>.25*u.height&&flushTextContentItem();return!0}function buildTextContentItem({chars:e,extraSpacing:t}){const i=S.font;if(!e){const e=S.charSpacing+t;e&&(i.vertical?S.translateTextMatrix(0,-e):S.translateTextMatrix(e*S.textHScale,0));Q&&compareWithLastPosition(0);return}const a=i.charsToGlyphs(e),s=S.fontMatrix[0]*S.fontSize;for(let e=0,r=a.length;e0){const e=y.join("");y.length=0;buildTextContentItem({chars:e,extraSpacing:0})}break;case he:if(!r.state.font){p.ensureStateFont(r.state);continue}buildTextContentItem({chars:N[0],extraSpacing:0});break;case Be:if(!r.state.font){p.ensureStateFont(r.state);continue}S.carriageReturn();buildTextContentItem({chars:N[0],extraSpacing:0});break;case Qe:if(!r.state.font){p.ensureStateFont(r.state);continue}S.wordSpacing=N[0];S.charSpacing=N[1];S.carriageReturn();buildTextContentItem({chars:N[2],extraSpacing:0});break;case xe:flushTextContentItem();w??=s.get("XObject")||Dict.empty;R=N[0]instanceof Name;f=N[0].name;if(R&&D.getByName(f))break;next(new Promise((function(e,i){if(!R)throw new FormatError("XObject must be referred to by name.");let a=w.getRaw(f);if(a instanceof Ref){if(D.getByRef(a)){e();return}if(p.globalImageCache.getData(a,p.pageIndex)){e();return}a=m.fetch(a)}if(!(a instanceof BaseStream))throw new FormatError("XObject should be a stream");const E=a.dict.get("Subtype");if(!(E instanceof Name))throw new FormatError("XObject should have a Name subtype");if("Form"!==E.name){D.set(f,a.dict.objId,!0);e();return}const u=r.state.clone(),d=new StateManager(u),y=lookupMatrix(a.dict.getArray("Matrix"),null);y&&d.transform(y);enqueueChunk();const b={enqueueInvoked:!1,enqueue(e,t){this.enqueueInvoked=!0;g.enqueue(e,t)},get desiredSize(){return g.desiredSize},get ready(){return g.ready}};p.getTextContent({stream:a,task:t,resources:a.dict.get("Resources")||s,stateManager:d,includeMarkedContent:n,sink:b,seenStyles:o,viewBox:c,lang:C,markedContentData:h,disableNormalization:l,keepWhiteSpace:Q}).then((function(){b.enqueueInvoked||D.set(f,a.dict.objId,!0);e()}),i)})).catch((function(e){if(!(e instanceof AbortException)){if(!p.options.ignoreErrors)throw e;warn(`getTextContent - ignoring XObject: "${e}".`)}})));return;case GA:R=N[0]instanceof Name;f=N[0].name;if(R&&b.getByName(f))break;next(new Promise((function(e,t){if(!R)throw new FormatError("GState must be referred to by name.");const i=s.get("ExtGState");if(!(i instanceof Dict))throw new FormatError("ExtGState should be a dictionary.");const a=i.get(f);if(!(a instanceof Dict))throw new FormatError("GState should be a dictionary.");const r=a.get("Font");if(r){flushTextContentItem();S.fontName=null;S.fontSize=r[1];handleSetFont(null,r[0]).then(e,t)}else{b.set(f,a.objId,!0);e()}})).catch((function(e){if(!(e instanceof AbortException)){if(!p.options.ignoreErrors)throw e;warn(`getTextContent - ignoring ExtGState: "${e}".`)}})));return;case Je:flushTextContentItem();if(n){h.level++;E.items.push({type:"beginMarkedContent",tag:N[0]instanceof Name?N[0].name:null})}break;case Ye:flushTextContentItem();if(n){h.level++;let e=null;N[1]instanceof Dict&&(e=N[1].get("MCID"));E.items.push({type:"beginMarkedContentProps",id:Number.isInteger(e)?`${p.idFactory.getPageObjId()}_mc${e}`:null,tag:N[0]instanceof Name?N[0].name:null})}break;case ve:flushTextContentItem();if(n){if(0===h.level)break;h.level--;E.items.push({type:"endMarkedContent"})}break;case UA:!e||e.font===S.font&&e.fontSize===S.fontSize&&e.fontName===S.fontName||flushTextContentItem()}if(E.items.length>=g.desiredSize){d=!0;break}}if(d)next(ms);else{flushTextContentItem();enqueueChunk();e()}})).catch((e=>{if(!(e instanceof AbortException)){if(!this.options.ignoreErrors)throw e;warn(`getTextContent - ignoring errors during "${t.name}" task: "${e}".`);flushTextContentItem();enqueueChunk()}}))}async extractDataStructures(e,t){const i=this.xref;let a;const s=this.readToUnicode(t.toUnicode);if(t.composite){const i=e.get("CIDSystemInfo");i instanceof Dict&&(t.cidSystemInfo={registry:stringToPDFString(i.get("Registry")),ordering:stringToPDFString(i.get("Ordering")),supplement:i.get("Supplement")});try{const t=e.get("CIDToGIDMap");t instanceof BaseStream&&(a=t.getBytes())}catch(e){if(!this.options.ignoreErrors)throw e;warn(`extractDataStructures - ignoring CIDToGIDMap data: "${e}".`)}}const r=[];let n,g=null;if(e.has("Encoding")){n=e.get("Encoding");if(n instanceof Dict){g=n.get("BaseEncoding");g=g instanceof Name?g.name:null;if(n.has("Differences")){const e=n.get("Differences");let t=0;for(const a of e){const e=i.fetchIfRef(a);if("number"==typeof e)t=e;else{if(!(e instanceof Name))throw new FormatError(`Invalid entry in 'Differences' array: ${e}`);r[t++]=e.name}}}}else if(n instanceof Name)g=n.name;else{const e="Encoding is not a Name nor a Dict";if(!this.options.ignoreErrors)throw new FormatError(e);warn(e)}"MacRomanEncoding"!==g&&"MacExpertEncoding"!==g&&"WinAnsiEncoding"!==g&&(g=null)}const o=!t.file||t.isInternalFont,c=qi()[t.name];g&&o&&c&&(g=null);if(g)t.defaultEncoding=getEncoding(g);else{const e=!!(t.flags&Mi),i=!!(t.flags&xi);n=hi;"TrueType"!==t.type||i||(n=li);if(e||c){n=Ci;o&&(/Symbol/i.test(t.name)?n=Bi:/Dingbats/i.test(t.name)?n=Qi:/Wingdings/i.test(t.name)&&(n=li))}t.defaultEncoding=n}t.differences=r;t.baseEncodingName=g;t.hasEncoding=!!g||r.length>0;t.dict=e;t.toUnicode=await s;const C=await this.buildToUnicode(t);t.toUnicode=C;a&&(t.cidToGidMap=this.readCidToGidMap(a,C));return t}_simpleFontToUnicode(e,t=!1){assert(!e.composite,"Must be a simple font.");const i=[],a=e.defaultEncoding.slice(),s=e.baseEncodingName,r=e.differences;for(const e in r){const t=r[e];".notdef"!==t&&(a[e]=t)}const n=wi();for(const r in a){let g=a[r];if(""===g)continue;let o=n[g];if(void 0!==o){i[r]=String.fromCharCode(o);continue}let c=0;switch(g[0]){case"G":3===g.length&&(c=parseInt(g.substring(1),16));break;case"g":5===g.length&&(c=parseInt(g.substring(1),16));break;case"C":case"c":if(g.length>=3&&g.length<=4){const i=g.substring(1);if(t){c=parseInt(i,16);break}c=+i;if(Number.isNaN(c)&&Number.isInteger(parseInt(i,16)))return this._simpleFontToUnicode(e,!0)}break;case"u":o=getUnicodeForGlyph(g,n);-1!==o&&(c=o);break;default:switch(g){case"f_h":case"f_t":case"T_h":i[r]=g.replaceAll("_","");continue}}if(c>0&&c<=1114111&&Number.isInteger(c)){if(s&&c===+r){const e=getEncoding(s);if(e&&(g=e[r])){i[r]=String.fromCharCode(n[g]);continue}}i[r]=String.fromCodePoint(c)}}return i}async buildToUnicode(e){e.hasIncludedToUnicodeMap=e.toUnicode?.length>0;if(e.hasIncludedToUnicodeMap){!e.composite&&e.hasEncoding&&(e.fallbackToUnicode=this._simpleFontToUnicode(e));return e.toUnicode}if(!e.composite)return new ToUnicodeMap(this._simpleFontToUnicode(e));if(e.composite&&(e.cMap.builtInCMap&&!(e.cMap instanceof IdentityCMap)||"Adobe"===e.cidSystemInfo?.registry&&("GB1"===e.cidSystemInfo.ordering||"CNS1"===e.cidSystemInfo.ordering||"Japan1"===e.cidSystemInfo.ordering||"Korea1"===e.cidSystemInfo.ordering))){const{registry:t,ordering:i}=e.cidSystemInfo,a=Name.get(`${t}-${i}-UCS2`),s=await CMapFactory.create({encoding:a,fetchBuiltInCMap:this._fetchBuiltInCMapBound,useCMap:null}),r=[],n=[];e.cMap.forEach((function(e,t){if(t>65535)throw new FormatError("Max size of CID is 65,535");const i=s.lookup(t);if(i){n.length=0;for(let e=0,t=i.length;e>1;(0!==s||t.has(r))&&(i[r]=s)}return i}extractWidths(e,t,i){const a=this.xref;let s=[],r=0;const n=[];let g;if(i.composite){const t=e.get("DW");r="number"==typeof t?Math.ceil(t):1e3;const o=e.get("W");if(Array.isArray(o))for(let e=0,t=o.length;e{const t=o.get(e),s=new OperatorList;return a.getOperatorList({stream:t,task:i,resources:c,operatorList:s}).then((()=>{s.fnArray[0]===ue&&this._removeType3ColorOperators(s,E);C[e]=s.getIR();for(const e of s.dependencies)n.add(e)})).catch((function(t){warn(`Type3 font resource "${e}" is not available.`);const i=new OperatorList;C[e]=i.getIR()}))}));this.type3Loaded=g.then((()=>{r.charProcOperatorList=C;if(this._bbox){r.isCharBBox=!0;r.bbox=this._bbox}}));return this.type3Loaded}_removeType3ColorOperators(e,t=NaN){const i=Util.normalizeRect(e.argsArray[0].slice(2)),a=i[2]-i[0],s=i[3]-i[1],r=Math.hypot(a,s);if(0===a||0===s){e.fnArray.splice(0,1);e.argsArray.splice(0,1)}else if(0===t||Math.round(r/t)>=10){this._bbox||(this._bbox=[1/0,1/0,-1/0,-1/0]);this._bbox[0]=Math.min(this._bbox[0],i[0]);this._bbox[1]=Math.min(this._bbox[1],i[1]);this._bbox[2]=Math.max(this._bbox[2],i[2]);this._bbox[3]=Math.max(this._bbox[3],i[3])}let n=0,g=e.length;for(;n=LA&&r<=zA;if(s.variableArgs)g>n&&info(`Command ${a}: expected [0, ${n}] args, but received ${g} args.`);else{if(g!==n){const e=this.nonProcessedArgs;for(;g>n;){e.push(t.shift());g--}for(;gEvaluatorPreprocessor.MAX_INVALID_PATH_OPS)throw new FormatError(`Invalid ${e}`);warn(`Skipping ${e}`);null!==t&&(t.length=0);continue}}this.preprocessCommand(r,t);e.fn=r;e.args=t;return!0}if(i===Bt)return!1;if(null!==i){null===t&&(t=[]);t.push(i);if(t.length>33)throw new FormatError("Too many arguments")}}}preprocessCommand(e,t){switch(0|e){case MA:this.stateManager.save();break;case UA:this.stateManager.restore();break;case xA:this.stateManager.transform(t)}}}class DefaultAppearanceEvaluator extends EvaluatorPreprocessor{constructor(e){super(new StringStream(e))}parse(){const e={fn:0,args:[]},t={fontSize:0,fontName:"",fontColor:new Uint8ClampedArray(3)};try{for(;;){e.args.length=0;if(!this.read(e))break;if(0!==this.savedStatesDepth)continue;const{fn:i,args:a}=e;switch(0|i){case re:const[e,i]=a;e instanceof Name&&(t.fontName=e.name);"number"==typeof i&&i>0&&(t.fontSize=i);break;case Se:ColorSpace.singletons.rgb.getRgbItem(a,0,t.fontColor,0);break;case be:ColorSpace.singletons.gray.getRgbItem(a,0,t.fontColor,0);break;case Re:ColorSpace.singletons.cmyk.getRgbItem(a,0,t.fontColor,0)}}}catch(e){warn(`parseDefaultAppearance - ignoring errors: "${e}".`)}return t}}function parseDefaultAppearance(e){return new DefaultAppearanceEvaluator(e).parse()}class AppearanceStreamEvaluator extends EvaluatorPreprocessor{constructor(e,t,i){super(e);this.stream=e;this.evaluatorOptions=t;this.xref=i;this.resources=e.dict?.get("Resources")}parse(){const e={fn:0,args:[]};let t={scaleFactor:1,fontSize:0,fontName:"",fontColor:new Uint8ClampedArray(3),fillColorSpace:ColorSpace.singletons.gray},i=!1;const a=[];try{for(;;){e.args.length=0;if(i||!this.read(e))break;const{fn:s,args:r}=e;switch(0|s){case MA:a.push({scaleFactor:t.scaleFactor,fontSize:t.fontSize,fontName:t.fontName,fontColor:t.fontColor.slice(),fillColorSpace:t.fillColorSpace});break;case UA:t=a.pop()||t;break;case ce:t.scaleFactor*=Math.hypot(r[0],r[1]);break;case re:const[e,s]=r;e instanceof Name&&(t.fontName=e.name);"number"==typeof s&&s>0&&(t.fontSize=s*t.scaleFactor);break;case fe:t.fillColorSpace=ColorSpace.parse({cs:r[0],xref:this.xref,resources:this.resources,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:this._localColorSpaceCache});break;case ye:t.fillColorSpace.getRgbItem(r,0,t.fontColor,0);break;case Se:ColorSpace.singletons.rgb.getRgbItem(r,0,t.fontColor,0);break;case be:ColorSpace.singletons.gray.getRgbItem(r,0,t.fontColor,0);break;case Re:ColorSpace.singletons.cmyk.getRgbItem(r,0,t.fontColor,0);break;case he:case le:case Be:case Qe:i=!0}}}catch(e){warn(`parseAppearanceStream - ignoring errors: "${e}".`)}this.stream.reset();delete t.scaleFactor;delete t.fillColorSpace;return t}get _localColorSpaceCache(){return shadow(this,"_localColorSpaceCache",new LocalColorSpaceCache)}get _pdfFunctionFactory(){return shadow(this,"_pdfFunctionFactory",new PDFFunctionFactory({xref:this.xref,isEvalSupported:this.evaluatorOptions.isEvalSupported}))}}function getPdfColor(e,t){if(e[0]===e[1]&&e[1]===e[2]){return`${numberToString(e[0]/255)} ${t?"g":"G"}`}return Array.from(e,(e=>numberToString(e/255))).join(" ")+" "+(t?"rg":"RG")}class FakeUnicodeFont{constructor(e,t){this.xref=e;this.widths=null;this.firstChar=1/0;this.lastChar=-1/0;this.fontFamily=t;const i=new OffscreenCanvas(1,1);this.ctxMeasure=i.getContext("2d",{willReadFrequently:!0});FakeUnicodeFont._fontNameId||(FakeUnicodeFont._fontNameId=1);this.fontName=Name.get(`InvalidPDFjsFont_${t}_${FakeUnicodeFont._fontNameId++}`)}get fontDescriptorRef(){if(!FakeUnicodeFont._fontDescriptorRef){const e=new Dict(this.xref);e.set("Type",Name.get("FontDescriptor"));e.set("FontName",this.fontName);e.set("FontFamily","MyriadPro Regular");e.set("FontBBox",[0,0,0,0]);e.set("FontStretch",Name.get("Normal"));e.set("FontWeight",400);e.set("ItalicAngle",0);FakeUnicodeFont._fontDescriptorRef=this.xref.getNewPersistentRef(e)}return FakeUnicodeFont._fontDescriptorRef}get descendantFontRef(){const e=new Dict(this.xref);e.set("BaseFont",this.fontName);e.set("Type",Name.get("Font"));e.set("Subtype",Name.get("CIDFontType0"));e.set("CIDToGIDMap",Name.get("Identity"));e.set("FirstChar",this.firstChar);e.set("LastChar",this.lastChar);e.set("FontDescriptor",this.fontDescriptorRef);e.set("DW",1e3);const t=[],i=[...this.widths.entries()].sort();let a=null,s=null;for(const[e,r]of i)if(a)if(e===a+s.length)s.push(r);else{t.push(a,s);a=e;s=[r]}else{a=e;s=[r]}a&&t.push(a,s);e.set("W",t);const r=new Dict(this.xref);r.set("Ordering","Identity");r.set("Registry","Adobe");r.set("Supplement",0);e.set("CIDSystemInfo",r);return this.xref.getNewPersistentRef(e)}get baseFontRef(){const e=new Dict(this.xref);e.set("BaseFont",this.fontName);e.set("Type",Name.get("Font"));e.set("Subtype",Name.get("Type0"));e.set("Encoding",Name.get("Identity-H"));e.set("DescendantFonts",[this.descendantFontRef]);e.set("ToUnicode",Name.get("Identity-H"));return this.xref.getNewPersistentRef(e)}get resources(){const e=new Dict(this.xref),t=new Dict(this.xref);t.set(this.fontName.name,this.baseFontRef);e.set("Font",t);return e}_createContext(){this.widths=new Map;this.ctxMeasure.font=`1000px ${this.fontFamily}`;return this.ctxMeasure}createFontResources(e){const t=this._createContext();for(const i of e.split(/\r\n?|\n/))for(const e of i.split("")){const i=e.charCodeAt(0);if(this.widths.has(i))continue;const a=t.measureText(e),s=Math.ceil(a.width);this.widths.set(i,s);this.firstChar=Math.min(i,this.firstChar);this.lastChar=Math.max(i,this.lastChar)}return this.resources}static getFirstPositionInfo(e,t,i){const[a,n,g,o]=e;let c=g-a,C=o-n;t%180!=0&&([c,C]=[C,c]);const h=s*i;return{coords:[0,C+r*i-h],bbox:[0,0,c,C],matrix:0!==t?getRotationMatrix(t,C,h):void 0}}createAppearance(e,t,i,a,n,g){const o=this._createContext(),c=[];let C=-1/0;for(const t of e.split(/\r\n?|\n/)){c.push(t);const e=o.measureText(t).width;C=Math.max(C,e);for(const e of codePointIter(t)){const t=String.fromCodePoint(e);let i=this.widths.get(e);if(void 0===i){const a=o.measureText(t);i=Math.ceil(a.width);this.widths.set(e,i);this.firstChar=Math.min(e,this.firstChar);this.lastChar=Math.max(e,this.lastChar)}}}C*=a/1e3;const[h,l,Q,E]=t;let u=Q-h,d=E-l;i%180!=0&&([u,d]=[d,u]);let f=1;C>u&&(f=u/C);let p=1;const m=s*a,y=r*a,w=m*c.length;w>d&&(p=d/w);const D=a*Math.min(f,p),b=["q",`0 0 ${numberToString(u)} ${numberToString(d)} re W n`,"BT",`1 0 0 1 0 ${numberToString(d+y)} Tm 0 Tc ${getPdfColor(n,!0)}`,`/${this.fontName.name} ${numberToString(D)} Tf`],{resources:F}=this;if(1!==(g="number"==typeof g&&g>=0&&g<=1?g:1)){b.push("/R0 gs");const e=new Dict(this.xref),t=new Dict(this.xref);t.set("ca",g);t.set("CA",g);t.set("Type",Name.get("ExtGState"));e.set("R0",t);F.set("ExtGState",e)}const S=numberToString(m);for(const e of c)b.push(`0 -${S} Td <${stringToUTF16HexString(e)}> Tj`);b.push("ET","Q");const k=b.join("\n"),R=new Dict(this.xref);R.set("Subtype",Name.get("Form"));R.set("Type",Name.get("XObject"));R.set("BBox",[0,0,u,d]);R.set("Length",k.length);R.set("Resources",F);if(i){const e=getRotationMatrix(i,u,d);R.set("Matrix",e)}const N=new StringStream(k);N.dict=R;return N}}class NameOrNumberTree{constructor(e,t,i){this.root=e;this.xref=t;this._type=i}getAll(){const e=new Map;if(!this.root)return e;const t=this.xref,i=new RefSet;i.put(this.root);const a=[this.root];for(;a.length>0;){const s=t.fetchIfRef(a.shift());if(!(s instanceof Dict))continue;if(s.has("Kids")){const e=s.get("Kids");if(!Array.isArray(e))continue;for(const t of e){if(i.has(t))throw new FormatError(`Duplicate entry in "${this._type}" tree.`);a.push(t);i.put(t)}continue}const r=s.get(this._type);if(Array.isArray(r))for(let i=0,a=r.length;i10){warn(`Search depth limit reached for "${this._type}" tree.`);return null}const s=i.get("Kids");if(!Array.isArray(s))return null;let r=0,n=s.length-1;for(;r<=n;){const a=r+n>>1,g=t.fetchIfRef(s[a]),o=g.get("Limits");if(et.fetchIfRef(o[1]))){i=g;break}r=a+1}}if(r>n)return null}const s=i.get(this._type);if(Array.isArray(s)){let i=0,a=s.length-2;for(;i<=a;){const r=i+a>>1,n=r+(1&r),g=t.fetchIfRef(s[n]);if(eg))return s[n+1];i=n+2}}}return null}get(e){return this.xref.fetchIfRef(this.getRaw(e))}}class NameTree extends NameOrNumberTree{constructor(e,t){super(e,t,"Names")}}class NumberTree extends NameOrNumberTree{constructor(e,t){super(e,t,"Nums")}}function clearGlobalCaches(){!function clearPatternCaches(){Qa=Object.create(null)}();!function clearPrimitiveCaches(){Qt=Object.create(null);Et=Object.create(null);ut=Object.create(null)}();!function clearUnicodeCaches(){ki.clear()}();JpxImage.cleanup()}function pickPlatformItem(e){return e instanceof Dict?e.has("UF")?e.get("UF"):e.has("F")?e.get("F"):e.has("Unix")?e.get("Unix"):e.has("Mac")?e.get("Mac"):e.has("DOS")?e.get("DOS"):null:null}class FileSpec{#U=!1;constructor(e,t,i=!1){if(e instanceof Dict){this.xref=t;this.root=e;e.has("FS")&&(this.fs=e.get("FS"));e.has("RF")&&warn("Related file specifications are not supported");i||(e.has("EF")?this.#U=!0:warn("Non-embedded file specifications are not supported"))}}get filename(){let e="";const t=pickPlatformItem(this.root);t&&"string"==typeof t&&(e=stringToPDFString(t).replaceAll("\\\\","\\").replaceAll("\\/","/").replaceAll("\\","/"));return shadow(this,"filename",e||"unnamed")}get content(){if(!this.#U)return null;this._contentRef||=pickPlatformItem(this.root?.get("EF"));let e=null;if(this._contentRef){const t=this.xref.fetchIfRef(this._contentRef);t instanceof BaseStream?e=t.getBytes():warn("Embedded file specification points to non-existing/invalid content")}else warn("Embedded file specification does not have any content");return e}get description(){let e="";const t=this.root?.get("Desc");t&&"string"==typeof t&&(e=stringToPDFString(t));return shadow(this,"description",e)}get serializable(){return{rawFilename:this.filename,filename:(e=this.filename,e.substring(e.lastIndexOf("/")+1)),content:this.content,description:this.description};var e}}const ys=0,ws=-2,Ds=-3,bs=-4,Fs=-5,Ss=-6,ks=-9;function isWhitespace(e,t){const i=e[t];return" "===i||"\n"===i||"\r"===i||"\t"===i}class XMLParserBase{_resolveEntities(e){return e.replaceAll(/&([^;]+);/g,((e,t)=>{if("#x"===t.substring(0,2))return String.fromCodePoint(parseInt(t.substring(2),16));if("#"===t.substring(0,1))return String.fromCodePoint(parseInt(t.substring(1),10));switch(t){case"lt":return"<";case"gt":return">";case"amp":return"&";case"quot":return'"';case"apos":return"'"}return this.onResolveEntity(t)}))}_parseContent(e,t){const i=[];let a=t;function skipWs(){for(;a"!==e[a]&&"/"!==e[a];)++a;const s=e.substring(t,a);skipWs();for(;a"!==e[a]&&"/"!==e[a]&&"?"!==e[a];){skipWs();let t="",s="";for(;a"!==e[i]&&"?"!==e[i]&&"/"!==e[i];)++i;const a=e.substring(t,i);!function skipWs(){for(;i"!==e[i+1]);)++i;return{name:a,value:e.substring(s,i),parsed:i-t}}parseXml(e){let t=0;for(;t",i);if(t<0){this.onError(ks);return}this.onEndElement(e.substring(i,t));i=t+1;break;case"?":++i;const a=this._parseProcessingInstruction(e,i);if("?>"!==e.substring(i+a.parsed,i+a.parsed+2)){this.onError(Ds);return}this.onPi(a.name,a.value);i+=a.parsed+2;break;case"!":if("--"===e.substring(i+1,i+3)){t=e.indexOf("--\x3e",i+3);if(t<0){this.onError(Fs);return}this.onComment(e.substring(i+3,t));i=t+3}else if("[CDATA["===e.substring(i+1,i+8)){t=e.indexOf("]]>",i+8);if(t<0){this.onError(ws);return}this.onCdata(e.substring(i+8,t));i=t+3}else{if("DOCTYPE"!==e.substring(i+1,i+8)){this.onError(Ss);return}{const a=e.indexOf("[",i+8);let s=!1;t=e.indexOf(">",i+8);if(t<0){this.onError(bs);return}if(a>0&&t>a){t=e.indexOf("]>",i+8);if(t<0){this.onError(bs);return}s=!0}const r=e.substring(i+8,t+(s?1:0));this.onDoctype(r);i=t+(s?2:1)}}break;default:const s=this._parseContent(e,i);if(null===s){this.onError(Ss);return}let r=!1;if("/>"===e.substring(i+s.parsed,i+s.parsed+2))r=!0;else if(">"!==e.substring(i+s.parsed,i+s.parsed+1)){this.onError(ks);return}this.onBeginElement(s.name,s.attributes,r);i+=s.parsed+(r?2:1)}}else{for(;i0}searchNode(e,t){if(t>=e.length)return this;const i=e[t];if(i.name.startsWith("#")&&t0){a.push([s,0]);s=s.childNodes[0]}else{if(0===a.length)return null;for(;0!==a.length;){const[e,t]=a.pop(),i=t+1;if(i");for(const t of this.childNodes)t.dump(e);e.push(``)}else this.nodeValue?e.push(`>${encodeToXmlString(this.nodeValue)}`):e.push("/>")}else e.push(encodeToXmlString(this.nodeValue))}}class SimpleXMLParser extends XMLParserBase{constructor({hasAttributes:e=!1,lowerCaseName:t=!1}){super();this._currentFragment=null;this._stack=null;this._errorCode=ys;this._hasAttributes=e;this._lowerCaseName=t}parseFromString(e){this._currentFragment=[];this._stack=[];this._errorCode=ys;this.parseXml(e);if(this._errorCode!==ys)return;const[t]=this._currentFragment;return t?{documentElement:t}:void 0}onText(e){if(function isWhitespaceString(e){for(let t=0,i=e.length;t\\376\\377([^<]+)/g,(function(e,t){const i=t.replaceAll(/\\([0-3])([0-7])([0-7])/g,(function(e,t,i,a){return String.fromCharCode(64*t+8*i+1*a)})).replaceAll(/&(amp|apos|gt|lt|quot);/g,(function(e,t){switch(t){case"amp":return"&";case"apos":return"'";case"gt":return">";case"lt":return"<";case"quot":return'"'}throw new Error(`_repair: ${t} isn't defined.`)})),a=[">"];for(let e=0,t=i.length;e=32&&t<127&&60!==t&&62!==t&&38!==t?a.push(String.fromCharCode(t)):a.push("&#x"+(65536+t).toString(16).substring(1)+";")}return a.join("")}))}_getSequence(e){const t=e.nodeName;return"rdf:bag"!==t&&"rdf:seq"!==t&&"rdf:alt"!==t?null:e.childNodes.filter((e=>"rdf:li"===e.nodeName))}_parseArray(e){if(!e.hasChildNodes())return;const[t]=e.childNodes,i=this._getSequence(t)||[];this._metadataMap.set(e.nodeName,i.map((e=>e.textContent.trim())))}_parse(e){let t=e.documentElement;if("rdf:rdf"!==t.nodeName){t=t.firstChild;for(;t&&"rdf:rdf"!==t.nodeName;)t=t.nextSibling}if(t&&"rdf:rdf"===t.nodeName&&t.hasChildNodes())for(const e of t.childNodes)if("rdf:description"===e.nodeName)for(const t of e.childNodes){const e=t.nodeName;switch(e){case"#text":continue;case"dc:creator":case"dc:subject":this._parseArray(t);continue}this._metadataMap.set(e,t.textContent.trim())}}get serializable(){return{parsedData:this._metadataMap,rawData:this._data}}}const Rs=1,Ns=2,Gs=3,Ms=4,Us=5;class StructTreeRoot{constructor(e,t){this.dict=e;this.ref=t instanceof Ref?t:null;this.roleMap=new Map;this.structParentIds=null}init(){this.readRoleMap()}#x(e,t,i){if(!(e instanceof Ref)||t<0)return;this.structParentIds||=new RefSetCache;let a=this.structParentIds.get(e);if(!a){a=[];this.structParentIds.put(e,a)}a.push([t,i])}addAnnotationIdToPage(e,t){this.#x(e,t,Ms)}readRoleMap(){const e=this.dict.get("RoleMap");if(e instanceof Dict)for(const[t,i]of e)i instanceof Name&&this.roleMap.set(t,i.name)}static async canCreateStructureTree({catalogRef:e,pdfManager:t,newAnnotationsByPage:i}){if(!(e instanceof Ref)){warn("Cannot save the struct tree: no catalog reference.");return!1}let a=0,s=!0;for(const[e,r]of i){const{ref:i}=await t.getPage(e);if(!(i instanceof Ref)){warn(`Cannot save the struct tree: page ${e} has no ref.`);s=!0;break}for(const e of r)if(e.accessibilityData?.type){e.parentTreeId=a++;s=!1}}if(s){for(const e of i.values())for(const t of e)delete t.parentTreeId;return!1}return!0}static async createStructureTree({newAnnotationsByPage:e,xref:t,catalogRef:i,pdfManager:a,changes:s}){const r=a.catalog.cloneDict(),n=new RefSetCache;n.put(i,r);const g=t.getNewTemporaryRef();r.set("StructTreeRoot",g);const o=new Dict(t);o.set("Type",Name.get("StructTreeRoot"));const c=t.getNewTemporaryRef();o.set("ParentTree",c);const C=[];o.set("K",C);n.put(g,o);const h=new Dict(t),l=[];h.set("Nums",l);const Q=await this.#L({newAnnotationsByPage:e,structTreeRootRef:g,structTreeRoot:null,kids:C,nums:l,xref:t,pdfManager:a,changes:s,cache:n});o.set("ParentTreeNextKey",Q);n.put(c,h);for(const[e,t]of n.items())s.put(e,{data:t})}async canUpdateStructTree({pdfManager:e,xref:t,newAnnotationsByPage:i}){if(!this.ref){warn("Cannot update the struct tree: no root reference.");return!1}let a=this.dict.get("ParentTreeNextKey");if(!Number.isInteger(a)||a<0){warn("Cannot update the struct tree: invalid next key.");return!1}const s=this.dict.get("ParentTree");if(!(s instanceof Dict)){warn("Cannot update the struct tree: ParentTree isn't a dict.");return!1}const r=s.get("Nums");if(!Array.isArray(r)){warn("Cannot update the struct tree: nums isn't an array.");return!1}const n=new NumberTree(s,t);for(const t of i.keys()){const{pageDict:i}=await e.getPage(t);if(!i.has("StructParents"))continue;const a=i.get("StructParents");if(!Number.isInteger(a)||!Array.isArray(n.get(a))){warn(`Cannot save the struct tree: page ${t} has a wrong id.`);return!1}}let g=!0;for(const[t,s]of i){const{pageDict:i}=await e.getPage(t);StructTreeRoot.#H({elements:s,xref:this.dict.xref,pageDict:i,numberTree:n});for(const e of s)if(e.accessibilityData?.type){e.accessibilityData.structParent>=0||(e.parentTreeId=a++);g=!1}}if(g){for(const e of i.values())for(const t of e){delete t.parentTreeId;delete t.structTreeParent}return!1}return!0}async updateStructureTree({newAnnotationsByPage:e,pdfManager:t,changes:i}){const a=this.dict.xref,s=this.dict.clone(),r=this.ref,n=new RefSetCache;n.put(r,s);let g,o=s.getRaw("ParentTree");if(o instanceof Ref)g=a.fetch(o);else{g=o;o=a.getNewTemporaryRef();s.set("ParentTree",o)}g=g.clone();n.put(o,g);let c=g.getRaw("Nums"),C=null;if(c instanceof Ref){C=c;c=a.fetch(C)}c=c.slice();C||g.set("Nums",c);const h=await StructTreeRoot.#L({newAnnotationsByPage:e,structTreeRootRef:r,structTreeRoot:this,kids:null,nums:c,xref:a,pdfManager:t,changes:i,cache:n});if(-1!==h){s.set("ParentTreeNextKey",h);C&&n.put(C,c);for(const[e,t]of n.items())i.put(e,{data:t})}}static async#L({newAnnotationsByPage:e,structTreeRootRef:t,structTreeRoot:i,kids:a,nums:s,xref:r,pdfManager:n,changes:g,cache:o}){const c=Name.get("OBJR");let C,h=-1;for(const[l,Q]of e){const e=await n.getPage(l),{ref:E}=e,u=E instanceof Ref;for(const{accessibilityData:n,ref:d,parentTreeId:f,structTreeParent:p}of Q){if(!n?.type)continue;const{structParent:Q}=n;if(i&&Number.isInteger(Q)&&Q>=0){let t=(C||=new Map).get(l);if(void 0===t){t=new StructTreePage(i,e.pageDict).collectObjects(E);C.set(l,t)}const a=t?.get(Q);if(a){const e=r.fetch(a).clone();StructTreeRoot.#J(e,n);g.put(a,{data:e});continue}}h=Math.max(h,f);const m=r.getNewTemporaryRef(),y=new Dict(r);StructTreeRoot.#J(y,n);await this.#Y({structTreeParent:p,tagDict:y,newTagRef:m,structTreeRootRef:t,fallbackKids:a,xref:r,cache:o});const w=new Dict(r);y.set("K",w);w.set("Type",c);u&&w.set("Pg",E);w.set("Obj",d);o.put(m,y);s.push(f,m)}}return h+1}static#J(e,{type:t,title:i,lang:a,alt:s,expanded:r,actualText:n}){e.set("S",Name.get(t));i&&e.set("T",stringToAsciiOrUTF16BE(i));a&&e.set("Lang",stringToAsciiOrUTF16BE(a));s&&e.set("Alt",stringToAsciiOrUTF16BE(s));r&&e.set("E",stringToAsciiOrUTF16BE(r));n&&e.set("ActualText",stringToAsciiOrUTF16BE(n))}static#H({elements:e,xref:t,pageDict:i,numberTree:a}){const s=new Map;for(const t of e)if(t.structTreeParentId){const e=parseInt(t.structTreeParentId.split("_mc")[1],10);let i=s.get(e);if(!i){i=[];s.set(e,i)}i.push(t)}const r=i.get("StructParents");if(!Number.isInteger(r))return;const n=a.get(r),updateElement=(e,i,a)=>{const r=s.get(e);if(r){const e=i.getRaw("P"),s=t.fetchIfRef(e);if(e instanceof Ref&&s instanceof Dict){const e={ref:a,dict:i};for(const t of r)t.structTreeParent=e}return!0}return!1};for(const e of n){if(!(e instanceof Ref))continue;const i=t.fetch(e),a=i.get("K");if(Number.isInteger(a))updateElement(a,i,e);else if(Array.isArray(a))for(let s of a){s=t.fetchIfRef(s);if(Number.isInteger(s)&&updateElement(s,i,e))break;if(!(s instanceof Dict))continue;if(!isName(s.get("Type"),"MCR"))break;const a=s.get("MCID");if(Number.isInteger(a)&&updateElement(a,i,e))break}}}static async#Y({structTreeParent:e,tagDict:t,newTagRef:i,structTreeRootRef:a,fallbackKids:s,xref:r,cache:n}){let g,o=null;if(e){({ref:o}=e);g=e.dict.getRaw("P")||a}else g=a;t.set("P",g);const c=r.fetchIfRef(g);if(!c){s.push(i);return}let C=n.get(g);if(!C){C=c.clone();n.put(g,C)}const h=C.getRaw("K");let l=h instanceof Ref?n.get(h):null;if(!l){l=r.fetchIfRef(h);l=Array.isArray(l)?l.slice():[h];const e=r.getNewTemporaryRef();C.set("K",e);n.put(e,l)}const Q=l.indexOf(o);l.splice(Q>=0?Q+1:l.length,0,i)}}class StructElementNode{constructor(e,t){this.tree=e;this.dict=t;this.kids=[];this.parseKids()}get role(){const e=this.dict.get("S"),t=e instanceof Name?e.name:"",{root:i}=this.tree;return i.roleMap.has(t)?i.roleMap.get(t):t}parseKids(){let e=null;const t=this.dict.getRaw("Pg");t instanceof Ref&&(e=t.toString());const i=this.dict.get("K");if(Array.isArray(i))for(const t of i){const i=this.parseKid(e,t);i&&this.kids.push(i)}else{const t=this.parseKid(e,i);t&&this.kids.push(t)}}parseKid(e,t){if(Number.isInteger(t))return this.tree.pageDict.objId!==e?null:new StructElement({type:Rs,mcid:t,pageObjId:e});let i=null;t instanceof Ref?i=this.dict.xref.fetch(t):t instanceof Dict&&(i=t);if(!i)return null;const a=i.getRaw("Pg");a instanceof Ref&&(e=a.toString());const s=i.get("Type")instanceof Name?i.get("Type").name:null;if("MCR"===s){if(this.tree.pageDict.objId!==e)return null;const t=i.getRaw("Stm");return new StructElement({type:Ns,refObjId:t instanceof Ref?t.toString():null,pageObjId:e,mcid:i.get("MCID")})}if("OBJR"===s){if(this.tree.pageDict.objId!==e)return null;const t=i.getRaw("Obj");return new StructElement({type:Gs,refObjId:t instanceof Ref?t.toString():null,pageObjId:e})}return new StructElement({type:Us,dict:i})}}class StructElement{constructor({type:e,dict:t=null,mcid:i=null,pageObjId:a=null,refObjId:s=null}){this.type=e;this.dict=t;this.mcid=i;this.pageObjId=a;this.refObjId=s;this.parentNode=null}}class StructTreePage{constructor(e,t){this.root=e;this.rootDict=e?e.dict:null;this.pageDict=t;this.nodes=[]}collectObjects(e){if(!(this.root&&this.rootDict&&e instanceof Ref))return null;const t=this.rootDict.get("ParentTree");if(!t)return null;const i=this.root.structParentIds?.get(e);if(!i)return null;const a=new Map,s=new NumberTree(t,this.rootDict.xref);for(const[e]of i){const t=s.getRaw(e);t instanceof Ref&&a.set(e,t)}return a}parse(e){if(!(this.root&&this.rootDict&&e instanceof Ref))return;const t=this.rootDict.get("ParentTree");if(!t)return;const i=this.pageDict.get("StructParents"),a=this.root.structParentIds?.get(e);if(!Number.isInteger(i)&&!a)return;const s=new Map,r=new NumberTree(t,this.rootDict.xref);if(Number.isInteger(i)){const e=r.get(i);if(Array.isArray(e))for(const t of e)t instanceof Ref&&this.addNode(this.rootDict.xref.fetch(t),s)}if(a)for(const[e,t]of a){const i=r.get(e);if(i){const e=this.addNode(this.rootDict.xref.fetchIfRef(i),s);1===e?.kids?.length&&e.kids[0].type===Gs&&(e.kids[0].type=t)}}}addNode(e,t,i=0){if(i>40){warn("StructTree MAX_DEPTH reached.");return null}if(!(e instanceof Dict))return null;if(t.has(e))return t.get(e);const a=new StructElementNode(this,e);t.set(e,a);const s=e.get("P");if(!s||isName(s.get("Type"),"StructTreeRoot")){this.addTopLevelNode(e,a)||t.delete(e);return a}const r=this.addNode(s,t,i+1);if(!r)return a;let n=!1;for(const t of r.kids)if(t.type===Us&&t.dict===e){t.parentNode=a;n=!0}n||t.delete(e);return a}addTopLevelNode(e,t){const i=this.rootDict.get("K");if(!i)return!1;if(i instanceof Dict){if(i.objId!==e.objId)return!1;this.nodes[0]=t;return!0}if(!Array.isArray(i))return!0;let a=!1;for(let s=0;s40){warn("StructTree too deep to be fully serialized.");return}const a=Object.create(null);a.role=e.role;a.children=[];t.children.push(a);let s=e.dict.get("Alt");"string"!=typeof s&&(s=e.dict.get("ActualText"));"string"==typeof s&&(a.alt=stringToPDFString(s));const r=e.dict.get("A");if(r instanceof Dict){const e=lookupNormalRect(r.getArray("BBox"),null);if(e)a.bbox=e;else{const e=r.get("Width"),t=r.get("Height");"number"==typeof e&&e>0&&"number"==typeof t&&t>0&&(a.bbox=[0,0,e,t])}}const n=e.dict.get("Lang");"string"==typeof n&&(a.lang=stringToPDFString(n));for(const t of e.kids){const e=t.type===Us?t.parentNode:null;e?nodeToSerializable(e,a,i+1):t.type===Rs||t.type===Ns?a.children.push({type:"content",id:`p${t.pageObjId}_mc${t.mcid}`}):t.type===Gs?a.children.push({type:"object",id:t.refObjId}):t.type===Ms&&a.children.push({type:"annotation",id:`pdfjs_internal_id_${t.refObjId}`})}}const e=Object.create(null);e.children=[];e.role="Root";for(const t of this.nodes)t&&nodeToSerializable(t,e);return e}}function isValidExplicitDest(e){if(!Array.isArray(e)||e.length<2)return!1;const[t,i,...a]=e;if(!(t instanceof Ref||Number.isInteger(t)))return!1;if(!(i instanceof Name))return!1;const s=a.length;let r=!0;switch(i.name){case"XYZ":if(s<2||s>3)return!1;break;case"Fit":case"FitB":return 0===s;case"FitH":case"FitBH":case"FitV":case"FitBV":if(s>1)return!1;break;case"FitR":if(4!==s)return!1;r=!1;break;default:return!1}for(const e of a)if(!("number"==typeof e||r&&null===e))return!1;return!0}function fetchDest(e){e instanceof Dict&&(e=e.get("D"));return isValidExplicitDest(e)?e:null}function fetchRemoteDest(e){let t=e.get("D");if(t){t instanceof Name&&(t=t.name);if("string"==typeof t)return stringToPDFString(t);if(isValidExplicitDest(t))return JSON.stringify(t)}return null}class Catalog{constructor(e,t){this.pdfManager=e;this.xref=t;this._catDict=t.getCatalogObj();if(!(this._catDict instanceof Dict))throw new FormatError("Catalog object is not a dictionary.");this.toplevelPagesDict;this._actualNumPages=null;this.fontCache=new RefSetCache;this.builtInCMapCache=new Map;this.standardFontDataCache=new Map;this.globalImageCache=new GlobalImageCache;this.pageKidsCountCache=new RefSetCache;this.pageIndexCache=new RefSetCache;this.pageDictCache=new RefSetCache;this.nonBlendModesSet=new RefSet;this.systemFontCache=new Map}cloneDict(){return this._catDict.clone()}get version(){const e=this._catDict.get("Version");if(e instanceof Name){if(ft.test(e.name))return shadow(this,"version",e.name);warn(`Invalid PDF catalog version: ${e.name}`)}return shadow(this,"version",null)}get lang(){const e=this._catDict.get("Lang");return shadow(this,"lang",e&&"string"==typeof e?stringToPDFString(e):null)}get needsRendering(){const e=this._catDict.get("NeedsRendering");return shadow(this,"needsRendering","boolean"==typeof e&&e)}get collection(){let e=null;try{const t=this._catDict.get("Collection");t instanceof Dict&&t.size>0&&(e=t)}catch(e){if(e instanceof MissingDataException)throw e;info("Cannot fetch Collection entry; assuming no collection is present.")}return shadow(this,"collection",e)}get acroForm(){let e=null;try{const t=this._catDict.get("AcroForm");t instanceof Dict&&t.size>0&&(e=t)}catch(e){if(e instanceof MissingDataException)throw e;info("Cannot fetch AcroForm entry; assuming no forms are present.")}return shadow(this,"acroForm",e)}get acroFormRef(){const e=this._catDict.getRaw("AcroForm");return shadow(this,"acroFormRef",e instanceof Ref?e:null)}get metadata(){const e=this._catDict.getRaw("Metadata");if(!(e instanceof Ref))return shadow(this,"metadata",null);let t=null;try{const i=this.xref.fetch(e,!this.xref.encrypt?.encryptMetadata);if(i instanceof BaseStream&&i.dict instanceof Dict){const e=i.dict.get("Type"),a=i.dict.get("Subtype");if(isName(e,"Metadata")&&isName(a,"XML")){const e=stringToUTF8String(i.getString());e&&(t=new MetadataParser(e).serializable)}}}catch(e){if(e instanceof MissingDataException)throw e;info(`Skipping invalid Metadata: "${e}".`)}return shadow(this,"metadata",t)}get markInfo(){let e=null;try{e=this._readMarkInfo()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read mark info.")}return shadow(this,"markInfo",e)}_readMarkInfo(){const e=this._catDict.get("MarkInfo");if(!(e instanceof Dict))return null;const t={Marked:!1,UserProperties:!1,Suspects:!1};for(const i in t){const a=e.get(i);"boolean"==typeof a&&(t[i]=a)}return t}get structTreeRoot(){let e=null;try{e=this._readStructTreeRoot()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable read to structTreeRoot info.")}return shadow(this,"structTreeRoot",e)}_readStructTreeRoot(){const e=this._catDict.getRaw("StructTreeRoot"),t=this.xref.fetchIfRef(e);if(!(t instanceof Dict))return null;const i=new StructTreeRoot(t,e);i.init();return i}get toplevelPagesDict(){const e=this._catDict.get("Pages");if(!(e instanceof Dict))throw new FormatError("Invalid top-level pages dictionary.");return shadow(this,"toplevelPagesDict",e)}get documentOutline(){let e=null;try{e=this._readDocumentOutline()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read document outline.")}return shadow(this,"documentOutline",e)}_readDocumentOutline(){let e=this._catDict.get("Outlines");if(!(e instanceof Dict))return null;e=e.getRaw("First");if(!(e instanceof Ref))return null;const t={items:[]},i=[{obj:e,parent:t}],a=new RefSet;a.put(e);const s=this.xref,r=new Uint8ClampedArray(3);for(;i.length>0;){const t=i.shift(),n=s.fetchIfRef(t.obj);if(null===n)continue;n.has("Title")||warn("Invalid outline item encountered.");const g={url:null,dest:null,action:null};Catalog.parseDestDictionary({destDict:n,resultObj:g,docBaseUrl:this.baseUrl,docAttachments:this.attachments});const o=n.get("Title"),c=n.get("F")||0,C=n.getArray("C"),h=n.get("Count");let l=r;!isNumberArray(C,3)||0===C[0]&&0===C[1]&&0===C[2]||(l=ColorSpace.singletons.rgb.getRgb(C,0));const Q={action:g.action,attachment:g.attachment,dest:g.dest,url:g.url,unsafeUrl:g.unsafeUrl,newWindow:g.newWindow,setOCGState:g.setOCGState,title:"string"==typeof o?stringToPDFString(o):"",color:l,count:Number.isInteger(h)?h:void 0,bold:!!(2&c),italic:!!(1&c),items:[]};t.parent.items.push(Q);e=n.getRaw("First");if(e instanceof Ref&&!a.has(e)){i.push({obj:e,parent:Q});a.put(e)}e=n.getRaw("Next");if(e instanceof Ref&&!a.has(e)){i.push({obj:e,parent:t.parent});a.put(e)}}return t.items.length>0?t.items:null}get permissions(){let e=null;try{e=this._readPermissions()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read permissions.")}return shadow(this,"permissions",e)}_readPermissions(){const e=this.xref.trailer.get("Encrypt");if(!(e instanceof Dict))return null;let t=e.get("P");if("number"!=typeof t)return null;t+=2**32;const i=[];for(const e in y){const a=y[e];t&a&&i.push(a)}return i}get optionalContentConfig(){let e=null;try{const t=this._catDict.get("OCProperties");if(!t)return shadow(this,"optionalContentConfig",null);const i=t.get("D");if(!i)return shadow(this,"optionalContentConfig",null);const a=t.get("OCGs");if(!Array.isArray(a))return shadow(this,"optionalContentConfig",null);const s=new RefSetCache;for(const e of a)e instanceof Ref&&!s.has(e)&&s.put(e,this.#v(e));e=this.#K(i,s)}catch(e){if(e instanceof MissingDataException)throw e;warn(`Unable to read optional content config: ${e}`)}return shadow(this,"optionalContentConfig",e)}#v(e){const t=this.xref.fetch(e),i={id:e.toString(),name:null,intent:null,usage:{print:null,view:null},rbGroups:[]},a=t.get("Name");"string"==typeof a&&(i.name=stringToPDFString(a));let s=t.getArray("Intent");Array.isArray(s)||(s=[s]);s.every((e=>e instanceof Name))&&(i.intent=s.map((e=>e.name)));const r=t.get("Usage");if(!(r instanceof Dict))return i;const n=i.usage,g=r.get("Print");if(g instanceof Dict){const e=g.get("PrintState");if(e instanceof Name)switch(e.name){case"ON":case"OFF":n.print={printState:e.name}}}const o=r.get("View");if(o instanceof Dict){const e=o.get("ViewState");if(e instanceof Name)switch(e.name){case"ON":case"OFF":n.view={viewState:e.name}}}return i}#K(e,t){function parseOnOff(e){const i=[];if(Array.isArray(e))for(const a of e)a instanceof Ref&&t.has(a)&&i.push(a.toString());return i}function parseOrder(e,i=0){if(!Array.isArray(e))return null;const s=[];for(const r of e){if(r instanceof Ref&&t.has(r)){a.put(r);s.push(r.toString());continue}const e=parseNestedOrder(r,i);e&&s.push(e)}if(i>0)return s;const r=[];for(const[e]of t.items())a.has(e)||r.push(e.toString());r.length&&s.push({name:null,order:r});return s}function parseNestedOrder(e,t){if(++t>s){warn("parseNestedOrder - reached MAX_NESTED_LEVELS.");return null}const a=i.fetchIfRef(e);if(!Array.isArray(a))return null;const r=i.fetchIfRef(a[0]);if("string"!=typeof r)return null;const n=parseOrder(a.slice(1),t);return n?.length?{name:stringToPDFString(r),order:n}:null}const i=this.xref,a=new RefSet,s=10;!function parseRBGroups(e){if(Array.isArray(e))for(const a of e){const e=i.fetchIfRef(a);if(!Array.isArray(e)||!e.length)continue;const s=new Set;for(const i of e)if(i instanceof Ref&&t.has(i)&&!s.has(i.toString())){s.add(i.toString());t.get(i).rbGroups.push(s)}}}(e.get("RBGroups"));return{name:"string"==typeof e.get("Name")?stringToPDFString(e.get("Name")):null,creator:"string"==typeof e.get("Creator")?stringToPDFString(e.get("Creator")):null,baseState:e.get("BaseState")instanceof Name?e.get("BaseState").name:null,on:parseOnOff(e.get("ON")),off:parseOnOff(e.get("OFF")),order:parseOrder(e.get("Order")),groups:[...t]}}setActualNumPages(e=null){this._actualNumPages=e}get hasActualNumPages(){return null!==this._actualNumPages}get _pagesCount(){const e=this.toplevelPagesDict.get("Count");if(!Number.isInteger(e))throw new FormatError("Page count in top-level pages dictionary is not an integer.");return shadow(this,"_pagesCount",e)}get numPages(){return this.hasActualNumPages?this._actualNumPages:this._pagesCount}get destinations(){const e=this._readDests(),t=Object.create(null);if(e instanceof NameTree)for(const[i,a]of e.getAll()){const e=fetchDest(a);e&&(t[stringToPDFString(i)]=e)}else if(e instanceof Dict)for(const[i,a]of e){const e=fetchDest(a);e&&(t[i]=e)}return shadow(this,"destinations",t)}getDestination(e){const t=this._readDests();if(t instanceof NameTree){const i=fetchDest(t.get(e));if(i)return i;const a=this.destinations[e];if(a){warn(`Found "${e}" at an incorrect position in the NameTree.`);return a}}else if(t instanceof Dict){const i=fetchDest(t.get(e));if(i)return i}return null}_readDests(){const e=this._catDict.get("Names");return e?.has("Dests")?new NameTree(e.getRaw("Dests"),this.xref):this._catDict.has("Dests")?this._catDict.get("Dests"):void 0}get pageLabels(){let e=null;try{e=this._readPageLabels()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read page labels.")}return shadow(this,"pageLabels",e)}_readPageLabels(){const e=this._catDict.getRaw("PageLabels");if(!e)return null;const t=new Array(this.numPages);let i=null,a="";const s=new NumberTree(e,this.xref).getAll();let r="",n=1;for(let e=0,g=this.numPages;e=1))throw new FormatError("Invalid start in PageLabel dictionary.");n=e}else n=1}switch(i){case"D":r=n;break;case"R":case"r":r=toRomanNumerals(n,"r"===i);break;case"A":case"a":const e=26,t="a"===i?97:65,a=n-1;r=String.fromCharCode(t+a%e).repeat(Math.floor(a/e)+1);break;default:if(i)throw new FormatError(`Invalid style "${i}" in PageLabel dictionary.`);r=""}t[e]=a+r;n++}return t}get pageLayout(){const e=this._catDict.get("PageLayout");let t="";if(e instanceof Name)switch(e.name){case"SinglePage":case"OneColumn":case"TwoColumnLeft":case"TwoColumnRight":case"TwoPageLeft":case"TwoPageRight":t=e.name}return shadow(this,"pageLayout",t)}get pageMode(){const e=this._catDict.get("PageMode");let t="UseNone";if(e instanceof Name)switch(e.name){case"UseNone":case"UseOutlines":case"UseThumbs":case"FullScreen":case"UseOC":case"UseAttachments":t=e.name}return shadow(this,"pageMode",t)}get viewerPreferences(){const e=this._catDict.get("ViewerPreferences");if(!(e instanceof Dict))return shadow(this,"viewerPreferences",null);let t=null;for(const i of e.getKeys()){const a=e.get(i);let s;switch(i){case"HideToolbar":case"HideMenubar":case"HideWindowUI":case"FitWindow":case"CenterWindow":case"DisplayDocTitle":case"PickTrayByPDFSize":"boolean"==typeof a&&(s=a);break;case"NonFullScreenPageMode":if(a instanceof Name)switch(a.name){case"UseNone":case"UseOutlines":case"UseThumbs":case"UseOC":s=a.name;break;default:s="UseNone"}break;case"Direction":if(a instanceof Name)switch(a.name){case"L2R":case"R2L":s=a.name;break;default:s="L2R"}break;case"ViewArea":case"ViewClip":case"PrintArea":case"PrintClip":if(a instanceof Name)switch(a.name){case"MediaBox":case"CropBox":case"BleedBox":case"TrimBox":case"ArtBox":s=a.name;break;default:s="CropBox"}break;case"PrintScaling":if(a instanceof Name)switch(a.name){case"None":case"AppDefault":s=a.name;break;default:s="AppDefault"}break;case"Duplex":if(a instanceof Name)switch(a.name){case"Simplex":case"DuplexFlipShortEdge":case"DuplexFlipLongEdge":s=a.name;break;default:s="None"}break;case"PrintPageRange":if(Array.isArray(a)&&a.length%2==0){a.every(((e,t,i)=>Number.isInteger(e)&&e>0&&(0===t||e>=i[t-1])&&e<=this.numPages))&&(s=a)}break;case"NumCopies":Number.isInteger(a)&&a>0&&(s=a);break;default:warn(`Ignoring non-standard key in ViewerPreferences: ${i}.`);continue}if(void 0!==s){t||(t=Object.create(null));t[i]=s}else warn(`Bad value, for key "${i}", in ViewerPreferences: ${a}.`)}return shadow(this,"viewerPreferences",t)}get openAction(){const e=this._catDict.get("OpenAction"),t=Object.create(null);if(e instanceof Dict){const i=new Dict(this.xref);i.set("A",e);const a={url:null,dest:null,action:null};Catalog.parseDestDictionary({destDict:i,resultObj:a});Array.isArray(a.dest)?t.dest=a.dest:a.action&&(t.action=a.action)}else Array.isArray(e)&&(t.dest=e);return shadow(this,"openAction",objectSize(t)>0?t:null)}get attachments(){const e=this._catDict.get("Names");let t=null;if(e instanceof Dict&&e.has("EmbeddedFiles")){const i=new NameTree(e.getRaw("EmbeddedFiles"),this.xref);for(const[e,a]of i.getAll()){const i=new FileSpec(a,this.xref);t||(t=Object.create(null));t[stringToPDFString(e)]=i.serializable}}return shadow(this,"attachments",t)}get xfaImages(){const e=this._catDict.get("Names");let t=null;if(e instanceof Dict&&e.has("XFAImages")){const i=new NameTree(e.getRaw("XFAImages"),this.xref);for(const[e,a]of i.getAll()){t||(t=new Dict(this.xref));t.set(stringToPDFString(e),a)}}return shadow(this,"xfaImages",t)}_collectJavaScript(){const e=this._catDict.get("Names");let t=null;function appendIfJavaScriptDict(e,i){if(!(i instanceof Dict))return;if(!isName(i.get("S"),"JavaScript"))return;let a=i.get("JS");if(a instanceof BaseStream)a=a.getString();else if("string"!=typeof a)return;a=stringToPDFString(a).replaceAll("\0","");a&&(t||=new Map).set(e,a)}if(e instanceof Dict&&e.has("JavaScript")){const t=new NameTree(e.getRaw("JavaScript"),this.xref);for(const[e,i]of t.getAll())appendIfJavaScriptDict(stringToPDFString(e),i)}const i=this._catDict.get("OpenAction");i&&appendIfJavaScriptDict("OpenAction",i);return t}get jsActions(){const e=this._collectJavaScript();let t=collectActions(this.xref,this._catDict,fA);if(e){t||=Object.create(null);for(const[i,a]of e)i in t?t[i].push(a):t[i]=[a]}return shadow(this,"jsActions",t)}async fontFallback(e,t){const i=await Promise.all(this.fontCache);for(const a of i)if(a.loadedName===e){a.fallback(t);return}}async cleanup(e=!1){clearGlobalCaches();this.globalImageCache.clear(e);this.pageKidsCountCache.clear();this.pageIndexCache.clear();this.pageDictCache.clear();this.nonBlendModesSet.clear();const t=await Promise.all(this.fontCache);for(const{dict:e}of t)delete e.cacheKey;this.fontCache.clear();this.builtInCMapCache.clear();this.standardFontDataCache.clear();this.systemFontCache.clear()}async getPageDict(e){const t=[this.toplevelPagesDict],i=new RefSet,a=this._catDict.getRaw("Pages");a instanceof Ref&&i.put(a);const s=this.xref,r=this.pageKidsCountCache,n=this.pageIndexCache,g=this.pageDictCache;let o=0;for(;t.length;){const a=t.pop();if(a instanceof Ref){const c=r.get(a);if(c>=0&&o+c<=e){o+=c;continue}if(i.has(a))throw new FormatError("Pages tree contains circular reference.");i.put(a);const C=await(g.get(a)||s.fetchAsync(a));if(C instanceof Dict){let t=C.getRaw("Type");t instanceof Ref&&(t=await s.fetchAsync(t));if(isName(t,"Page")||!C.has("Kids")){r.has(a)||r.put(a,1);n.has(a)||n.put(a,o);if(o===e)return[C,a];o++;continue}}t.push(C);continue}if(!(a instanceof Dict))throw new FormatError("Page dictionary kid reference points to wrong type of object.");const{objId:c}=a;let C=a.getRaw("Count");C instanceof Ref&&(C=await s.fetchAsync(C));if(Number.isInteger(C)&&C>=0){c&&!r.has(c)&&r.put(c,C);if(o+C<=e){o+=C;continue}}let h=a.getRaw("Kids");h instanceof Ref&&(h=await s.fetchAsync(h));if(!Array.isArray(h)){let t=a.getRaw("Type");t instanceof Ref&&(t=await s.fetchAsync(t));if(isName(t,"Page")||!a.has("Kids")){if(o===e)return[a,null];o++;continue}throw new FormatError("Page dictionary kids object is not an array.")}for(let e=h.length-1;e>=0;e--){const i=h[e];t.push(i);a===this.toplevelPagesDict&&i instanceof Ref&&!g.has(i)&&g.put(i,s.fetchAsync(i))}}throw new Error(`Page index ${e} not found.`)}async getAllPageDicts(e=!1){const{ignoreErrors:t}=this.pdfManager.evaluatorOptions,i=[{currentNode:this.toplevelPagesDict,posInKids:0}],a=new RefSet,s=this._catDict.getRaw("Pages");s instanceof Ref&&a.put(s);const r=new Map,n=this.xref,g=this.pageIndexCache;let o=0;function addPageDict(e,t){t&&!g.has(t)&&g.put(t,o);r.set(o++,[e,t])}function addPageError(i){if(i instanceof XRefEntryException&&!e)throw i;if(e&&t&&0===o){warn(`getAllPageDicts - Skipping invalid first page: "${i}".`);i=Dict.empty}r.set(o++,[i,null])}for(;i.length>0;){const e=i.at(-1),{currentNode:t,posInKids:s}=e;let r=t.getRaw("Kids");if(r instanceof Ref)try{r=await n.fetchAsync(r)}catch(e){addPageError(e);break}if(!Array.isArray(r)){addPageError(new FormatError("Page dictionary kids object is not an array."));break}if(s>=r.length){i.pop();continue}const g=r[s];let o;if(g instanceof Ref){if(a.has(g)){addPageError(new FormatError("Pages tree contains circular reference."));break}a.put(g);try{o=await n.fetchAsync(g)}catch(e){addPageError(e);break}}else o=g;if(!(o instanceof Dict)){addPageError(new FormatError("Page dictionary kid reference points to wrong type of object."));break}let c=o.getRaw("Type");if(c instanceof Ref)try{c=await n.fetchAsync(c)}catch(e){addPageError(e);break}isName(c,"Page")||!o.has("Kids")?addPageDict(o,g instanceof Ref?g:null):i.push({currentNode:o,posInKids:0});e.posInKids++}return r}getPageIndex(e){const t=this.pageIndexCache.get(e);if(void 0!==t)return Promise.resolve(t);const i=this.xref;let a=0;const next=t=>function pagesBeforeRef(t){let a,s=0;return i.fetchAsync(t).then((function(i){if(isRefsEqual(t,e)&&!isDict(i,"Page")&&!(i instanceof Dict&&!i.has("Type")&&i.has("Contents")))throw new FormatError("The reference does not point to a /Page dictionary.");if(!i)return null;if(!(i instanceof Dict))throw new FormatError("Node must be a dictionary.");a=i.getRaw("Parent");return i.getAsync("Parent")})).then((function(e){if(!e)return null;if(!(e instanceof Dict))throw new FormatError("Parent must be a dictionary.");return e.getAsync("Kids")})).then((function(e){if(!e)return null;const r=[];let n=!1;for(const a of e){if(!(a instanceof Ref))throw new FormatError("Kid must be a reference.");if(isRefsEqual(a,t)){n=!0;break}r.push(i.fetchAsync(a).then((function(e){if(!(e instanceof Dict))throw new FormatError("Kid node must be a dictionary.");e.has("Count")?s+=e.get("Count"):s++})))}if(!n)throw new FormatError("Kid reference not found in parent's kids.");return Promise.all(r).then((function(){return[s,a]}))}))}(t).then((t=>{if(!t){this.pageIndexCache.put(e,a);return a}const[i,s]=t;a+=i;return next(s)}));return next(e)}get baseUrl(){const e=this._catDict.get("URI");if(e instanceof Dict){const t=e.get("Base");if("string"==typeof t){const e=createValidAbsoluteUrl(t,null,{tryConvertEncoding:!0});if(e)return shadow(this,"baseUrl",e.href)}}return shadow(this,"baseUrl",this.pdfManager.docBaseUrl)}static parseDestDictionary({destDict:e,resultObj:t,docBaseUrl:i=null,docAttachments:a=null}){if(!(e instanceof Dict)){warn("parseDestDictionary: `destDict` must be a dictionary.");return}let s,r,n=e.get("A");if(!(n instanceof Dict))if(e.has("Dest"))n=e.get("Dest");else{n=e.get("AA");n instanceof Dict&&(n.has("D")?n=n.get("D"):n.has("U")&&(n=n.get("U")))}if(n instanceof Dict){const e=n.get("S");if(!(e instanceof Name)){warn("parseDestDictionary: Invalid type in Action dictionary.");return}const i=e.name;switch(i){case"ResetForm":const e=n.get("Flags"),g=!(1&("number"==typeof e?e:0)),o=[],c=[];for(const e of n.get("Fields")||[])e instanceof Ref?c.push(e.toString()):"string"==typeof e&&o.push(stringToPDFString(e));t.resetForm={fields:o,refs:c,include:g};break;case"URI":s=n.get("URI");s instanceof Name&&(s="/"+s.name);break;case"GoTo":r=n.get("D");break;case"Launch":case"GoToR":const C=n.get("F");if(C instanceof Dict){const e=new FileSpec(C,null,!0),{rawFilename:t}=e.serializable;s=t}else"string"==typeof C&&(s=C);const h=fetchRemoteDest(n);h&&"string"==typeof s&&(s=s.split("#",1)[0]+"#"+h);const l=n.get("NewWindow");"boolean"==typeof l&&(t.newWindow=l);break;case"GoToE":const Q=n.get("T");let E;if(a&&Q instanceof Dict){const e=Q.get("R"),t=Q.get("N");isName(e,"C")&&"string"==typeof t&&(E=a[stringToPDFString(t)])}if(E){t.attachment=E;const e=fetchRemoteDest(n);e&&(t.attachmentDest=e)}else warn('parseDestDictionary - unimplemented "GoToE" action.');break;case"Named":const u=n.get("N");u instanceof Name&&(t.action=u.name);break;case"SetOCGState":const d=n.get("State"),f=n.get("PreserveRB");if(!Array.isArray(d)||0===d.length)break;const p=[];for(const e of d)if(e instanceof Name)switch(e.name){case"ON":case"OFF":case"Toggle":p.push(e.name)}else e instanceof Ref&&p.push(e.toString());if(p.length!==d.length)break;t.setOCGState={state:p,preserveRB:"boolean"!=typeof f||f};break;case"JavaScript":const m=n.get("JS");let y;m instanceof BaseStream?y=m.getString():"string"==typeof m&&(y=m);const w=y&&recoverJsURL(stringToPDFString(y));if(w){s=w.url;t.newWindow=w.newWindow;break}default:if("JavaScript"===i||"SubmitForm"===i)break;warn(`parseDestDictionary - unsupported action: "${i}".`)}}else e.has("Dest")&&(r=e.get("Dest"));if("string"==typeof s){const e=createValidAbsoluteUrl(s,i,{addDefaultProtocol:!0,tryConvertEncoding:!0});e&&(t.url=e.href);t.unsafeUrl=s}if(r){r instanceof Name&&(r=r.name);"string"==typeof r?t.dest=stringToPDFString(r):isValidExplicitDest(r)&&(t.dest=r)}}}function addChildren(e,t){if(e instanceof Dict)e=e.getRawValues();else if(e instanceof BaseStream)e=e.dict.getRawValues();else if(!Array.isArray(e))return;for(const a of e)((i=a)instanceof Ref||i instanceof Dict||i instanceof BaseStream||Array.isArray(i))&&t.push(a);var i}class ObjectLoader{constructor(e,t,i){this.dict=e;this.keys=t;this.xref=i;this.refSet=null}async load(){if(this.xref.stream.isDataLoaded)return;const{keys:e,dict:t}=this;this.refSet=new RefSet;const i=[];for(const a of e){const e=t.getRaw(a);void 0!==e&&i.push(e)}return this._walk(i)}async _walk(e){const t=[],i=[];for(;e.length;){let a=e.pop();if(a instanceof Ref){if(this.refSet.has(a))continue;try{this.refSet.put(a);a=this.xref.fetch(a)}catch(e){if(!(e instanceof MissingDataException)){warn(`ObjectLoader._walk - requesting all data: "${e}".`);this.refSet=null;const{manager:t}=this.xref.stream;return t.requestAllChunks()}t.push(a);i.push({begin:e.begin,end:e.end})}}if(a instanceof BaseStream){const e=a.getBaseStreams();if(e){let s=!1;for(const t of e)if(!t.isDataLoaded){s=!0;i.push({begin:t.start,end:t.end})}s&&t.push(a)}}addChildren(a,e)}if(i.length){await this.xref.stream.manager.requestRanges(i);for(const e of t)e instanceof Ref&&this.refSet.remove(e);return this._walk(t)}this.refSet=null}}const xs=Symbol(),Ls=Symbol(),Hs=Symbol(),Js=Symbol(),Ys=Symbol(),vs=Symbol(),Ks=Symbol(),Ts=Symbol(),qs=Symbol(),Os=Symbol("content"),Ws=Symbol("data"),js=Symbol(),Xs=Symbol("extra"),Zs=Symbol(),Vs=Symbol(),zs=Symbol(),_s=Symbol(),$s=Symbol(),Ar=Symbol(),er=Symbol(),tr=Symbol(),ir=Symbol(),ar=Symbol(),sr=Symbol(),rr=Symbol(),nr=Symbol(),gr=Symbol(),or=Symbol(),Ir=Symbol(),cr=Symbol(),Cr=Symbol(),hr=Symbol(),lr=Symbol(),Qr=Symbol(),Er=Symbol(),ur=Symbol(),dr=Symbol(),fr=Symbol(),pr=Symbol(),mr=Symbol(),yr=Symbol(),wr=Symbol(),Dr=Symbol(),br=Symbol(),Fr=Symbol(),Sr=Symbol("namespaceId"),kr=Symbol("nodeName"),Rr=Symbol(),Nr=Symbol(),Gr=Symbol(),Mr=Symbol(),Ur=Symbol(),xr=Symbol(),Lr=Symbol(),Hr=Symbol(),Jr=Symbol("root"),Yr=Symbol(),vr=Symbol(),Kr=Symbol(),Tr=Symbol(),qr=Symbol(),Or=Symbol(),Pr=Symbol(),Wr=Symbol(),jr=Symbol(),Xr=Symbol(),Zr=Symbol(),Vr=Symbol("uid"),zr=Symbol(),_r={config:{id:0,check:e=>e.startsWith("http://www.xfa.org/schema/xci/")},connectionSet:{id:1,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-connection-set/")},datasets:{id:2,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-data/")},form:{id:3,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-form/")},localeSet:{id:4,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-locale-set/")},pdf:{id:5,check:e=>"http://ns.adobe.com/xdp/pdf/"===e},signature:{id:6,check:e=>"http://www.w3.org/2000/09/xmldsig#"===e},sourceSet:{id:7,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-source-set/")},stylesheet:{id:8,check:e=>"http://www.w3.org/1999/XSL/Transform"===e},template:{id:9,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-template/")},xdc:{id:10,check:e=>e.startsWith("http://www.xfa.org/schema/xdc/")},xdp:{id:11,check:e=>"http://ns.adobe.com/xdp/"===e},xfdf:{id:12,check:e=>"http://ns.adobe.com/xfdf/"===e},xhtml:{id:13,check:e=>"http://www.w3.org/1999/xhtml"===e},xmpmeta:{id:14,check:e=>"http://ns.adobe.com/xmpmeta/"===e}},$r={pt:e=>e,cm:e=>e/2.54*72,mm:e=>e/25.4*72,in:e=>72*e,px:e=>e},An=/([+-]?\d+\.?\d*)(.*)/;function stripQuotes(e){return e.startsWith("'")||e.startsWith('"')?e.slice(1,-1):e}function getInteger({data:e,defaultValue:t,validate:i}){if(!e)return t;e=e.trim();const a=parseInt(e,10);return!isNaN(a)&&i(a)?a:t}function getFloat({data:e,defaultValue:t,validate:i}){if(!e)return t;e=e.trim();const a=parseFloat(e);return!isNaN(a)&&i(a)?a:t}function getKeyword({data:e,defaultValue:t,validate:i}){return e&&i(e=e.trim())?e:t}function getStringOption(e,t){return getKeyword({data:e,defaultValue:t[0],validate:e=>t.includes(e)})}function getMeasurement(e,t="0"){t||="0";if(!e)return getMeasurement(t);const i=e.trim().match(An);if(!i)return getMeasurement(t);const[,a,s]=i,r=parseFloat(a);if(isNaN(r))return getMeasurement(t);if(0===r)return 0;const n=$r[s];return n?n(r):r}function getRatio(e){if(!e)return{num:1,den:1};const t=e.trim().split(/\s*:\s*/).map((e=>parseFloat(e))).filter((e=>!isNaN(e)));1===t.length&&t.push(1);if(0===t.length)return{num:1,den:1};const[i,a]=t;return{num:i,den:a}}function getRelevant(e){return e?e.trim().split(/\s+/).map((e=>({excluded:"-"===e[0],viewname:e.substring(1)}))):[]}class HTMLResult{static get FAILURE(){return shadow(this,"FAILURE",new HTMLResult(!1,null,null,null))}static get EMPTY(){return shadow(this,"EMPTY",new HTMLResult(!0,null,null,null))}constructor(e,t,i,a){this.success=e;this.html=t;this.bbox=i;this.breakNode=a}isBreak(){return!!this.breakNode}static breakNode(e){return new HTMLResult(!1,null,null,e)}static success(e,t=null){return new HTMLResult(!0,e,t,null)}}class FontFinder{constructor(e){this.fonts=new Map;this.cache=new Map;this.warned=new Set;this.defaultFont=null;this.add(e)}add(e,t=null){for(const t of e)this.addPdfFont(t);for(const e of this.fonts.values())e.regular||(e.regular=e.italic||e.bold||e.bolditalic);if(!t||0===t.size)return;const i=this.fonts.get("PdfJS-Fallback-PdfJS-XFA");for(const e of t)this.fonts.set(e,i)}addPdfFont(e){const t=e.cssFontInfo,i=t.fontFamily;let a=this.fonts.get(i);if(!a){a=Object.create(null);this.fonts.set(i,a);this.defaultFont||(this.defaultFont=a)}let s="";const r=parseFloat(t.fontWeight);0!==parseFloat(t.italicAngle)?s=r>=700?"bolditalic":"italic":r>=700&&(s="bold");if(!s){(e.name.includes("Bold")||e.psName?.includes("Bold"))&&(s="bold");(e.name.includes("Italic")||e.name.endsWith("It")||e.psName?.includes("Italic")||e.psName?.endsWith("It"))&&(s+="italic")}s||(s="regular");a[s]=e}getDefault(){return this.defaultFont}find(e,t=!0){let i=this.fonts.get(e)||this.cache.get(e);if(i)return i;const a=/,|-|_| |bolditalic|bold|italic|regular|it/gi;let s=e.replaceAll(a,"");i=this.fonts.get(s);if(i){this.cache.set(e,i);return i}s=s.toLowerCase();const r=[];for(const[e,t]of this.fonts.entries())e.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(t);if(0===r.length)for(const[,e]of this.fonts.entries())e.regular.name?.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(e);if(0===r.length){s=s.replaceAll(/psmt|mt/gi,"");for(const[e,t]of this.fonts.entries())e.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(t)}if(0===r.length)for(const e of this.fonts.values())e.regular.name?.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(e);if(r.length>=1){1!==r.length&&t&&warn(`XFA - Too many choices to guess the correct font: ${e}`);this.cache.set(e,r[0]);return r[0]}if(t&&!this.warned.has(e)){this.warned.add(e);warn(`XFA - Cannot find the font: ${e}`)}return null}}function selectFont(e,t){return"italic"===e.posture?"bold"===e.weight?t.bolditalic:t.italic:"bold"===e.weight?t.bold:t.regular}class FontInfo{constructor(e,t,i,a){this.lineHeight=i;this.paraMargin=t||{top:0,bottom:0,left:0,right:0};if(!e){[this.pdfFont,this.xfaFont]=this.defaultFont(a);return}this.xfaFont={typeface:e.typeface,posture:e.posture,weight:e.weight,size:e.size,letterSpacing:e.letterSpacing};const s=a.find(e.typeface);if(s){this.pdfFont=selectFont(e,s);this.pdfFont||([this.pdfFont,this.xfaFont]=this.defaultFont(a))}else[this.pdfFont,this.xfaFont]=this.defaultFont(a)}defaultFont(e){const t=e.find("Helvetica",!1)||e.find("Myriad Pro",!1)||e.find("Arial",!1)||e.getDefault();if(t?.regular){const e=t.regular;return[e,{typeface:e.cssFontInfo.fontFamily,posture:"normal",weight:"normal",size:10,letterSpacing:0}]}return[null,{typeface:"Courier",posture:"normal",weight:"normal",size:10,letterSpacing:0}]}}class FontSelector{constructor(e,t,i,a){this.fontFinder=a;this.stack=[new FontInfo(e,t,i,a)]}pushData(e,t,i){const a=this.stack.at(-1);for(const t of["typeface","posture","weight","size","letterSpacing"])e[t]||(e[t]=a.xfaFont[t]);for(const e of["top","bottom","left","right"])isNaN(t[e])&&(t[e]=a.paraMargin[e]);const s=new FontInfo(e,t,i||a.lineHeight,this.fontFinder);s.pdfFont||(s.pdfFont=a.pdfFont);this.stack.push(s)}popFont(){this.stack.pop()}topFont(){return this.stack.at(-1)}}class TextMeasure{constructor(e,t,i,a){this.glyphs=[];this.fontSelector=new FontSelector(e,t,i,a);this.extraHeight=0}pushData(e,t,i){this.fontSelector.pushData(e,t,i)}popFont(e){return this.fontSelector.popFont()}addPara(){const e=this.fontSelector.topFont();this.extraHeight+=e.paraMargin.top+e.paraMargin.bottom}addString(e){if(!e)return;const t=this.fontSelector.topFont(),i=t.xfaFont.size;if(t.pdfFont){const a=t.xfaFont.letterSpacing,s=t.pdfFont,r=s.lineHeight||1.2,n=t.lineHeight||Math.max(1.2,r)*i,g=r-(void 0===s.lineGap?.2:s.lineGap),o=Math.max(1,g)*i,c=i/1e3,C=s.defaultWidth||s.charsToGlyphs(" ")[0].width;for(const t of e.split(/[\u2029\n]/)){const e=s.encodeString(t).join(""),i=s.charsToGlyphs(e);for(const e of i){const t=e.width||C;this.glyphs.push([t*c+a,n,o,e.unicode,!1])}this.glyphs.push([0,0,0,"\n",!0])}this.glyphs.pop()}else{for(const t of e.split(/[\u2029\n]/)){for(const e of t.split(""))this.glyphs.push([i,1.2*i,i,e,!1]);this.glyphs.push([0,0,0,"\n",!0])}this.glyphs.pop()}}compute(e){let t=-1,i=0,a=0,s=0,r=0,n=0,g=!1,o=!0;for(let c=0,C=this.glyphs.length;ce){a=Math.max(a,r);r=0;s+=n;n=d;t=-1;i=0;g=!0;o=!1}else{n=Math.max(d,n);i=r;r+=C;t=c}else if(r+C>e){s+=n;n=d;if(-1!==t){c=t;a=Math.max(a,i);r=0;t=-1;i=0}else{a=Math.max(a,r);r=C}g=!0;o=!1}else{r+=C;n=Math.max(d,n)}}a=Math.max(a,r);s+=n+this.extraHeight;return{width:1.02*a,height:s,isBroken:g}}}const en=/^[^.[]+/,tn=/^[^\]]+/,an=0,sn=1,rn=2,nn=3,gn=4,on=new Map([["$data",(e,t)=>e.datasets?e.datasets.data:e],["$record",(e,t)=>(e.datasets?e.datasets.data:e)[rr]()[0]],["$template",(e,t)=>e.template],["$connectionSet",(e,t)=>e.connectionSet],["$form",(e,t)=>e.form],["$layout",(e,t)=>e.layout],["$host",(e,t)=>e.host],["$dataWindow",(e,t)=>e.dataWindow],["$event",(e,t)=>e.event],["!",(e,t)=>e.datasets],["$xfa",(e,t)=>e],["xfa",(e,t)=>e],["$",(e,t)=>t]]),In=new WeakMap;function parseExpression(e,t,i=!0){let a=e.match(en);if(!a)return null;let[s]=a;const r=[{name:s,cacheName:"."+s,index:0,js:null,formCalc:null,operator:an}];let n=s.length;for(;n0&&C.push(e)}if(0!==C.length||g||0!==o)e=isFinite(c)?C.filter((e=>ce[c])):C.flat();else{const i=t[Ir]();if(!(t=i))return null;o=-1;e=[t]}}return 0===e.length?null:e}function createDataNode(e,t,i){const a=parseExpression(i);if(!a)return null;if(a.some((e=>e.operator===sn)))return null;const s=on.get(a[0].name);let r=0;if(s){e=s(e,t);r=1}else e=t||e;for(let t=a.length;re[Pr]())).join("")}get[hn](){const e=Object.getPrototypeOf(this);if(!e._attributes){const t=e._attributes=new Set;for(const e of Object.getOwnPropertyNames(this)){if(null===this[e]||this[e]instanceof XFAObject||this[e]instanceof XFAObjectArray)break;t.add(e)}}return shadow(this,hn,e._attributes)}[pr](e){let t=this;for(;t;){if(t===e)return!0;t=t[Ir]()}return!1}[Ir](){return this[wn]}[or](){return this[Ir]()}[rr](e=null){return e?this[e]:this[ln]}[js](){const e=Object.create(null);this[Os]&&(e.$content=this[Os]);for(const t of Object.getOwnPropertyNames(this)){const i=this[t];null!==i&&(i instanceof XFAObject?e[t]=i[js]():i instanceof XFAObjectArray?i.isEmpty()||(e[t]=i.dump()):e[t]=i)}return e}[Zr](){return null}[jr](){return HTMLResult.EMPTY}*[nr](){for(const e of this[rr]())yield e}*[un](e,t){for(const i of this[nr]())if(!e||t===e.has(i[kr])){const e=this[$s](),t=i[jr](e);t.success||(this[Xs].failingNode=i);yield t}}[Vs](){return null}[Ls](e,t){this[Xs].children.push(e)}[$s](){}[Js]({filter:e=null,include:t=!0}){if(this[Xs].generator){const e=this[$s](),t=this[Xs].failingNode[jr](e);if(!t.success)return t;t.html&&this[Ls](t.html,t.bbox);delete this[Xs].failingNode}else this[Xs].generator=this[un](e,t);for(;;){const e=this[Xs].generator.next();if(e.done)break;const t=e.value;if(!t.success)return t;t.html&&this[Ls](t.html,t.bbox)}this[Xs].generator=null;return HTMLResult.EMPTY}[Tr](e){this[bn]=new Set(Object.keys(e))}[fn](e){const t=this[hn],i=this[bn];return[...e].filter((e=>t.has(e)&&!i.has(e)))}[Yr](e,t=new Set){for(const i of this[ln])i[Dn](e,t)}[Dn](e,t){const i=this[dn](e,t);i?this[cn](i,e,t):this[Yr](e,t)}[dn](e,t){const{use:i,usehref:a}=this;if(!i&&!a)return null;let s=null,r=null,n=null,g=i;if(a){g=a;a.startsWith("#som(")&&a.endsWith(")")?r=a.slice(5,-1):a.startsWith(".#som(")&&a.endsWith(")")?r=a.slice(6,-1):a.startsWith("#")?n=a.slice(1):a.startsWith(".#")&&(n=a.slice(2))}else i.startsWith("#")?n=i.slice(1):r=i;this.use=this.usehref="";if(n)s=e.get(n);else{s=searchNode(e.get(Jr),this,r,!0,!1);s&&(s=s[0])}if(!s){warn(`XFA - Invalid prototype reference: ${g}.`);return null}if(s[kr]!==this[kr]){warn(`XFA - Incompatible prototype: ${s[kr]} !== ${this[kr]}.`);return null}if(t.has(s)){warn("XFA - Cycle detected in prototypes use.");return null}t.add(s);const o=s[dn](e,t);o&&s[cn](o,e,t);s[Yr](e,t);t.delete(s);return s}[cn](e,t,i){if(i.has(e)){warn("XFA - Cycle detected in prototypes use.");return}!this[Os]&&e[Os]&&(this[Os]=e[Os]);new Set(i).add(e);for(const t of this[fn](e[bn])){this[t]=e[t];this[bn]&&this[bn].add(t)}for(const a of Object.getOwnPropertyNames(this)){if(this[hn].has(a))continue;const s=this[a],r=e[a];if(s instanceof XFAObjectArray){for(const e of s[ln])e[Dn](t,i);for(let a=s[ln].length,n=r[ln].length;aXFAObject[Bn](e))):"object"==typeof e&&null!==e?Object.assign({},e):e}[Ts](){const e=Object.create(Object.getPrototypeOf(this));for(const t of Object.getOwnPropertySymbols(this))try{e[t]=this[t]}catch{shadow(e,t,this[t])}e[Vr]=`${e[kr]}${Sn++}`;e[ln]=[];for(const t of Object.getOwnPropertyNames(this)){if(this[hn].has(t)){e[t]=XFAObject[Bn](this[t]);continue}const i=this[t];e[t]=i instanceof XFAObjectArray?new XFAObjectArray(i[mn]):null}for(const t of this[ln]){const i=t[kr],a=t[Ts]();e[ln].push(a);a[wn]=e;null===e[i]?e[i]=a:e[i][ln].push(a)}return e}[rr](e=null){return e?this[ln].filter((t=>t[kr]===e)):this[ln]}[Ar](e){return this[e]}[er](e,t,i=!0){return Array.from(this[tr](e,t,i))}*[tr](e,t,i=!0){if("parent"!==e){for(const i of this[ln]){i[kr]===e&&(yield i);i.name===e&&(yield i);(t||i[Dr]())&&(yield*i[tr](e,t,!1))}i&&this[hn].has(e)&&(yield new XFAAttribute(this,e,this[e]))}else yield this[wn]}}class XFAObjectArray{constructor(e=1/0){this[mn]=e;this[ln]=[]}get isXFAObject(){return!1}get isXFAObjectArray(){return!0}push(e){if(this[ln].length<=this[mn]){this[ln].push(e);return!0}warn(`XFA - node "${e[kr]}" accepts no more than ${this[mn]} children`);return!1}isEmpty(){return 0===this[ln].length}dump(){return 1===this[ln].length?this[ln][0][js]():this[ln].map((e=>e[js]()))}[Ts](){const e=new XFAObjectArray(this[mn]);e[ln]=this[ln].map((e=>e[Ts]()));return e}get children(){return this[ln]}clear(){this[ln].length=0}}class XFAAttribute{constructor(e,t,i){this[wn]=e;this[kr]=t;this[Os]=i;this[qs]=!1;this[Vr]="attribute"+Sn++}[Ir](){return this[wn]}[fr](){return!0}[ir](){return this[Os].trim()}[qr](e){e=e.value||"";this[Os]=e.toString()}[Pr](){return this[Os]}[pr](e){return this[wn]===e||this[wn][pr](e)}}class XmlObject extends XFAObject{constructor(e,t,i={}){super(e,t);this[Os]="";this[Qn]=null;if("#text"!==t){const e=new Map;this[Cn]=e;for(const[t,a]of Object.entries(i))e.set(t,new XFAAttribute(this,t,a));if(i.hasOwnProperty(Rr)){const e=i[Rr].xfa.dataNode;void 0!==e&&("dataGroup"===e?this[Qn]=!1:"dataValue"===e&&(this[Qn]=!0))}}this[qs]=!1}[Xr](e){const t=this[kr];if("#text"===t){e.push(encodeToXmlString(this[Os]));return}const i=utf8StringToString(t),a=this[Sr]===kn?"xfa:":"";e.push(`<${a}${i}`);for(const[t,i]of this[Cn].entries()){const a=utf8StringToString(t);e.push(` ${a}="${encodeToXmlString(i[Os])}"`)}null!==this[Qn]&&(this[Qn]?e.push(' xfa:dataNode="dataValue"'):e.push(' xfa:dataNode="dataGroup"'));if(this[Os]||0!==this[ln].length){e.push(">");if(this[Os])"string"==typeof this[Os]?e.push(encodeToXmlString(this[Os])):this[Os][Xr](e);else for(const t of this[ln])t[Xr](e);e.push(``)}else e.push("/>")}[Nr](e){if(this[Os]){const e=new XmlObject(this[Sr],"#text");this[Hs](e);e[Os]=this[Os];this[Os]=""}this[Hs](e);return!0}[Mr](e){this[Os]+=e}[Zs](){if(this[Os]&&this[ln].length>0){const e=new XmlObject(this[Sr],"#text");this[Hs](e);e[Os]=this[Os];delete this[Os]}}[jr](){return"#text"===this[kr]?HTMLResult.success({name:"#text",value:this[Os]}):HTMLResult.EMPTY}[rr](e=null){return e?this[ln].filter((t=>t[kr]===e)):this[ln]}[_s](){return this[Cn]}[Ar](e){const t=this[Cn].get(e);return void 0!==t?t:this[rr](e)}*[tr](e,t){const i=this[Cn].get(e);i&&(yield i);for(const i of this[ln]){i[kr]===e&&(yield i);t&&(yield*i[tr](e,t))}}*[zs](e,t){const i=this[Cn].get(e);!i||t&&i[qs]||(yield i);for(const i of this[ln])yield*i[zs](e,t)}*[sr](e,t,i){for(const a of this[ln]){a[kr]!==e||i&&a[qs]||(yield a);t&&(yield*a[sr](e,t,i))}}[fr](){return null===this[Qn]?0===this[ln].length||this[ln][0][Sr]===_r.xhtml.id:this[Qn]}[ir](){return null===this[Qn]?0===this[ln].length?this[Os].trim():this[ln][0][Sr]===_r.xhtml.id?this[ln][0][Pr]().trim():null:this[Os].trim()}[qr](e){e=e.value||"";this[Os]=e.toString()}[js](e=!1){const t=Object.create(null);e&&(t.$ns=this[Sr]);this[Os]&&(t.$content=this[Os]);t.$name=this[kr];t.children=[];for(const i of this[ln])t.children.push(i[js](e));t.attributes=Object.create(null);for(const[e,i]of this[Cn])t.attributes[e]=i[Os];return t}}class ContentObject extends XFAObject{constructor(e,t){super(e,t);this[Os]=""}[Mr](e){this[Os]+=e}[Zs](){}}class OptionObject extends ContentObject{constructor(e,t,i){super(e,t);this[yn]=i}[Zs](){this[Os]=getKeyword({data:this[Os],defaultValue:this[yn][0],validate:e=>this[yn].includes(e)})}[Ys](e){super[Ys](e);delete this[yn]}}class StringObject extends ContentObject{[Zs](){this[Os]=this[Os].trim()}}class IntegerObject extends ContentObject{constructor(e,t,i,a){super(e,t);this[En]=i;this[Fn]=a}[Zs](){this[Os]=getInteger({data:this[Os],defaultValue:this[En],validate:this[Fn]})}[Ys](e){super[Ys](e);delete this[En];delete this[Fn]}}class Option01 extends IntegerObject{constructor(e,t){super(e,t,0,(e=>1===e))}}class Option10 extends IntegerObject{constructor(e,t){super(e,t,1,(e=>0===e))}}function measureToString(e){return"string"==typeof e?"0px":Number.isInteger(e)?`${e}px`:`${e.toFixed(2)}px`}const Rn={anchorType(e,t){const i=e[or]();if(i&&(!i.layout||"position"===i.layout)){"transform"in t||(t.transform="");switch(e.anchorType){case"bottomCenter":t.transform+="translate(-50%, -100%)";break;case"bottomLeft":t.transform+="translate(0,-100%)";break;case"bottomRight":t.transform+="translate(-100%,-100%)";break;case"middleCenter":t.transform+="translate(-50%,-50%)";break;case"middleLeft":t.transform+="translate(0,-50%)";break;case"middleRight":t.transform+="translate(-100%,-50%)";break;case"topCenter":t.transform+="translate(-50%,0)";break;case"topRight":t.transform+="translate(-100%,0)"}}},dimensions(e,t){const i=e[or]();let a=e.w;const s=e.h;if(i.layout?.includes("row")){const t=i[Xs],s=e.colSpan;let r;if(-1===s){r=t.columnWidths.slice(t.currentColumn).reduce(((e,t)=>e+t),0);t.currentColumn=0}else{r=t.columnWidths.slice(t.currentColumn,t.currentColumn+s).reduce(((e,t)=>e+t),0);t.currentColumn=(t.currentColumn+e.colSpan)%t.columnWidths.length}isNaN(r)||(a=e.w=r)}t.width=""!==a?measureToString(a):"auto";t.height=""!==s?measureToString(s):"auto"},position(e,t){const i=e[or]();if(!i?.layout||"position"===i.layout){t.position="absolute";t.left=measureToString(e.x);t.top=measureToString(e.y)}},rotate(e,t){if(e.rotate){"transform"in t||(t.transform="");t.transform+=`rotate(-${e.rotate}deg)`;t.transformOrigin="top left"}},presence(e,t){switch(e.presence){case"invisible":t.visibility="hidden";break;case"hidden":case"inactive":t.display="none"}},hAlign(e,t){if("para"===e[kr])switch(e.hAlign){case"justifyAll":t.textAlign="justify-all";break;case"radix":t.textAlign="left";break;default:t.textAlign=e.hAlign}else switch(e.hAlign){case"left":t.alignSelf="start";break;case"center":t.alignSelf="center";break;case"right":t.alignSelf="end"}},margin(e,t){e.margin&&(t.margin=e.margin[Zr]().margin)}};function setMinMaxDimensions(e,t){if("position"===e[or]().layout){e.minW>0&&(t.minWidth=measureToString(e.minW));e.maxW>0&&(t.maxWidth=measureToString(e.maxW));e.minH>0&&(t.minHeight=measureToString(e.minH));e.maxH>0&&(t.maxHeight=measureToString(e.maxH))}}function layoutText(e,t,i,a,s,r){const n=new TextMeasure(t,i,a,s);"string"==typeof e?n.addString(e):e[Ur](n);return n.compute(r)}function layoutNode(e,t){let i=null,a=null,s=!1;if((!e.w||!e.h)&&e.value){let r=0,n=0;if(e.margin){r=e.margin.leftInset+e.margin.rightInset;n=e.margin.topInset+e.margin.bottomInset}let g=null,o=null;if(e.para){o=Object.create(null);g=""===e.para.lineHeight?null:e.para.lineHeight;o.top=""===e.para.spaceAbove?0:e.para.spaceAbove;o.bottom=""===e.para.spaceBelow?0:e.para.spaceBelow;o.left=""===e.para.marginLeft?0:e.para.marginLeft;o.right=""===e.para.marginRight?0:e.para.marginRight}let c=e.font;if(!c){const t=e[cr]();let i=e[Ir]();for(;i&&i!==t;){if(i.font){c=i.font;break}i=i[Ir]()}}const C=(e.w||t.width)-r,h=e[Cr].fontFinder;if(e.value.exData&&e.value.exData[Os]&&"text/html"===e.value.exData.contentType){const t=layoutText(e.value.exData[Os],c,o,g,h,C);a=t.width;i=t.height;s=t.isBroken}else{const t=e.value[Pr]();if(t){const e=layoutText(t,c,o,g,h,C);a=e.width;i=e.height;s=e.isBroken}}null===a||e.w||(a+=r);null===i||e.h||(i+=n)}return{w:a,h:i,isBroken:s}}function computeBbox(e,t,i){let a;if(""!==e.w&&""!==e.h)a=[e.x,e.y,e.w,e.h];else{if(!i)return null;let s=e.w;if(""===s){if(0===e.maxW){const t=e[or]();s="position"===t.layout&&""!==t.w?0:e.minW}else s=Math.min(e.maxW,i.width);t.attributes.style.width=measureToString(s)}let r=e.h;if(""===r){if(0===e.maxH){const t=e[or]();r="position"===t.layout&&""!==t.h?0:e.minH}else r=Math.min(e.maxH,i.height);t.attributes.style.height=measureToString(r)}a=[e.x,e.y,s,r]}return a}function fixDimensions(e){const t=e[or]();if(t.layout?.includes("row")){const i=t[Xs],a=e.colSpan;let s;s=-1===a?i.columnWidths.slice(i.currentColumn).reduce(((e,t)=>e+t),0):i.columnWidths.slice(i.currentColumn,i.currentColumn+a).reduce(((e,t)=>e+t),0);isNaN(s)||(e.w=s)}t.layout&&"position"!==t.layout&&(e.x=e.y=0);"table"===e.layout&&""===e.w&&Array.isArray(e.columnWidths)&&(e.w=e.columnWidths.reduce(((e,t)=>e+t),0))}function layoutClass(e){switch(e.layout){case"position":default:return"xfaPosition";case"lr-tb":return"xfaLrTb";case"rl-row":return"xfaRlRow";case"rl-tb":return"xfaRlTb";case"row":return"xfaRow";case"table":return"xfaTable";case"tb":return"xfaTb"}}function toStyle(e,...t){const i=Object.create(null);for(const a of t){const t=e[a];if(null!==t)if(Rn.hasOwnProperty(a))Rn[a](e,i);else if(t instanceof XFAObject){const e=t[Zr]();e?Object.assign(i,e):warn(`(DEBUG) - XFA - style for ${a} not implemented yet`)}}return i}function createWrapper(e,t){const{attributes:i}=t,{style:a}=i,s={name:"div",attributes:{class:["xfaWrapper"],style:Object.create(null)},children:[]};i.class.push("xfaWrapped");if(e.border){const{widths:i,insets:r}=e.border[Xs];let n,g,o=r[0],c=r[3];const C=r[0]+r[2],h=r[1]+r[3];switch(e.border.hand){case"even":o-=i[0]/2;c-=i[3]/2;n=`calc(100% + ${(i[1]+i[3])/2-h}px)`;g=`calc(100% + ${(i[0]+i[2])/2-C}px)`;break;case"left":o-=i[0];c-=i[3];n=`calc(100% + ${i[1]+i[3]-h}px)`;g=`calc(100% + ${i[0]+i[2]-C}px)`;break;case"right":n=h?`calc(100% - ${h}px)`:"100%";g=C?`calc(100% - ${C}px)`:"100%"}const l=["xfaBorder"];isPrintOnly(e.border)&&l.push("xfaPrintOnly");const Q={name:"div",attributes:{class:l,style:{top:`${o}px`,left:`${c}px`,width:n,height:g}},children:[]};for(const e of["border","borderWidth","borderColor","borderRadius","borderStyle"])if(void 0!==a[e]){Q.attributes.style[e]=a[e];delete a[e]}s.children.push(Q,t)}else s.children.push(t);for(const e of["background","backgroundClip","top","left","width","height","minWidth","minHeight","maxWidth","maxHeight","transform","transformOrigin","visibility"])if(void 0!==a[e]){s.attributes.style[e]=a[e];delete a[e]}s.attributes.style.position="absolute"===a.position?"absolute":"relative";delete a.position;if(a.alignSelf){s.attributes.style.alignSelf=a.alignSelf;delete a.alignSelf}return s}function fixTextIndent(e){const t=getMeasurement(e.textIndent,"0px");if(t>=0)return;const i="padding"+("left"===("right"===e.textAlign?"right":"left")?"Left":"Right"),a=getMeasurement(e[i],"0px");e[i]=a-t+"px"}function setAccess(e,t){switch(e.access){case"nonInteractive":t.push("xfaNonInteractive");break;case"readOnly":t.push("xfaReadOnly");break;case"protected":t.push("xfaDisabled")}}function isPrintOnly(e){return e.relevant.length>0&&!e.relevant[0].excluded&&"print"===e.relevant[0].viewname}function getCurrentPara(e){const t=e[cr]()[Xs].paraStack;return t.length?t.at(-1):null}function setPara(e,t,i){if(i.attributes.class?.includes("xfaRich")){if(t){""===e.h&&(t.height="auto");""===e.w&&(t.width="auto")}const a=getCurrentPara(e);if(a){const e=i.attributes.style;e.display="flex";e.flexDirection="column";switch(a.vAlign){case"top":e.justifyContent="start";break;case"bottom":e.justifyContent="end";break;case"middle":e.justifyContent="center"}const t=a[Zr]();for(const[i,a]of Object.entries(t))i in e||(e[i]=a)}}}function setFontFamily(e,t,i,a){if(!i){delete a.fontFamily;return}const s=stripQuotes(e.typeface);a.fontFamily=`"${s}"`;const r=i.find(s);if(r){const{fontFamily:i}=r.regular.cssFontInfo;i!==s&&(a.fontFamily=`"${i}"`);const n=getCurrentPara(t);if(n&&""!==n.lineHeight)return;if(a.lineHeight)return;const g=selectFont(e,r);g&&(a.lineHeight=Math.max(1.2,g.lineHeight))}}function fixURL(e){const t=createValidAbsoluteUrl(e,null,{addDefaultProtocol:!0,tryConvertEncoding:!0});return t?t.href:null}function createLine(e,t){return{name:"div",attributes:{class:["lr-tb"===e.layout?"xfaLr":"xfaRl"]},children:t}}function flushHTML(e){if(!e[Xs])return null;const t={name:"div",attributes:e[Xs].attributes,children:e[Xs].children};if(e[Xs].failingNode){const i=e[Xs].failingNode[Vs]();i&&(e.layout.endsWith("-tb")?t.children.push(createLine(e,[i])):t.children.push(i))}return 0===t.children.length?null:t}function addHTML(e,t,i){const a=e[Xs],s=a.availableSpace,[r,n,g,o]=i;switch(e.layout){case"position":a.width=Math.max(a.width,r+g);a.height=Math.max(a.height,n+o);a.children.push(t);break;case"lr-tb":case"rl-tb":if(!a.line||1===a.attempt){a.line=createLine(e,[]);a.children.push(a.line);a.numberInLine=0}a.numberInLine+=1;a.line.children.push(t);if(0===a.attempt){a.currentWidth+=g;a.height=Math.max(a.height,a.prevHeight+o)}else{a.currentWidth=g;a.prevHeight=a.height;a.height+=o;a.attempt=0}a.width=Math.max(a.width,a.currentWidth);break;case"rl-row":case"row":{a.children.push(t);a.width+=g;a.height=Math.max(a.height,o);const e=measureToString(a.height);for(const t of a.children)t.attributes.style.height=e;break}case"table":case"tb":a.width=Math.min(s.width,Math.max(a.width,g));a.height+=o;a.children.push(t)}}function getAvailableSpace(e){const t=e[Xs].availableSpace,i=e.margin?e.margin.topInset+e.margin.bottomInset:0,a=e.margin?e.margin.leftInset+e.margin.rightInset:0;switch(e.layout){case"lr-tb":case"rl-tb":return 0===e[Xs].attempt?{width:t.width-a-e[Xs].currentWidth,height:t.height-i-e[Xs].prevHeight}:{width:t.width-a,height:t.height-i-e[Xs].height};case"rl-row":case"row":return{width:e[Xs].columnWidths.slice(e[Xs].currentColumn).reduce(((e,t)=>e+t)),height:t.height-a};case"table":case"tb":return{width:t.width-a,height:t.height-i-e[Xs].height};default:return t}}function checkDimensions(e,t){if(null===e[cr]()[Xs].firstUnsplittable)return!0;if(0===e.w||0===e.h)return!0;const i=e[or](),a=i[Xs]?.attempt||0,[,s,r,n]=function getTransformedBBox(e){let t,i,a=""===e.w?NaN:e.w,s=""===e.h?NaN:e.h,[r,n]=[0,0];switch(e.anchorType||""){case"bottomCenter":[r,n]=[a/2,s];break;case"bottomLeft":[r,n]=[0,s];break;case"bottomRight":[r,n]=[a,s];break;case"middleCenter":[r,n]=[a/2,s/2];break;case"middleLeft":[r,n]=[0,s/2];break;case"middleRight":[r,n]=[a,s/2];break;case"topCenter":[r,n]=[a/2,0];break;case"topRight":[r,n]=[a,0]}switch(e.rotate||0){case 0:[t,i]=[-r,-n];break;case 90:[t,i]=[-n,r];[a,s]=[s,-a];break;case 180:[t,i]=[r,n];[a,s]=[-a,-s];break;case 270:[t,i]=[n,-r];[a,s]=[-s,a]}return[e.x+t+Math.min(0,a),e.y+i+Math.min(0,s),Math.abs(a),Math.abs(s)]}(e);switch(i.layout){case"lr-tb":case"rl-tb":return 0===a?e[cr]()[Xs].noLayoutFailure?""!==e.w?Math.round(r-t.width)<=2:t.width>2:!(""!==e.h&&Math.round(n-t.height)>2)&&(""!==e.w?Math.round(r-t.width)<=2||0===i[Xs].numberInLine&&t.height>2:t.width>2):!!e[cr]()[Xs].noLayoutFailure||!(""!==e.h&&Math.round(n-t.height)>2)&&((""===e.w||Math.round(r-t.width)<=2||!i[wr]())&&t.height>2);case"table":case"tb":return!!e[cr]()[Xs].noLayoutFailure||(""===e.h||e[yr]()?(""===e.w||Math.round(r-t.width)<=2||!i[wr]())&&t.height>2:Math.round(n-t.height)<=2);case"position":if(e[cr]()[Xs].noLayoutFailure)return!0;if(""===e.h||Math.round(n+s-t.height)<=2)return!0;return n+s>e[cr]()[Xs].currentContentArea.h;case"rl-row":case"row":return!!e[cr]()[Xs].noLayoutFailure||(""===e.h||Math.round(n-t.height)<=2);default:return!0}}const Nn=_r.template.id,Gn="http://www.w3.org/2000/svg",Mn=/^H(\d+)$/,Un=new Set(["image/gif","image/jpeg","image/jpg","image/pjpeg","image/png","image/apng","image/x-png","image/bmp","image/x-ms-bmp","image/tiff","image/tif","application/octet-stream"]),xn=[[[66,77],"image/bmp"],[[255,216,255],"image/jpeg"],[[73,73,42,0],"image/tiff"],[[77,77,0,42],"image/tiff"],[[71,73,70,56,57,97],"image/gif"],[[137,80,78,71,13,10,26,10],"image/png"]];function getBorderDims(e){if(!e||!e.border)return{w:0,h:0};const t=e.border[ar]();return t?{w:t.widths[0]+t.widths[2]+t.insets[0]+t.insets[2],h:t.widths[1]+t.widths[3]+t.insets[1]+t.insets[3]}:{w:0,h:0}}function hasMargin(e){return e.margin&&(e.margin.topInset||e.margin.rightInset||e.margin.bottomInset||e.margin.leftInset)}function _setValue(e,t){if(!e.value){const t=new Value({});e[Hs](t);e.value=t}e.value[qr](t)}function*getContainedChildren(e){for(const t of e[rr]())t instanceof SubformSet?yield*t[nr]():yield t}function isRequired(e){return"error"===e.validate?.nullTest}function setTabIndex(e){for(;e;){if(!e.traversal){e[Or]=e[Ir]()[Or];return}if(e[Or])return;let t=null;for(const i of e.traversal[rr]())if("next"===i.operation){t=i;break}if(!t||!t.ref){e[Or]=e[Ir]()[Or];return}const i=e[cr]();e[Or]=++i[Or];const a=i[vr](t.ref,e);if(!a)return;e=a[0]}}function applyAssist(e,t){const i=e.assist;if(i){const e=i[jr]();e&&(t.title=e);const a=i.role.match(Mn);if(a){const e="heading",i=a[1];t.role=e;t["aria-level"]=i}}if("table"===e.layout)t.role="table";else if("row"===e.layout)t.role="row";else{const i=e[Ir]();"row"===i.layout&&(t.role="TH"===i.assist?.role?"columnheader":"cell")}}function ariaLabel(e){if(!e.assist)return null;const t=e.assist;return t.speak&&""!==t.speak[Os]?t.speak[Os]:t.toolTip?t.toolTip[Os]:null}function valueToHtml(e){return HTMLResult.success({name:"div",attributes:{class:["xfaRich"],style:Object.create(null)},children:[{name:"span",attributes:{style:Object.create(null)},value:e}]})}function setFirstUnsplittable(e){const t=e[cr]();if(null===t[Xs].firstUnsplittable){t[Xs].firstUnsplittable=e;t[Xs].noLayoutFailure=!0}}function unsetFirstUnsplittable(e){const t=e[cr]();t[Xs].firstUnsplittable===e&&(t[Xs].noLayoutFailure=!1)}function handleBreak(e){if(e[Xs])return!1;e[Xs]=Object.create(null);if("auto"===e.targetType)return!1;const t=e[cr]();let i=null;if(e.target){i=t[vr](e.target,e[Ir]());if(!i)return!1;i=i[0]}const{currentPageArea:a,currentContentArea:s}=t[Xs];if("pageArea"===e.targetType){i instanceof PageArea||(i=null);if(e.startNew){e[Xs].target=i||a;return!0}if(i&&i!==a){e[Xs].target=i;return!0}return!1}i instanceof ContentArea||(i=null);const r=i&&i[Ir]();let n,g=r;if(e.startNew)if(i){const e=r.contentArea.children,t=e.indexOf(s),a=e.indexOf(i);-1!==t&&te;a[Xs].noLayoutFailure=!0;const n=t[jr](i);e[Ls](n.html,n.bbox);a[Xs].noLayoutFailure=s;t[or]=r}class AppearanceFilter extends StringObject{constructor(e){super(Nn,"appearanceFilter");this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Arc extends XFAObject{constructor(e){super(Nn,"arc",!0);this.circular=getInteger({data:e.circular,defaultValue:0,validate:e=>1===e});this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.startAngle=getFloat({data:e.startAngle,defaultValue:0,validate:e=>!0});this.sweepAngle=getFloat({data:e.sweepAngle,defaultValue:360,validate:e=>!0});this.use=e.use||"";this.usehref=e.usehref||"";this.edge=null;this.fill=null}[jr](){const e=this.edge||new Edge({}),t=e[Zr](),i=Object.create(null);"visible"===this.fill?.presence?Object.assign(i,this.fill[Zr]()):i.fill="transparent";i.strokeWidth=measureToString("visible"===e.presence?e.thickness:0);i.stroke=t.color;let a;const s={xmlns:Gn,style:{width:"100%",height:"100%",overflow:"visible"}};if(360===this.sweepAngle)a={name:"ellipse",attributes:{xmlns:Gn,cx:"50%",cy:"50%",rx:"50%",ry:"50%",style:i}};else{const e=this.startAngle*Math.PI/180,t=this.sweepAngle*Math.PI/180,r=this.sweepAngle>180?1:0,[n,g,o,c]=[50*(1+Math.cos(e)),50*(1-Math.sin(e)),50*(1+Math.cos(e+t)),50*(1-Math.sin(e+t))];a={name:"path",attributes:{xmlns:Gn,d:`M ${n} ${g} A 50 50 0 ${r} 0 ${o} ${c}`,vectorEffect:"non-scaling-stroke",style:i}};Object.assign(s,{viewBox:"0 0 100 100",preserveAspectRatio:"none"})}const r={name:"svg",children:[a],attributes:s};if(hasMargin(this[Ir]()[Ir]()))return HTMLResult.success({name:"div",attributes:{style:{display:"inline",width:"100%",height:"100%"}},children:[r]});r.attributes.style.position="absolute";return HTMLResult.success(r)}}class Area extends XFAObject{constructor(e){super(Nn,"area",!0);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.id=e.id||"";this.name=e.name||"";this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.desc=null;this.extras=null;this.area=new XFAObjectArray;this.draw=new XFAObjectArray;this.exObject=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.field=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray}*[nr](){yield*getContainedChildren(this)}[Dr](){return!0}[dr](){return!0}[Ls](e,t){const[i,a,s,r]=t;this[Xs].width=Math.max(this[Xs].width,i+s);this[Xs].height=Math.max(this[Xs].height,a+r);this[Xs].children.push(e)}[$s](){return this[Xs].availableSpace}[jr](e){const t=toStyle(this,"position"),i={style:t,id:this[Vr],class:["xfaArea"]};isPrintOnly(this)&&i.class.push("xfaPrintOnly");this.name&&(i.xfaName=this.name);const a=[];this[Xs]={children:a,width:0,height:0,availableSpace:e};const s=this[Js]({filter:new Set(["area","draw","field","exclGroup","subform","subformSet"]),include:!0});if(!s.success){if(s.isBreak())return s;delete this[Xs];return HTMLResult.FAILURE}t.width=measureToString(this[Xs].width);t.height=measureToString(this[Xs].height);const r={name:"div",attributes:i,children:a},n=[this.x,this.y,this[Xs].width,this[Xs].height];delete this[Xs];return HTMLResult.success(r,n)}}class Assist extends XFAObject{constructor(e){super(Nn,"assist",!0);this.id=e.id||"";this.role=e.role||"";this.use=e.use||"";this.usehref=e.usehref||"";this.speak=null;this.toolTip=null}[jr](){return this.toolTip?.[Os]||null}}class Barcode extends XFAObject{constructor(e){super(Nn,"barcode",!0);this.charEncoding=getKeyword({data:e.charEncoding?e.charEncoding.toLowerCase():"",defaultValue:"",validate:e=>["utf-8","big-five","fontspecific","gbk","gb-18030","gb-2312","ksc-5601","none","shift-jis","ucs-2","utf-16"].includes(e)||e.match(/iso-8859-\d{2}/)});this.checksum=getStringOption(e.checksum,["none","1mod10","1mod10_1mod11","2mod10","auto"]);this.dataColumnCount=getInteger({data:e.dataColumnCount,defaultValue:-1,validate:e=>e>=0});this.dataLength=getInteger({data:e.dataLength,defaultValue:-1,validate:e=>e>=0});this.dataPrep=getStringOption(e.dataPrep,["none","flateCompress"]);this.dataRowCount=getInteger({data:e.dataRowCount,defaultValue:-1,validate:e=>e>=0});this.endChar=e.endChar||"";this.errorCorrectionLevel=getInteger({data:e.errorCorrectionLevel,defaultValue:-1,validate:e=>e>=0&&e<=8});this.id=e.id||"";this.moduleHeight=getMeasurement(e.moduleHeight,"5mm");this.moduleWidth=getMeasurement(e.moduleWidth,"0.25mm");this.printCheckDigit=getInteger({data:e.printCheckDigit,defaultValue:0,validate:e=>1===e});this.rowColumnRatio=getRatio(e.rowColumnRatio);this.startChar=e.startChar||"";this.textLocation=getStringOption(e.textLocation,["below","above","aboveEmbedded","belowEmbedded","none"]);this.truncate=getInteger({data:e.truncate,defaultValue:0,validate:e=>1===e});this.type=getStringOption(e.type?e.type.toLowerCase():"",["aztec","codabar","code2of5industrial","code2of5interleaved","code2of5matrix","code2of5standard","code3of9","code3of9extended","code11","code49","code93","code128","code128a","code128b","code128c","code128sscc","datamatrix","ean8","ean8add2","ean8add5","ean13","ean13add2","ean13add5","ean13pwcd","fim","logmars","maxicode","msi","pdf417","pdf417macro","plessey","postauscust2","postauscust3","postausreplypaid","postausstandard","postukrm4scc","postusdpbc","postusimb","postusstandard","postus5zip","qrcode","rfid","rss14","rss14expanded","rss14limited","rss14stacked","rss14stackedomni","rss14truncated","telepen","ucc128","ucc128random","ucc128sscc","upca","upcaadd2","upcaadd5","upcapwcd","upce","upceadd2","upceadd5","upcean2","upcean5","upsmaxicode"]);this.upsMode=getStringOption(e.upsMode,["usCarrier","internationalCarrier","secureSymbol","standardSymbol"]);this.use=e.use||"";this.usehref=e.usehref||"";this.wideNarrowRatio=getRatio(e.wideNarrowRatio);this.encrypt=null;this.extras=null}}class Bind extends XFAObject{constructor(e){super(Nn,"bind",!0);this.match=getStringOption(e.match,["once","dataRef","global","none"]);this.ref=e.ref||"";this.picture=null}}class BindItems extends XFAObject{constructor(e){super(Nn,"bindItems");this.connection=e.connection||"";this.labelRef=e.labelRef||"";this.ref=e.ref||"";this.valueRef=e.valueRef||""}}class Bookend extends XFAObject{constructor(e){super(Nn,"bookend");this.id=e.id||"";this.leader=e.leader||"";this.trailer=e.trailer||"";this.use=e.use||"";this.usehref=e.usehref||""}}class BooleanElement extends Option01{constructor(e){super(Nn,"boolean");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[jr](e){return valueToHtml(1===this[Os]?"1":"0")}}class Border extends XFAObject{constructor(e){super(Nn,"border",!0);this.break=getStringOption(e.break,["close","open"]);this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.corner=new XFAObjectArray(4);this.edge=new XFAObjectArray(4);this.extras=null;this.fill=null;this.margin=null}[ar](){if(!this[Xs]){const e=this.edge.children.slice();if(e.length<4){const t=e.at(-1)||new Edge({});for(let i=e.length;i<4;i++)e.push(t)}const t=e.map((e=>e.thickness)),i=[0,0,0,0];if(this.margin){i[0]=this.margin.topInset;i[1]=this.margin.rightInset;i[2]=this.margin.bottomInset;i[3]=this.margin.leftInset}this[Xs]={widths:t,insets:i,edges:e}}return this[Xs]}[Zr](){const{edges:e}=this[ar](),t=e.map((e=>{const t=e[Zr]();t.color||="#000000";return t})),i=Object.create(null);this.margin&&Object.assign(i,this.margin[Zr]());"visible"===this.fill?.presence&&Object.assign(i,this.fill[Zr]());if(this.corner.children.some((e=>0!==e.radius))){const e=this.corner.children.map((e=>e[Zr]()));if(2===e.length||3===e.length){const t=e.at(-1);for(let i=e.length;i<4;i++)e.push(t)}i.borderRadius=e.map((e=>e.radius)).join(" ")}switch(this.presence){case"invisible":case"hidden":i.borderStyle="";break;case"inactive":i.borderStyle="none";break;default:i.borderStyle=t.map((e=>e.style)).join(" ")}i.borderWidth=t.map((e=>e.width)).join(" ");i.borderColor=t.map((e=>e.color)).join(" ");return i}}class Break extends XFAObject{constructor(e){super(Nn,"break",!0);this.after=getStringOption(e.after,["auto","contentArea","pageArea","pageEven","pageOdd"]);this.afterTarget=e.afterTarget||"";this.before=getStringOption(e.before,["auto","contentArea","pageArea","pageEven","pageOdd"]);this.beforeTarget=e.beforeTarget||"";this.bookendLeader=e.bookendLeader||"";this.bookendTrailer=e.bookendTrailer||"";this.id=e.id||"";this.overflowLeader=e.overflowLeader||"";this.overflowTarget=e.overflowTarget||"";this.overflowTrailer=e.overflowTrailer||"";this.startNew=getInteger({data:e.startNew,defaultValue:0,validate:e=>1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}}class BreakAfter extends XFAObject{constructor(e){super(Nn,"breakAfter",!0);this.id=e.id||"";this.leader=e.leader||"";this.startNew=getInteger({data:e.startNew,defaultValue:0,validate:e=>1===e});this.target=e.target||"";this.targetType=getStringOption(e.targetType,["auto","contentArea","pageArea"]);this.trailer=e.trailer||"";this.use=e.use||"";this.usehref=e.usehref||"";this.script=null}}class BreakBefore extends XFAObject{constructor(e){super(Nn,"breakBefore",!0);this.id=e.id||"";this.leader=e.leader||"";this.startNew=getInteger({data:e.startNew,defaultValue:0,validate:e=>1===e});this.target=e.target||"";this.targetType=getStringOption(e.targetType,["auto","contentArea","pageArea"]);this.trailer=e.trailer||"";this.use=e.use||"";this.usehref=e.usehref||"";this.script=null}[jr](e){this[Xs]={};return HTMLResult.FAILURE}}class Button extends XFAObject{constructor(e){super(Nn,"button",!0);this.highlight=getStringOption(e.highlight,["inverted","none","outline","push"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[jr](e){const t=this[Ir]()[Ir](),i={name:"button",attributes:{id:this[Vr],class:["xfaButton"],style:{}},children:[]};for(const e of t.event.children){if("click"!==e.activity||!e.script)continue;const t=recoverJsURL(e.script[Os]);if(!t)continue;const a=fixURL(t.url);a&&i.children.push({name:"a",attributes:{id:"link"+this[Vr],href:a,newWindow:t.newWindow,class:["xfaLink"],style:{}},children:[]})}return HTMLResult.success(i)}}class Calculate extends XFAObject{constructor(e){super(Nn,"calculate",!0);this.id=e.id||"";this.override=getStringOption(e.override,["disabled","error","ignore","warning"]);this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.message=null;this.script=null}}class Caption extends XFAObject{constructor(e){super(Nn,"caption",!0);this.id=e.id||"";this.placement=getStringOption(e.placement,["left","bottom","inline","right","top"]);this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.reserve=Math.ceil(getMeasurement(e.reserve));this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.font=null;this.margin=null;this.para=null;this.value=null}[qr](e){_setValue(this,e)}[ar](e){if(!this[Xs]){let{width:t,height:i}=e;switch(this.placement){case"left":case"right":case"inline":t=this.reserve<=0?t:this.reserve;break;case"top":case"bottom":i=this.reserve<=0?i:this.reserve}this[Xs]=layoutNode(this,{width:t,height:i})}return this[Xs]}[jr](e){if(!this.value)return HTMLResult.EMPTY;this[Lr]();const t=this.value[jr](e).html;if(!t){this[xr]();return HTMLResult.EMPTY}const i=this.reserve;if(this.reserve<=0){const{w:t,h:i}=this[ar](e);switch(this.placement){case"left":case"right":case"inline":this.reserve=t;break;case"top":case"bottom":this.reserve=i}}const a=[];"string"==typeof t?a.push({name:"#text",value:t}):a.push(t);const s=toStyle(this,"font","margin","visibility");switch(this.placement){case"left":case"right":this.reserve>0&&(s.width=measureToString(this.reserve));break;case"top":case"bottom":this.reserve>0&&(s.height=measureToString(this.reserve))}setPara(this,null,t);this[xr]();this.reserve=i;return HTMLResult.success({name:"div",attributes:{style:s,class:["xfaCaption"]},children:a})}}class Certificate extends StringObject{constructor(e){super(Nn,"certificate");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Certificates extends XFAObject{constructor(e){super(Nn,"certificates",!0);this.credentialServerPolicy=getStringOption(e.credentialServerPolicy,["optional","required"]);this.id=e.id||"";this.url=e.url||"";this.urlPolicy=e.urlPolicy||"";this.use=e.use||"";this.usehref=e.usehref||"";this.encryption=null;this.issuers=null;this.keyUsage=null;this.oids=null;this.signing=null;this.subjectDNs=null}}class CheckButton extends XFAObject{constructor(e){super(Nn,"checkButton",!0);this.id=e.id||"";this.mark=getStringOption(e.mark,["default","check","circle","cross","diamond","square","star"]);this.shape=getStringOption(e.shape,["square","round"]);this.size=getMeasurement(e.size,"10pt");this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle("margin"),i=measureToString(this.size);t.width=t.height=i;let a,s,r;const n=this[Ir]()[Ir](),g=n.items.children.length&&n.items.children[0][jr]().html||[],o={on:(void 0!==g[0]?g[0]:"on").toString(),off:(void 0!==g[1]?g[1]:"off").toString()},c=(n.value?.[Pr]()||"off")===o.on||void 0,C=n[or](),h=n[Vr];let l;if(C instanceof ExclGroup){r=C[Vr];a="radio";s="xfaRadio";l=C[Ws]?.[Vr]||C[Vr]}else{a="checkbox";s="xfaCheckbox";l=n[Ws]?.[Vr]||n[Vr]}const Q={name:"input",attributes:{class:[s],style:t,fieldId:h,dataId:l,type:a,checked:c,xfaOn:o.on,xfaOff:o.off,"aria-label":ariaLabel(n),"aria-required":!1}};r&&(Q.attributes.name=r);if(isRequired(n)){Q.attributes["aria-required"]=!0;Q.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[Q]})}}class ChoiceList extends XFAObject{constructor(e){super(Nn,"choiceList",!0);this.commitOn=getStringOption(e.commitOn,["select","exit"]);this.id=e.id||"";this.open=getStringOption(e.open,["userControl","always","multiSelect","onEntry"]);this.textEntry=getInteger({data:e.textEntry,defaultValue:0,validate:e=>1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","margin"),i=this[Ir]()[Ir](),a={fontSize:`calc(${i.font?.size||10}px * var(--scale-factor))`},s=[];if(i.items.children.length>0){const e=i.items;let t=0,r=0;if(2===e.children.length){t=e.children[0].save;r=1-t}const n=e.children[t][jr]().html,g=e.children[r][jr]().html;let o=!1;const c=i.value?.[Pr]()||"";for(let e=0,t=n.length;eMath.min(Math.max(0,parseInt(e.trim(),10)),255))).map((e=>isNaN(e)?0:e));if(r.length<3)return{r:i,g:a,b:s};[i,a,s]=r;return{r:i,g:a,b:s}}(e.value):"";this.extras=null}[hr](){return!1}[Zr](){return this.value?Util.makeHexColor(this.value.r,this.value.g,this.value.b):null}}class Comb extends XFAObject{constructor(e){super(Nn,"comb");this.id=e.id||"";this.numberOfCells=getInteger({data:e.numberOfCells,defaultValue:0,validate:e=>e>=0});this.use=e.use||"";this.usehref=e.usehref||""}}class Connect extends XFAObject{constructor(e){super(Nn,"connect",!0);this.connection=e.connection||"";this.id=e.id||"";this.ref=e.ref||"";this.usage=getStringOption(e.usage,["exportAndImport","exportOnly","importOnly"]);this.use=e.use||"";this.usehref=e.usehref||"";this.picture=null}}class ContentArea extends XFAObject{constructor(e){super(Nn,"contentArea",!0);this.h=getMeasurement(e.h);this.id=e.id||"";this.name=e.name||"";this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.w=getMeasurement(e.w);this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.desc=null;this.extras=null}[jr](e){const t={left:measureToString(this.x),top:measureToString(this.y),width:measureToString(this.w),height:measureToString(this.h)},i=["xfaContentarea"];isPrintOnly(this)&&i.push("xfaPrintOnly");return HTMLResult.success({name:"div",children:[],attributes:{style:t,class:i,id:this[Vr]}})}}class Corner extends XFAObject{constructor(e){super(Nn,"corner",!0);this.id=e.id||"";this.inverted=getInteger({data:e.inverted,defaultValue:0,validate:e=>1===e});this.join=getStringOption(e.join,["square","round"]);this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.radius=getMeasurement(e.radius);this.stroke=getStringOption(e.stroke,["solid","dashDot","dashDotDot","dashed","dotted","embossed","etched","lowered","raised"]);this.thickness=getMeasurement(e.thickness,"0.5pt");this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](){const e=toStyle(this,"visibility");e.radius=measureToString("square"===this.join?0:this.radius);return e}}class DateElement extends ContentObject{constructor(e){super(Nn,"date");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=this[Os].trim();this[Os]=e?new Date(e):null}[jr](e){return valueToHtml(this[Os]?this[Os].toString():"")}}class DateTime extends ContentObject{constructor(e){super(Nn,"dateTime");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=this[Os].trim();this[Os]=e?new Date(e):null}[jr](e){return valueToHtml(this[Os]?this[Os].toString():"")}}class DateTimeEdit extends XFAObject{constructor(e){super(Nn,"dateTimeEdit",!0);this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.picker=getStringOption(e.picker,["host","none"]);this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.comb=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","font","margin"),i=this[Ir]()[Ir](),a={name:"input",attributes:{type:"text",fieldId:i[Vr],dataId:i[Ws]?.[Vr]||i[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(i),"aria-required":!1}};if(isRequired(i)){a.attributes["aria-required"]=!0;a.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[a]})}}class Decimal extends ContentObject{constructor(e){super(Nn,"decimal");this.fracDigits=getInteger({data:e.fracDigits,defaultValue:2,validate:e=>!0});this.id=e.id||"";this.leadDigits=getInteger({data:e.leadDigits,defaultValue:-1,validate:e=>!0});this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=parseFloat(this[Os].trim());this[Os]=isNaN(e)?null:e}[jr](e){return valueToHtml(null!==this[Os]?this[Os].toString():"")}}class DefaultUi extends XFAObject{constructor(e){super(Nn,"defaultUi",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}}class Desc extends XFAObject{constructor(e){super(Nn,"desc",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}}class DigestMethod extends OptionObject{constructor(e){super(Nn,"digestMethod",["","SHA1","SHA256","SHA512","RIPEMD160"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class DigestMethods extends XFAObject{constructor(e){super(Nn,"digestMethods",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.digestMethod=new XFAObjectArray}}class Draw extends XFAObject{constructor(e){super(Nn,"draw",!0);this.anchorType=getStringOption(e.anchorType,["topLeft","bottomCenter","bottomLeft","bottomRight","middleCenter","middleLeft","middleRight","topCenter","topRight"]);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.locale=e.locale||"";this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.rotate=getInteger({data:e.rotate,defaultValue:0,validate:e=>e%90==0});this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.border=null;this.caption=null;this.desc=null;this.extras=null;this.font=null;this.keep=null;this.margin=null;this.para=null;this.traversal=null;this.ui=null;this.value=null;this.setProperty=new XFAObjectArray}[qr](e){_setValue(this,e)}[jr](e){setTabIndex(this);if("hidden"===this.presence||"inactive"===this.presence)return HTMLResult.EMPTY;fixDimensions(this);this[Lr]();const t=this.w,i=this.h,{w:a,h:s,isBroken:r}=layoutNode(this,e);if(a&&""===this.w){if(r&&this[or]()[wr]()){this[xr]();return HTMLResult.FAILURE}this.w=a}s&&""===this.h&&(this.h=s);setFirstUnsplittable(this);if(!checkDimensions(this,e)){this.w=t;this.h=i;this[xr]();return HTMLResult.FAILURE}unsetFirstUnsplittable(this);const n=toStyle(this,"font","hAlign","dimensions","position","presence","rotate","anchorType","border","margin");setMinMaxDimensions(this,n);if(n.margin){n.padding=n.margin;delete n.margin}const g=["xfaDraw"];this.font&&g.push("xfaFont");isPrintOnly(this)&&g.push("xfaPrintOnly");const o={style:n,id:this[Vr],class:g};this.name&&(o.xfaName=this.name);const c={name:"div",attributes:o,children:[]};applyAssist(this,o);const C=computeBbox(this,c,e),h=this.value?this.value[jr](e).html:null;if(null===h){this.w=t;this.h=i;this[xr]();return HTMLResult.success(createWrapper(this,c),C)}c.children.push(h);setPara(this,n,h);this.w=t;this.h=i;this[xr]();return HTMLResult.success(createWrapper(this,c),C)}}class Edge extends XFAObject{constructor(e){super(Nn,"edge",!0);this.cap=getStringOption(e.cap,["square","butt","round"]);this.id=e.id||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.stroke=getStringOption(e.stroke,["solid","dashDot","dashDotDot","dashed","dotted","embossed","etched","lowered","raised"]);this.thickness=getMeasurement(e.thickness,"0.5pt");this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](){const e=toStyle(this,"visibility");Object.assign(e,{linecap:this.cap,width:measureToString(this.thickness),color:this.color?this.color[Zr]():"#000000",style:""});if("visible"!==this.presence)e.style="none";else switch(this.stroke){case"solid":e.style="solid";break;case"dashDot":case"dashDotDot":case"dashed":e.style="dashed";break;case"dotted":e.style="dotted";break;case"embossed":e.style="ridge";break;case"etched":e.style="groove";break;case"lowered":e.style="inset";break;case"raised":e.style="outset"}return e}}class Encoding extends OptionObject{constructor(e){super(Nn,"encoding",["adbe.x509.rsa_sha1","adbe.pkcs7.detached","adbe.pkcs7.sha1"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Encodings extends XFAObject{constructor(e){super(Nn,"encodings",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.encoding=new XFAObjectArray}}class Encrypt extends XFAObject{constructor(e){super(Nn,"encrypt",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=null}}class EncryptData extends XFAObject{constructor(e){super(Nn,"encryptData",!0);this.id=e.id||"";this.operation=getStringOption(e.operation,["encrypt","decrypt"]);this.target=e.target||"";this.use=e.use||"";this.usehref=e.usehref||"";this.filter=null;this.manifest=null}}class Encryption extends XFAObject{constructor(e){super(Nn,"encryption",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=new XFAObjectArray}}class EncryptionMethod extends OptionObject{constructor(e){super(Nn,"encryptionMethod",["","AES256-CBC","TRIPLEDES-CBC","AES128-CBC","AES192-CBC"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class EncryptionMethods extends XFAObject{constructor(e){super(Nn,"encryptionMethods",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.encryptionMethod=new XFAObjectArray}}class Event extends XFAObject{constructor(e){super(Nn,"event",!0);this.activity=getStringOption(e.activity,["click","change","docClose","docReady","enter","exit","full","indexChange","initialize","mouseDown","mouseEnter","mouseExit","mouseUp","postExecute","postOpen","postPrint","postSave","postSign","postSubmit","preExecute","preOpen","prePrint","preSave","preSign","preSubmit","ready","validationState"]);this.id=e.id||"";this.listen=getStringOption(e.listen,["refOnly","refAndDescendents"]);this.name=e.name||"";this.ref=e.ref||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.encryptData=null;this.execute=null;this.script=null;this.signData=null;this.submit=null}}class ExData extends ContentObject{constructor(e){super(Nn,"exData");this.contentType=e.contentType||"";this.href=e.href||"";this.id=e.id||"";this.maxLength=getInteger({data:e.maxLength,defaultValue:-1,validate:e=>e>=-1});this.name=e.name||"";this.rid=e.rid||"";this.transferEncoding=getStringOption(e.transferEncoding,["none","base64","package"]);this.use=e.use||"";this.usehref=e.usehref||""}[ur](){return"text/html"===this.contentType}[Nr](e){if("text/html"===this.contentType&&e[Sr]===_r.xhtml.id){this[Os]=e;return!0}if("text/xml"===this.contentType){this[Os]=e;return!0}return!1}[jr](e){return"text/html"===this.contentType&&this[Os]?this[Os][jr](e):HTMLResult.EMPTY}}class ExObject extends XFAObject{constructor(e){super(Nn,"exObject",!0);this.archive=e.archive||"";this.classId=e.classId||"";this.codeBase=e.codeBase||"";this.codeType=e.codeType||"";this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.exObject=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}}class ExclGroup extends XFAObject{constructor(e){super(Nn,"exclGroup",!0);this.access=getStringOption(e.access,["open","nonInteractive","protected","readOnly"]);this.accessKey=e.accessKey||"";this.anchorType=getStringOption(e.anchorType,["topLeft","bottomCenter","bottomLeft","bottomRight","middleCenter","middleLeft","middleRight","topCenter","topRight"]);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.layout=getStringOption(e.layout,["position","lr-tb","rl-row","rl-tb","row","table","tb"]);this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.bind=null;this.border=null;this.calculate=null;this.caption=null;this.desc=null;this.extras=null;this.margin=null;this.para=null;this.traversal=null;this.validate=null;this.connect=new XFAObjectArray;this.event=new XFAObjectArray;this.field=new XFAObjectArray;this.setProperty=new XFAObjectArray}[dr](){return!0}[hr](){return!0}[qr](e){for(const t of this.field.children){if(!t.value){const e=new Value({});t[Hs](e);t.value=e}t.value[qr](e)}}[wr](){return this.layout.endsWith("-tb")&&0===this[Xs].attempt&&this[Xs].numberInLine>0||this[Ir]()[wr]()}[yr](){const e=this[or]();if(!e[yr]())return!1;if(void 0!==this[Xs]._isSplittable)return this[Xs]._isSplittable;if("position"===this.layout||this.layout.includes("row")){this[Xs]._isSplittable=!1;return!1}if(e.layout?.endsWith("-tb")&&0!==e[Xs].numberInLine)return!1;this[Xs]._isSplittable=!0;return!0}[Vs](){return flushHTML(this)}[Ls](e,t){addHTML(this,e,t)}[$s](){return getAvailableSpace(this)}[jr](e){setTabIndex(this);if("hidden"===this.presence||"inactive"===this.presence||0===this.h||0===this.w)return HTMLResult.EMPTY;fixDimensions(this);const t=[],i={id:this[Vr],class:[]};setAccess(this,i.class);this[Xs]||(this[Xs]=Object.create(null));Object.assign(this[Xs],{children:t,attributes:i,attempt:0,line:null,numberInLine:0,availableSpace:{width:Math.min(this.w||1/0,e.width),height:Math.min(this.h||1/0,e.height)},width:0,height:0,prevHeight:0,currentWidth:0});const a=this[yr]();a||setFirstUnsplittable(this);if(!checkDimensions(this,e))return HTMLResult.FAILURE;const s=new Set(["field"]);if(this.layout.includes("row")){const e=this[or]().columnWidths;if(Array.isArray(e)&&e.length>0){this[Xs].columnWidths=e;this[Xs].currentColumn=0}}const r=toStyle(this,"anchorType","dimensions","position","presence","border","margin","hAlign"),n=["xfaExclgroup"],g=layoutClass(this);g&&n.push(g);isPrintOnly(this)&&n.push("xfaPrintOnly");i.style=r;i.class=n;this.name&&(i.xfaName=this.name);this[Lr]();const o="lr-tb"===this.layout||"rl-tb"===this.layout,c=o?2:1;for(;this[Xs].attempte>=1||-1===e});this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.locale=e.locale||"";this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.rotate=getInteger({data:e.rotate,defaultValue:0,validate:e=>e%90==0});this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.bind=null;this.border=null;this.calculate=null;this.caption=null;this.desc=null;this.extras=null;this.font=null;this.format=null;this.items=new XFAObjectArray(2);this.keep=null;this.margin=null;this.para=null;this.traversal=null;this.ui=null;this.validate=null;this.value=null;this.bindItems=new XFAObjectArray;this.connect=new XFAObjectArray;this.event=new XFAObjectArray;this.setProperty=new XFAObjectArray}[dr](){return!0}[qr](e){_setValue(this,e)}[jr](e){setTabIndex(this);if(!this.ui){this.ui=new Ui({});this.ui[Cr]=this[Cr];this[Hs](this.ui);let e;switch(this.items.children.length){case 0:e=new TextEdit({});this.ui.textEdit=e;break;case 1:e=new CheckButton({});this.ui.checkButton=e;break;case 2:e=new ChoiceList({});this.ui.choiceList=e}this.ui[Hs](e)}if(!this.ui||"hidden"===this.presence||"inactive"===this.presence||0===this.h||0===this.w)return HTMLResult.EMPTY;this.caption&&delete this.caption[Xs];this[Lr]();const t=this.caption?this.caption[jr](e).html:null,i=this.w,a=this.h;let s=0,r=0;if(this.margin){s=this.margin.leftInset+this.margin.rightInset;r=this.margin.topInset+this.margin.bottomInset}let n=null;if(""===this.w||""===this.h){let t=null,i=null,a=0,g=0;if(this.ui.checkButton)a=g=this.ui.checkButton.size;else{const{w:t,h:i}=layoutNode(this,e);if(null!==t){a=t;g=i}else g=function fonts_getMetrics(e,t=!1){let i=null;if(e){const t=stripQuotes(e.typeface),a=e[Cr].fontFinder.find(t);i=selectFont(e,a)}if(!i)return{lineHeight:12,lineGap:2,lineNoGap:10};const a=e.size||10,s=i.lineHeight?Math.max(t?0:1.2,i.lineHeight):1.2,r=void 0===i.lineGap?.2:i.lineGap;return{lineHeight:s*a,lineGap:r*a,lineNoGap:Math.max(1,s-r)*a}}(this.font,!0).lineNoGap}n=getBorderDims(this.ui[ar]());a+=n.w;g+=n.h;if(this.caption){const{w:s,h:r,isBroken:n}=this.caption[ar](e);if(n&&this[or]()[wr]()){this[xr]();return HTMLResult.FAILURE}t=s;i=r;switch(this.caption.placement){case"left":case"right":case"inline":t+=a;break;case"top":case"bottom":i+=g}}else{t=a;i=g}if(t&&""===this.w){t+=s;this.w=Math.min(this.maxW<=0?1/0:this.maxW,this.minW+1e>=1&&e<=5});this.appearanceFilter=null;this.certificates=null;this.digestMethods=null;this.encodings=null;this.encryptionMethods=null;this.handler=null;this.lockDocument=null;this.mdp=null;this.reasons=null;this.timeStamp=null}}class Float extends ContentObject{constructor(e){super(Nn,"float");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=parseFloat(this[Os].trim());this[Os]=isNaN(e)?null:e}[jr](e){return valueToHtml(null!==this[Os]?this[Os].toString():"")}}class template_Font extends XFAObject{constructor(e){super(Nn,"font",!0);this.baselineShift=getMeasurement(e.baselineShift);this.fontHorizontalScale=getFloat({data:e.fontHorizontalScale,defaultValue:100,validate:e=>e>=0});this.fontVerticalScale=getFloat({data:e.fontVerticalScale,defaultValue:100,validate:e=>e>=0});this.id=e.id||"";this.kerningMode=getStringOption(e.kerningMode,["none","pair"]);this.letterSpacing=getMeasurement(e.letterSpacing,"0");this.lineThrough=getInteger({data:e.lineThrough,defaultValue:0,validate:e=>1===e||2===e});this.lineThroughPeriod=getStringOption(e.lineThroughPeriod,["all","word"]);this.overline=getInteger({data:e.overline,defaultValue:0,validate:e=>1===e||2===e});this.overlinePeriod=getStringOption(e.overlinePeriod,["all","word"]);this.posture=getStringOption(e.posture,["normal","italic"]);this.size=getMeasurement(e.size,"10pt");this.typeface=e.typeface||"Courier";this.underline=getInteger({data:e.underline,defaultValue:0,validate:e=>1===e||2===e});this.underlinePeriod=getStringOption(e.underlinePeriod,["all","word"]);this.use=e.use||"";this.usehref=e.usehref||"";this.weight=getStringOption(e.weight,["normal","bold"]);this.extras=null;this.fill=null}[Ys](e){super[Ys](e);this[Cr].usedTypefaces.add(this.typeface)}[Zr](){const e=toStyle(this,"fill"),t=e.color;if(t)if("#000000"===t)delete e.color;else if(!t.startsWith("#")){e.background=t;e.backgroundClip="text";e.color="transparent"}this.baselineShift&&(e.verticalAlign=measureToString(this.baselineShift));e.fontKerning="none"===this.kerningMode?"none":"normal";e.letterSpacing=measureToString(this.letterSpacing);if(0!==this.lineThrough){e.textDecoration="line-through";2===this.lineThrough&&(e.textDecorationStyle="double")}if(0!==this.overline){e.textDecoration="overline";2===this.overline&&(e.textDecorationStyle="double")}e.fontStyle=this.posture;e.fontSize=measureToString(.99*this.size);setFontFamily(this,this,this[Cr].fontFinder,e);if(0!==this.underline){e.textDecoration="underline";2===this.underline&&(e.textDecorationStyle="double")}e.fontWeight=this.weight;return e}}class Format extends XFAObject{constructor(e){super(Nn,"format",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.picture=null}}class Handler extends StringObject{constructor(e){super(Nn,"handler");this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Hyphenation extends XFAObject{constructor(e){super(Nn,"hyphenation");this.excludeAllCaps=getInteger({data:e.excludeAllCaps,defaultValue:0,validate:e=>1===e});this.excludeInitialCap=getInteger({data:e.excludeInitialCap,defaultValue:0,validate:e=>1===e});this.hyphenate=getInteger({data:e.hyphenate,defaultValue:0,validate:e=>1===e});this.id=e.id||"";this.pushCharacterCount=getInteger({data:e.pushCharacterCount,defaultValue:3,validate:e=>e>=0});this.remainCharacterCount=getInteger({data:e.remainCharacterCount,defaultValue:3,validate:e=>e>=0});this.use=e.use||"";this.usehref=e.usehref||"";this.wordCharacterCount=getInteger({data:e.wordCharacterCount,defaultValue:7,validate:e=>e>=0})}}class Image extends StringObject{constructor(e){super(Nn,"image");this.aspect=getStringOption(e.aspect,["fit","actual","height","none","width"]);this.contentType=e.contentType||"";this.href=e.href||"";this.id=e.id||"";this.name=e.name||"";this.transferEncoding=getStringOption(e.transferEncoding,["base64","none","package"]);this.use=e.use||"";this.usehref=e.usehref||""}[jr](){if(this.contentType&&!Un.has(this.contentType.toLowerCase()))return HTMLResult.EMPTY;let e=this[Cr].images&&this[Cr].images.get(this.href);if(!e&&(this.href||!this[Os]))return HTMLResult.EMPTY;e||"base64"!==this.transferEncoding||(e=function fromBase64Util(e){return Uint8Array.fromBase64?Uint8Array.fromBase64(e):stringToBytes(atob(e))}(this[Os]));if(!e)return HTMLResult.EMPTY;if(!this.contentType){for(const[t,i]of xn)if(e.length>t.length&&t.every(((t,i)=>t===e[i]))){this.contentType=i;break}if(!this.contentType)return HTMLResult.EMPTY}const t=new Blob([e],{type:this.contentType});let i;switch(this.aspect){case"fit":case"actual":break;case"height":i={height:"100%",objectFit:"fill"};break;case"none":i={width:"100%",height:"100%",objectFit:"fill"};break;case"width":i={width:"100%",objectFit:"fill"}}const a=this[Ir]();return HTMLResult.success({name:"img",attributes:{class:["xfaImage"],style:i,src:URL.createObjectURL(t),alt:a?ariaLabel(a[Ir]()):null}})}}class ImageEdit extends XFAObject{constructor(e){super(Nn,"imageEdit",!0);this.data=getStringOption(e.data,["link","embed"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}[jr](e){return"embed"===this.data?HTMLResult.success({name:"div",children:[],attributes:{}}):HTMLResult.EMPTY}}class Integer extends ContentObject{constructor(e){super(Nn,"integer");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=parseInt(this[Os].trim(),10);this[Os]=isNaN(e)?null:e}[jr](e){return valueToHtml(null!==this[Os]?this[Os].toString():"")}}class Issuers extends XFAObject{constructor(e){super(Nn,"issuers",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=new XFAObjectArray}}class Items extends XFAObject{constructor(e){super(Nn,"items",!0);this.id=e.id||"";this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.ref=e.ref||"";this.save=getInteger({data:e.save,defaultValue:0,validate:e=>1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}[jr](){const e=[];for(const t of this[rr]())e.push(t[Pr]());return HTMLResult.success(e)}}class Keep extends XFAObject{constructor(e){super(Nn,"keep",!0);this.id=e.id||"";const t=["none","contentArea","pageArea"];this.intact=getStringOption(e.intact,t);this.next=getStringOption(e.next,t);this.previous=getStringOption(e.previous,t);this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}}class KeyUsage extends XFAObject{constructor(e){super(Nn,"keyUsage");const t=["","yes","no"];this.crlSign=getStringOption(e.crlSign,t);this.dataEncipherment=getStringOption(e.dataEncipherment,t);this.decipherOnly=getStringOption(e.decipherOnly,t);this.digitalSignature=getStringOption(e.digitalSignature,t);this.encipherOnly=getStringOption(e.encipherOnly,t);this.id=e.id||"";this.keyAgreement=getStringOption(e.keyAgreement,t);this.keyCertSign=getStringOption(e.keyCertSign,t);this.keyEncipherment=getStringOption(e.keyEncipherment,t);this.nonRepudiation=getStringOption(e.nonRepudiation,t);this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Line extends XFAObject{constructor(e){super(Nn,"line",!0);this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.slope=getStringOption(e.slope,["\\","/"]);this.use=e.use||"";this.usehref=e.usehref||"";this.edge=null}[jr](){const e=this[Ir]()[Ir](),t=this.edge||new Edge({}),i=t[Zr](),a=Object.create(null),s="visible"===t.presence?t.thickness:0;a.strokeWidth=measureToString(s);a.stroke=i.color;let r,n,g,o,c="100%",C="100%";if(e.w<=s){[r,n,g,o]=["50%",0,"50%","100%"];c=a.strokeWidth}else if(e.h<=s){[r,n,g,o]=[0,"50%","100%","50%"];C=a.strokeWidth}else"\\"===this.slope?[r,n,g,o]=[0,0,"100%","100%"]:[r,n,g,o]=[0,"100%","100%",0];const h={name:"svg",children:[{name:"line",attributes:{xmlns:Gn,x1:r,y1:n,x2:g,y2:o,style:a}}],attributes:{xmlns:Gn,width:c,height:C,style:{overflow:"visible"}}};if(hasMargin(e))return HTMLResult.success({name:"div",attributes:{style:{display:"inline",width:"100%",height:"100%"}},children:[h]});h.attributes.style.position="absolute";return HTMLResult.success(h)}}class Linear extends XFAObject{constructor(e){super(Nn,"linear",!0);this.id=e.id||"";this.type=getStringOption(e.type,["toRight","toBottom","toLeft","toTop"]);this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){e=e?e[Zr]():"#FFFFFF";return`linear-gradient(${this.type.replace(/([RBLT])/," $1").toLowerCase()}, ${e}, ${this.color?this.color[Zr]():"#000000"})`}}class LockDocument extends ContentObject{constructor(e){super(Nn,"lockDocument");this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){this[Os]=getStringOption(this[Os],["auto","0","1"])}}class Manifest extends XFAObject{constructor(e){super(Nn,"manifest",!0);this.action=getStringOption(e.action,["include","all","exclude"]);this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.ref=new XFAObjectArray}}class Margin extends XFAObject{constructor(e){super(Nn,"margin",!0);this.bottomInset=getMeasurement(e.bottomInset,"0");this.id=e.id||"";this.leftInset=getMeasurement(e.leftInset,"0");this.rightInset=getMeasurement(e.rightInset,"0");this.topInset=getMeasurement(e.topInset,"0");this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[Zr](){return{margin:measureToString(this.topInset)+" "+measureToString(this.rightInset)+" "+measureToString(this.bottomInset)+" "+measureToString(this.leftInset)}}}class Mdp extends XFAObject{constructor(e){super(Nn,"mdp");this.id=e.id||"";this.permissions=getInteger({data:e.permissions,defaultValue:2,validate:e=>1===e||3===e});this.signatureType=getStringOption(e.signatureType,["filler","author"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Medium extends XFAObject{constructor(e){super(Nn,"medium");this.id=e.id||"";this.imagingBBox=function getBBox(e){const t=-1;if(!e)return{x:t,y:t,width:t,height:t};const i=e.trim().split(/\s*,\s*/).map((e=>getMeasurement(e,"-1")));if(i.length<4||i[2]<0||i[3]<0)return{x:t,y:t,width:t,height:t};const[a,s,r,n]=i;return{x:a,y:s,width:r,height:n}}(e.imagingBBox);this.long=getMeasurement(e.long);this.orientation=getStringOption(e.orientation,["portrait","landscape"]);this.short=getMeasurement(e.short);this.stock=e.stock||"";this.trayIn=getStringOption(e.trayIn,["auto","delegate","pageFront"]);this.trayOut=getStringOption(e.trayOut,["auto","delegate"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Message extends XFAObject{constructor(e){super(Nn,"message",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.text=new XFAObjectArray}}class NumericEdit extends XFAObject{constructor(e){super(Nn,"numericEdit",!0);this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.comb=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","font","margin"),i=this[Ir]()[Ir](),a={name:"input",attributes:{type:"text",fieldId:i[Vr],dataId:i[Ws]?.[Vr]||i[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(i),"aria-required":!1}};if(isRequired(i)){a.attributes["aria-required"]=!0;a.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[a]})}}class Occur extends XFAObject{constructor(e){super(Nn,"occur",!0);this.id=e.id||"";this.initial=""!==e.initial?getInteger({data:e.initial,defaultValue:"",validate:e=>!0}):"";this.max=""!==e.max?getInteger({data:e.max,defaultValue:1,validate:e=>!0}):"";this.min=""!==e.min?getInteger({data:e.min,defaultValue:1,validate:e=>!0}):"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[Ys](){const e=this[Ir](),t=this.min;""===this.min&&(this.min=e instanceof PageArea||e instanceof PageSet?0:1);""===this.max&&(this.max=""===t?e instanceof PageArea||e instanceof PageSet?-1:1:this.min);-1!==this.max&&this.max!0});this.name=e.name||"";this.numbered=getInteger({data:e.numbered,defaultValue:1,validate:e=>!0});this.oddOrEven=getStringOption(e.oddOrEven,["any","even","odd"]);this.pagePosition=getStringOption(e.pagePosition,["any","first","last","only","rest"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.desc=null;this.extras=null;this.medium=null;this.occur=null;this.area=new XFAObjectArray;this.contentArea=new XFAObjectArray;this.draw=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.field=new XFAObjectArray;this.subform=new XFAObjectArray}[br](){if(!this[Xs]){this[Xs]={numberOfUse:0};return!0}return!this.occur||-1===this.occur.max||this[Xs].numberOfUsee.oddOrEven===t&&e.pagePosition===i));if(a)return a;a=this.pageArea.children.find((e=>"any"===e.oddOrEven&&e.pagePosition===i));if(a)return a;a=this.pageArea.children.find((e=>"any"===e.oddOrEven&&"any"===e.pagePosition));return a||this.pageArea.children[0]}}class Para extends XFAObject{constructor(e){super(Nn,"para",!0);this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.lineHeight=e.lineHeight?getMeasurement(e.lineHeight,"0pt"):"";this.marginLeft=e.marginLeft?getMeasurement(e.marginLeft,"0pt"):"";this.marginRight=e.marginRight?getMeasurement(e.marginRight,"0pt"):"";this.orphans=getInteger({data:e.orphans,defaultValue:0,validate:e=>e>=0});this.preserve=e.preserve||"";this.radixOffset=e.radixOffset?getMeasurement(e.radixOffset,"0pt"):"";this.spaceAbove=e.spaceAbove?getMeasurement(e.spaceAbove,"0pt"):"";this.spaceBelow=e.spaceBelow?getMeasurement(e.spaceBelow,"0pt"):"";this.tabDefault=e.tabDefault?getMeasurement(this.tabDefault):"";this.tabStops=(e.tabStops||"").trim().split(/\s+/).map(((e,t)=>t%2==1?getMeasurement(e):e));this.textIndent=e.textIndent?getMeasurement(e.textIndent,"0pt"):"";this.use=e.use||"";this.usehref=e.usehref||"";this.vAlign=getStringOption(e.vAlign,["top","bottom","middle"]);this.widows=getInteger({data:e.widows,defaultValue:0,validate:e=>e>=0});this.hyphenation=null}[Zr](){const e=toStyle(this,"hAlign");""!==this.marginLeft&&(e.paddingLeft=measureToString(this.marginLeft));""!==this.marginRight&&(e.paddingRight=measureToString(this.marginRight));""!==this.spaceAbove&&(e.paddingTop=measureToString(this.spaceAbove));""!==this.spaceBelow&&(e.paddingBottom=measureToString(this.spaceBelow));if(""!==this.textIndent){e.textIndent=measureToString(this.textIndent);fixTextIndent(e)}this.lineHeight>0&&(e.lineHeight=measureToString(this.lineHeight));""!==this.tabDefault&&(e.tabSize=measureToString(this.tabDefault));this.tabStops.length;this.hyphenatation&&Object.assign(e,this.hyphenatation[Zr]());return e}}class PasswordEdit extends XFAObject{constructor(e){super(Nn,"passwordEdit",!0);this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.passwordChar=e.passwordChar||"*";this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}}class template_Pattern extends XFAObject{constructor(e){super(Nn,"pattern",!0);this.id=e.id||"";this.type=getStringOption(e.type,["crossHatch","crossDiagonal","diagonalLeft","diagonalRight","horizontal","vertical"]);this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){e=e?e[Zr]():"#FFFFFF";const t=this.color?this.color[Zr]():"#000000",i="repeating-linear-gradient",a=`${e},${e} 5px,${t} 5px,${t} 10px`;switch(this.type){case"crossHatch":return`${i}(to top,${a}) ${i}(to right,${a})`;case"crossDiagonal":return`${i}(45deg,${a}) ${i}(-45deg,${a})`;case"diagonalLeft":return`${i}(45deg,${a})`;case"diagonalRight":return`${i}(-45deg,${a})`;case"horizontal":return`${i}(to top,${a})`;case"vertical":return`${i}(to right,${a})`}return""}}class Picture extends StringObject{constructor(e){super(Nn,"picture");this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Proto extends XFAObject{constructor(e){super(Nn,"proto",!0);this.appearanceFilter=new XFAObjectArray;this.arc=new XFAObjectArray;this.area=new XFAObjectArray;this.assist=new XFAObjectArray;this.barcode=new XFAObjectArray;this.bindItems=new XFAObjectArray;this.bookend=new XFAObjectArray;this.boolean=new XFAObjectArray;this.border=new XFAObjectArray;this.break=new XFAObjectArray;this.breakAfter=new XFAObjectArray;this.breakBefore=new XFAObjectArray;this.button=new XFAObjectArray;this.calculate=new XFAObjectArray;this.caption=new XFAObjectArray;this.certificate=new XFAObjectArray;this.certificates=new XFAObjectArray;this.checkButton=new XFAObjectArray;this.choiceList=new XFAObjectArray;this.color=new XFAObjectArray;this.comb=new XFAObjectArray;this.connect=new XFAObjectArray;this.contentArea=new XFAObjectArray;this.corner=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.dateTimeEdit=new XFAObjectArray;this.decimal=new XFAObjectArray;this.defaultUi=new XFAObjectArray;this.desc=new XFAObjectArray;this.digestMethod=new XFAObjectArray;this.digestMethods=new XFAObjectArray;this.draw=new XFAObjectArray;this.edge=new XFAObjectArray;this.encoding=new XFAObjectArray;this.encodings=new XFAObjectArray;this.encrypt=new XFAObjectArray;this.encryptData=new XFAObjectArray;this.encryption=new XFAObjectArray;this.encryptionMethod=new XFAObjectArray;this.encryptionMethods=new XFAObjectArray;this.event=new XFAObjectArray;this.exData=new XFAObjectArray;this.exObject=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.execute=new XFAObjectArray;this.extras=new XFAObjectArray;this.field=new XFAObjectArray;this.fill=new XFAObjectArray;this.filter=new XFAObjectArray;this.float=new XFAObjectArray;this.font=new XFAObjectArray;this.format=new XFAObjectArray;this.handler=new XFAObjectArray;this.hyphenation=new XFAObjectArray;this.image=new XFAObjectArray;this.imageEdit=new XFAObjectArray;this.integer=new XFAObjectArray;this.issuers=new XFAObjectArray;this.items=new XFAObjectArray;this.keep=new XFAObjectArray;this.keyUsage=new XFAObjectArray;this.line=new XFAObjectArray;this.linear=new XFAObjectArray;this.lockDocument=new XFAObjectArray;this.manifest=new XFAObjectArray;this.margin=new XFAObjectArray;this.mdp=new XFAObjectArray;this.medium=new XFAObjectArray;this.message=new XFAObjectArray;this.numericEdit=new XFAObjectArray;this.occur=new XFAObjectArray;this.oid=new XFAObjectArray;this.oids=new XFAObjectArray;this.overflow=new XFAObjectArray;this.pageArea=new XFAObjectArray;this.pageSet=new XFAObjectArray;this.para=new XFAObjectArray;this.passwordEdit=new XFAObjectArray;this.pattern=new XFAObjectArray;this.picture=new XFAObjectArray;this.radial=new XFAObjectArray;this.reason=new XFAObjectArray;this.reasons=new XFAObjectArray;this.rectangle=new XFAObjectArray;this.ref=new XFAObjectArray;this.script=new XFAObjectArray;this.setProperty=new XFAObjectArray;this.signData=new XFAObjectArray;this.signature=new XFAObjectArray;this.signing=new XFAObjectArray;this.solid=new XFAObjectArray;this.speak=new XFAObjectArray;this.stipple=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray;this.subjectDN=new XFAObjectArray;this.subjectDNs=new XFAObjectArray;this.submit=new XFAObjectArray;this.text=new XFAObjectArray;this.textEdit=new XFAObjectArray;this.time=new XFAObjectArray;this.timeStamp=new XFAObjectArray;this.toolTip=new XFAObjectArray;this.traversal=new XFAObjectArray;this.traverse=new XFAObjectArray;this.ui=new XFAObjectArray;this.validate=new XFAObjectArray;this.value=new XFAObjectArray;this.variables=new XFAObjectArray}}class Radial extends XFAObject{constructor(e){super(Nn,"radial",!0);this.id=e.id||"";this.type=getStringOption(e.type,["toEdge","toCenter"]);this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){e=e?e[Zr]():"#FFFFFF";const t=this.color?this.color[Zr]():"#000000";return`radial-gradient(circle at center, ${"toEdge"===this.type?`${e},${t}`:`${t},${e}`})`}}class Reason extends StringObject{constructor(e){super(Nn,"reason");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Reasons extends XFAObject{constructor(e){super(Nn,"reasons",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.reason=new XFAObjectArray}}class Rectangle extends XFAObject{constructor(e){super(Nn,"rectangle",!0);this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.corner=new XFAObjectArray(4);this.edge=new XFAObjectArray(4);this.fill=null}[jr](){const e=this.edge.children.length?this.edge.children[0]:new Edge({}),t=e[Zr](),i=Object.create(null);"visible"===this.fill?.presence?Object.assign(i,this.fill[Zr]()):i.fill="transparent";i.strokeWidth=measureToString("visible"===e.presence?e.thickness:0);i.stroke=t.color;const a=(this.corner.children.length?this.corner.children[0]:new Corner({}))[Zr](),s={name:"svg",children:[{name:"rect",attributes:{xmlns:Gn,width:"100%",height:"100%",x:0,y:0,rx:a.radius,ry:a.radius,style:i}}],attributes:{xmlns:Gn,style:{overflow:"visible"},width:"100%",height:"100%"}};if(hasMargin(this[Ir]()[Ir]()))return HTMLResult.success({name:"div",attributes:{style:{display:"inline",width:"100%",height:"100%"}},children:[s]});s.attributes.style.position="absolute";return HTMLResult.success(s)}}class RefElement extends StringObject{constructor(e){super(Nn,"ref");this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Script extends StringObject{constructor(e){super(Nn,"script");this.binding=e.binding||"";this.contentType=e.contentType||"";this.id=e.id||"";this.name=e.name||"";this.runAt=getStringOption(e.runAt,["client","both","server"]);this.use=e.use||"";this.usehref=e.usehref||""}}class SetProperty extends XFAObject{constructor(e){super(Nn,"setProperty");this.connection=e.connection||"";this.ref=e.ref||"";this.target=e.target||""}}class SignData extends XFAObject{constructor(e){super(Nn,"signData",!0);this.id=e.id||"";this.operation=getStringOption(e.operation,["sign","clear","verify"]);this.ref=e.ref||"";this.target=e.target||"";this.use=e.use||"";this.usehref=e.usehref||"";this.filter=null;this.manifest=null}}class Signature extends XFAObject{constructor(e){super(Nn,"signature",!0);this.id=e.id||"";this.type=getStringOption(e.type,["PDF1.3","PDF1.6"]);this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.filter=null;this.manifest=null;this.margin=null}}class Signing extends XFAObject{constructor(e){super(Nn,"signing",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=new XFAObjectArray}}class Solid extends XFAObject{constructor(e){super(Nn,"solid",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[Zr](e){return e?e[Zr]():"#FFFFFF"}}class Speak extends StringObject{constructor(e){super(Nn,"speak");this.disable=getInteger({data:e.disable,defaultValue:0,validate:e=>1===e});this.id=e.id||"";this.priority=getStringOption(e.priority,["custom","caption","name","toolTip"]);this.rid=e.rid||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Stipple extends XFAObject{constructor(e){super(Nn,"stipple",!0);this.id=e.id||"";this.rate=getInteger({data:e.rate,defaultValue:50,validate:e=>e>=0&&e<=100});this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){const t=this.rate/100;return Util.makeHexColor(Math.round(e.value.r*(1-t)+this.value.r*t),Math.round(e.value.g*(1-t)+this.value.g*t),Math.round(e.value.b*(1-t)+this.value.b*t))}}class Subform extends XFAObject{constructor(e){super(Nn,"subform",!0);this.access=getStringOption(e.access,["open","nonInteractive","protected","readOnly"]);this.allowMacro=getInteger({data:e.allowMacro,defaultValue:0,validate:e=>1===e});this.anchorType=getStringOption(e.anchorType,["topLeft","bottomCenter","bottomLeft","bottomRight","middleCenter","middleLeft","middleRight","topCenter","topRight"]);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.columnWidths=(e.columnWidths||"").trim().split(/\s+/).map((e=>"-1"===e?-1:getMeasurement(e)));this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.layout=getStringOption(e.layout,["position","lr-tb","rl-row","rl-tb","row","table","tb"]);this.locale=e.locale||"";this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.mergeMode=getStringOption(e.mergeMode,["consumeData","matchTemplate"]);this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.restoreState=getStringOption(e.restoreState,["manual","auto"]);this.scope=getStringOption(e.scope,["name","none"]);this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.bind=null;this.bookend=null;this.border=null;this.break=null;this.calculate=null;this.desc=null;this.extras=null;this.keep=null;this.margin=null;this.occur=null;this.overflow=null;this.pageSet=null;this.para=null;this.traversal=null;this.validate=null;this.variables=null;this.area=new XFAObjectArray;this.breakAfter=new XFAObjectArray;this.breakBefore=new XFAObjectArray;this.connect=new XFAObjectArray;this.draw=new XFAObjectArray;this.event=new XFAObjectArray;this.exObject=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.field=new XFAObjectArray;this.proto=new XFAObjectArray;this.setProperty=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray}[or](){const e=this[Ir]();return e instanceof SubformSet?e[or]():e}[dr](){return!0}[wr](){return this.layout.endsWith("-tb")&&0===this[Xs].attempt&&this[Xs].numberInLine>0||this[Ir]()[wr]()}*[nr](){yield*getContainedChildren(this)}[Vs](){return flushHTML(this)}[Ls](e,t){addHTML(this,e,t)}[$s](){return getAvailableSpace(this)}[yr](){const e=this[or]();if(!e[yr]())return!1;if(void 0!==this[Xs]._isSplittable)return this[Xs]._isSplittable;if("position"===this.layout||this.layout.includes("row")){this[Xs]._isSplittable=!1;return!1}if(this.keep&&"none"!==this.keep.intact){this[Xs]._isSplittable=!1;return!1}if(e.layout?.endsWith("-tb")&&0!==e[Xs].numberInLine)return!1;this[Xs]._isSplittable=!0;return!0}[jr](e){setTabIndex(this);if(this.break){if("auto"!==this.break.after||""!==this.break.afterTarget){const e=new BreakAfter({targetType:this.break.after,target:this.break.afterTarget,startNew:this.break.startNew.toString()});e[Cr]=this[Cr];this[Hs](e);this.breakAfter.push(e)}if("auto"!==this.break.before||""!==this.break.beforeTarget){const e=new BreakBefore({targetType:this.break.before,target:this.break.beforeTarget,startNew:this.break.startNew.toString()});e[Cr]=this[Cr];this[Hs](e);this.breakBefore.push(e)}if(""!==this.break.overflowTarget){const e=new Overflow({target:this.break.overflowTarget,leader:this.break.overflowLeader,trailer:this.break.overflowTrailer});e[Cr]=this[Cr];this[Hs](e);this.overflow.push(e)}this[Hr](this.break);this.break=null}if("hidden"===this.presence||"inactive"===this.presence)return HTMLResult.EMPTY;(this.breakBefore.children.length>1||this.breakAfter.children.length>1)&&warn("XFA - Several breakBefore or breakAfter in subforms: please file a bug.");if(this.breakBefore.children.length>=1){const e=this.breakBefore.children[0];if(handleBreak(e))return HTMLResult.breakNode(e)}if(this[Xs]?.afterBreakAfter)return HTMLResult.EMPTY;fixDimensions(this);const t=[],i={id:this[Vr],class:[]};setAccess(this,i.class);this[Xs]||(this[Xs]=Object.create(null));Object.assign(this[Xs],{children:t,line:null,attributes:i,attempt:0,numberInLine:0,availableSpace:{width:Math.min(this.w||1/0,e.width),height:Math.min(this.h||1/0,e.height)},width:0,height:0,prevHeight:0,currentWidth:0});const a=this[cr](),s=a[Xs].noLayoutFailure,r=this[yr]();r||setFirstUnsplittable(this);if(!checkDimensions(this,e))return HTMLResult.FAILURE;const n=new Set(["area","draw","exclGroup","field","subform","subformSet"]);if(this.layout.includes("row")){const e=this[or]().columnWidths;if(Array.isArray(e)&&e.length>0){this[Xs].columnWidths=e;this[Xs].currentColumn=0}}const g=toStyle(this,"anchorType","dimensions","position","presence","border","margin","hAlign"),o=["xfaSubform"],c=layoutClass(this);c&&o.push(c);i.style=g;i.class=o;this.name&&(i.xfaName=this.name);if(this.overflow){const t=this.overflow[ar]();if(t.addLeader){t.addLeader=!1;handleOverflow(this,t.leader,e)}}this[Lr]();const C="lr-tb"===this.layout||"rl-tb"===this.layout,h=C?2:1;for(;this[Xs].attempt=1){const e=this.breakAfter.children[0];if(handleBreak(e)){this[Xs].afterBreakAfter=p;return HTMLResult.breakNode(e)}}delete this[Xs];return p}}class SubformSet extends XFAObject{constructor(e){super(Nn,"subformSet",!0);this.id=e.id||"";this.name=e.name||"";this.relation=getStringOption(e.relation,["ordered","choice","unordered"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.bookend=null;this.break=null;this.desc=null;this.extras=null;this.occur=null;this.overflow=null;this.breakAfter=new XFAObjectArray;this.breakBefore=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray}*[nr](){yield*getContainedChildren(this)}[or](){let e=this[Ir]();for(;!(e instanceof Subform);)e=e[Ir]();return e}[dr](){return!0}}class SubjectDN extends ContentObject{constructor(e){super(Nn,"subjectDN");this.delimiter=e.delimiter||",";this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){this[Os]=new Map(this[Os].split(this.delimiter).map((e=>{(e=e.split("=",2))[0]=e[0].trim();return e})))}}class SubjectDNs extends XFAObject{constructor(e){super(Nn,"subjectDNs",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.subjectDN=new XFAObjectArray}}class Submit extends XFAObject{constructor(e){super(Nn,"submit",!0);this.embedPDF=getInteger({data:e.embedPDF,defaultValue:0,validate:e=>1===e});this.format=getStringOption(e.format,["xdp","formdata","pdf","urlencoded","xfd","xml"]);this.id=e.id||"";this.target=e.target||"";this.textEncoding=getKeyword({data:e.textEncoding?e.textEncoding.toLowerCase():"",defaultValue:"",validate:e=>["utf-8","big-five","fontspecific","gbk","gb-18030","gb-2312","ksc-5601","none","shift-jis","ucs-2","utf-16"].includes(e)||e.match(/iso-8859-\d{2}/)});this.use=e.use||"";this.usehref=e.usehref||"";this.xdpContent=e.xdpContent||"";this.encrypt=null;this.encryptData=new XFAObjectArray;this.signData=new XFAObjectArray}}class Template extends XFAObject{constructor(e){super(Nn,"template",!0);this.baseProfile=getStringOption(e.baseProfile,["full","interactiveForms"]);this.extras=null;this.subform=new XFAObjectArray}[Zs](){0===this.subform.children.length&&warn("XFA - No subforms in template node.");this.subform.children.length>=2&&warn("XFA - Several subforms in template node: please file a bug.");this[Or]=5e3}[yr](){return!0}[vr](e,t){return e.startsWith("#")?[this[lr].get(e.slice(1))]:searchNode(this,t,e,!0,!0)}*[Wr](){if(!this.subform.children.length)return HTMLResult.success({name:"div",children:[]});this[Xs]={overflowNode:null,firstUnsplittable:null,currentContentArea:null,currentPageArea:null,noLayoutFailure:!1,pageNumber:1,pagePosition:"first",oddOrEven:"odd",blankOrNotBlank:"nonBlank",paraStack:[]};const e=this.subform.children[0];e.pageSet[vs]();const t=e.pageSet.pageArea.children,i={name:"div",children:[]};let a=null,s=null,r=null;if(e.breakBefore.children.length>=1){s=e.breakBefore.children[0];r=s.target}else if(e.subform.children.length>=1&&e.subform.children[0].breakBefore.children.length>=1){s=e.subform.children[0].breakBefore.children[0];r=s.target}else if(e.break?.beforeTarget){s=e.break;r=s.beforeTarget}else if(e.subform.children.length>=1&&e.subform.children[0].break?.beforeTarget){s=e.subform.children[0].break;r=s.beforeTarget}if(s){const e=this[vr](r,s[Ir]());if(e instanceof PageArea){a=e;s[Xs]={}}}a||(a=t[0]);a[Xs]={numberOfUse:1};const n=a[Ir]();n[Xs]={numberOfUse:1,pageIndex:n.pageArea.children.indexOf(a),pageSetIndex:0};let g,o=null,c=null,C=!0,h=0,l=0;for(;;){if(C)h=0;else{i.children.pop();if(3==++h){warn("XFA - Something goes wrong: please file a bug.");return i}}g=null;this[Xs].currentPageArea=a;const t=a[jr]().html;i.children.push(t);if(o){this[Xs].noLayoutFailure=!0;t.children.push(o[jr](a[Xs].space).html);o=null}if(c){this[Xs].noLayoutFailure=!0;t.children.push(c[jr](a[Xs].space).html);c=null}const s=a.contentArea.children,r=t.children.filter((e=>e.attributes.class.includes("xfaContentarea")));C=!1;this[Xs].firstUnsplittable=null;this[Xs].noLayoutFailure=!1;const flush=t=>{const i=e[Vs]();if(i){C||=i.children?.length>0;r[t].children.push(i)}};for(let t=l,a=s.length;t0;r[t].children.push(h.html)}else!C&&i.children.length>1&&i.children.pop();return i}if(h.isBreak()){const e=h.breakNode;flush(t);if("auto"===e.targetType)continue;if(e.leader){o=this[vr](e.leader,e[Ir]());o=o?o[0]:null}if(e.trailer){c=this[vr](e.trailer,e[Ir]());c=c?c[0]:null}if("pageArea"===e.targetType){g=e[Xs].target;t=1/0}else if(e[Xs].target){g=e[Xs].target;l=e[Xs].index+1;t=1/0}else t=e[Xs].index}else if(this[Xs].overflowNode){const e=this[Xs].overflowNode;this[Xs].overflowNode=null;const i=e[ar](),a=i.target;i.addLeader=null!==i.leader;i.addTrailer=null!==i.trailer;flush(t);const r=t;t=1/0;if(a instanceof PageArea)g=a;else if(a instanceof ContentArea){const e=s.indexOf(a);if(-1!==e)e>r?t=e-1:l=e;else{g=a[Ir]();l=g.contentArea.children.indexOf(a)}}}else flush(t)}this[Xs].pageNumber+=1;g&&(g[br]()?g[Xs].numberOfUse+=1:g=null);a=g||a[gr]();yield null}}}class Text extends ContentObject{constructor(e){super(Nn,"text");this.id=e.id||"";this.maxChars=getInteger({data:e.maxChars,defaultValue:0,validate:e=>e>=0});this.name=e.name||"";this.rid=e.rid||"";this.use=e.use||"";this.usehref=e.usehref||""}[xs](){return!0}[Nr](e){if(e[Sr]===_r.xhtml.id){this[Os]=e;return!0}warn(`XFA - Invalid content in Text: ${e[kr]}.`);return!1}[Mr](e){this[Os]instanceof XFAObject||super[Mr](e)}[Zs](){"string"==typeof this[Os]&&(this[Os]=this[Os].replaceAll("\r\n","\n"))}[ar](){return"string"==typeof this[Os]?this[Os].split(/[\u2029\u2028\n]/).reduce(((e,t)=>{t&&e.push(t);return e}),[]).join("\n"):this[Os][Pr]()}[jr](e){if("string"==typeof this[Os]){const e=valueToHtml(this[Os]).html;if(this[Os].includes("\u2029")){e.name="div";e.children=[];this[Os].split("\u2029").map((e=>e.split(/[\u2028\n]/).reduce(((e,t)=>{e.push({name:"span",value:t},{name:"br"});return e}),[]))).forEach((t=>{e.children.push({name:"p",children:t})}))}else if(/[\u2028\n]/.test(this[Os])){e.name="div";e.children=[];this[Os].split(/[\u2028\n]/).forEach((t=>{e.children.push({name:"span",value:t},{name:"br"})}))}return HTMLResult.success(e)}return this[Os][jr](e)}}class TextEdit extends XFAObject{constructor(e){super(Nn,"textEdit",!0);this.allowRichText=getInteger({data:e.allowRichText,defaultValue:0,validate:e=>1===e});this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.multiLine=getInteger({data:e.multiLine,defaultValue:"",validate:e=>0===e||1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.vScrollPolicy=getStringOption(e.vScrollPolicy,["auto","off","on"]);this.border=null;this.comb=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","font","margin");let i;const a=this[Ir]()[Ir]();""===this.multiLine&&(this.multiLine=a instanceof Draw?1:0);i=1===this.multiLine?{name:"textarea",attributes:{dataId:a[Ws]?.[Vr]||a[Vr],fieldId:a[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(a),"aria-required":!1}}:{name:"input",attributes:{type:"text",dataId:a[Ws]?.[Vr]||a[Vr],fieldId:a[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(a),"aria-required":!1}};if(isRequired(a)){i.attributes["aria-required"]=!0;i.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[i]})}}class Time extends StringObject{constructor(e){super(Nn,"time");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=this[Os].trim();this[Os]=e?new Date(e):null}[jr](e){return valueToHtml(this[Os]?this[Os].toString():"")}}class TimeStamp extends XFAObject{constructor(e){super(Nn,"timeStamp");this.id=e.id||"";this.server=e.server||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class ToolTip extends StringObject{constructor(e){super(Nn,"toolTip");this.id=e.id||"";this.rid=e.rid||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Traversal extends XFAObject{constructor(e){super(Nn,"traversal",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.traverse=new XFAObjectArray}}class Traverse extends XFAObject{constructor(e){super(Nn,"traverse",!0);this.id=e.id||"";this.operation=getStringOption(e.operation,["next","back","down","first","left","right","up"]);this.ref=e.ref||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.script=null}get name(){return this.operation}[Dr](){return!1}}class Ui extends XFAObject{constructor(e){super(Nn,"ui",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.picture=null;this.barcode=null;this.button=null;this.checkButton=null;this.choiceList=null;this.dateTimeEdit=null;this.defaultUi=null;this.imageEdit=null;this.numericEdit=null;this.passwordEdit=null;this.signature=null;this.textEdit=null}[ar](){if(void 0===this[Xs]){for(const e of Object.getOwnPropertyNames(this)){if("extras"===e||"picture"===e)continue;const t=this[e];if(t instanceof XFAObject){this[Xs]=t;return t}}this[Xs]=null}return this[Xs]}[jr](e){const t=this[ar]();return t?t[jr](e):HTMLResult.EMPTY}}class Validate extends XFAObject{constructor(e){super(Nn,"validate",!0);this.formatTest=getStringOption(e.formatTest,["warning","disabled","error"]);this.id=e.id||"";this.nullTest=getStringOption(e.nullTest,["disabled","error","warning"]);this.scriptTest=getStringOption(e.scriptTest,["error","disabled","warning"]);this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.message=null;this.picture=null;this.script=null}}class Value extends XFAObject{constructor(e){super(Nn,"value",!0);this.id=e.id||"";this.override=getInteger({data:e.override,defaultValue:0,validate:e=>1===e});this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.arc=null;this.boolean=null;this.date=null;this.dateTime=null;this.decimal=null;this.exData=null;this.float=null;this.image=null;this.integer=null;this.line=null;this.rectangle=null;this.text=null;this.time=null}[qr](e){const t=this[Ir]();if(t instanceof Field&&t.ui?.imageEdit){if(!this.image){this.image=new Image({});this[Hs](this.image)}this.image[Os]=e[Os];return}const i=e[kr];if(null===this[i]){for(const e of Object.getOwnPropertyNames(this)){const t=this[e];if(t instanceof XFAObject){this[e]=null;this[Hr](t)}}this[e[kr]]=e;this[Hs](e)}else this[i][Os]=e[Os]}[Pr](){if(this.exData)return"string"==typeof this.exData[Os]?this.exData[Os].trim():this.exData[Os][Pr]().trim();for(const e of Object.getOwnPropertyNames(this)){if("image"===e)continue;const t=this[e];if(t instanceof XFAObject)return(t[Os]||"").toString().trim()}return null}[jr](e){for(const t of Object.getOwnPropertyNames(this)){const i=this[t];if(i instanceof XFAObject)return i[jr](e)}return HTMLResult.EMPTY}}class Variables extends XFAObject{constructor(e){super(Nn,"variables",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.manifest=new XFAObjectArray;this.script=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}[Dr](){return!0}}class TemplateNamespace{static[zr](e,t){if(TemplateNamespace.hasOwnProperty(e)){const i=TemplateNamespace[e](t);i[Tr](t);return i}}static appearanceFilter(e){return new AppearanceFilter(e)}static arc(e){return new Arc(e)}static area(e){return new Area(e)}static assist(e){return new Assist(e)}static barcode(e){return new Barcode(e)}static bind(e){return new Bind(e)}static bindItems(e){return new BindItems(e)}static bookend(e){return new Bookend(e)}static boolean(e){return new BooleanElement(e)}static border(e){return new Border(e)}static break(e){return new Break(e)}static breakAfter(e){return new BreakAfter(e)}static breakBefore(e){return new BreakBefore(e)}static button(e){return new Button(e)}static calculate(e){return new Calculate(e)}static caption(e){return new Caption(e)}static certificate(e){return new Certificate(e)}static certificates(e){return new Certificates(e)}static checkButton(e){return new CheckButton(e)}static choiceList(e){return new ChoiceList(e)}static color(e){return new Color(e)}static comb(e){return new Comb(e)}static connect(e){return new Connect(e)}static contentArea(e){return new ContentArea(e)}static corner(e){return new Corner(e)}static date(e){return new DateElement(e)}static dateTime(e){return new DateTime(e)}static dateTimeEdit(e){return new DateTimeEdit(e)}static decimal(e){return new Decimal(e)}static defaultUi(e){return new DefaultUi(e)}static desc(e){return new Desc(e)}static digestMethod(e){return new DigestMethod(e)}static digestMethods(e){return new DigestMethods(e)}static draw(e){return new Draw(e)}static edge(e){return new Edge(e)}static encoding(e){return new Encoding(e)}static encodings(e){return new Encodings(e)}static encrypt(e){return new Encrypt(e)}static encryptData(e){return new EncryptData(e)}static encryption(e){return new Encryption(e)}static encryptionMethod(e){return new EncryptionMethod(e)}static encryptionMethods(e){return new EncryptionMethods(e)}static event(e){return new Event(e)}static exData(e){return new ExData(e)}static exObject(e){return new ExObject(e)}static exclGroup(e){return new ExclGroup(e)}static execute(e){return new Execute(e)}static extras(e){return new Extras(e)}static field(e){return new Field(e)}static fill(e){return new Fill(e)}static filter(e){return new Filter(e)}static float(e){return new Float(e)}static font(e){return new template_Font(e)}static format(e){return new Format(e)}static handler(e){return new Handler(e)}static hyphenation(e){return new Hyphenation(e)}static image(e){return new Image(e)}static imageEdit(e){return new ImageEdit(e)}static integer(e){return new Integer(e)}static issuers(e){return new Issuers(e)}static items(e){return new Items(e)}static keep(e){return new Keep(e)}static keyUsage(e){return new KeyUsage(e)}static line(e){return new Line(e)}static linear(e){return new Linear(e)}static lockDocument(e){return new LockDocument(e)}static manifest(e){return new Manifest(e)}static margin(e){return new Margin(e)}static mdp(e){return new Mdp(e)}static medium(e){return new Medium(e)}static message(e){return new Message(e)}static numericEdit(e){return new NumericEdit(e)}static occur(e){return new Occur(e)}static oid(e){return new Oid(e)}static oids(e){return new Oids(e)}static overflow(e){return new Overflow(e)}static pageArea(e){return new PageArea(e)}static pageSet(e){return new PageSet(e)}static para(e){return new Para(e)}static passwordEdit(e){return new PasswordEdit(e)}static pattern(e){return new template_Pattern(e)}static picture(e){return new Picture(e)}static proto(e){return new Proto(e)}static radial(e){return new Radial(e)}static reason(e){return new Reason(e)}static reasons(e){return new Reasons(e)}static rectangle(e){return new Rectangle(e)}static ref(e){return new RefElement(e)}static script(e){return new Script(e)}static setProperty(e){return new SetProperty(e)}static signData(e){return new SignData(e)}static signature(e){return new Signature(e)}static signing(e){return new Signing(e)}static solid(e){return new Solid(e)}static speak(e){return new Speak(e)}static stipple(e){return new Stipple(e)}static subform(e){return new Subform(e)}static subformSet(e){return new SubformSet(e)}static subjectDN(e){return new SubjectDN(e)}static subjectDNs(e){return new SubjectDNs(e)}static submit(e){return new Submit(e)}static template(e){return new Template(e)}static text(e){return new Text(e)}static textEdit(e){return new TextEdit(e)}static time(e){return new Time(e)}static timeStamp(e){return new TimeStamp(e)}static toolTip(e){return new ToolTip(e)}static traversal(e){return new Traversal(e)}static traverse(e){return new Traverse(e)}static ui(e){return new Ui(e)}static validate(e){return new Validate(e)}static value(e){return new Value(e)}static variables(e){return new Variables(e)}}const Ln=_r.datasets.id;function createText(e){const t=new Text({});t[Os]=e;return t}class Binder{constructor(e){this.root=e;this.datasets=e.datasets;this.data=e.datasets?.data||new XmlObject(_r.datasets.id,"data");this.emptyMerge=0===this.data[rr]().length;this.root.form=this.form=e.template[Ts]()}_isConsumeData(){return!this.emptyMerge&&this._mergeMode}_isMatchTemplate(){return!this._isConsumeData()}bind(){this._bindElement(this.form,this.data);return this.form}getData(){return this.data}_bindValue(e,t,i){e[Ws]=t;if(e[hr]())if(t[fr]()){const i=t[ir]();e[qr](createText(i))}else if(e instanceof Field&&"multiSelect"===e.ui?.choiceList?.open){const i=t[rr]().map((e=>e[Os].trim())).join("\n");e[qr](createText(i))}else this._isConsumeData()&&warn("XFA - Nodes haven't the same type.");else!t[fr]()||this._isMatchTemplate()?this._bindElement(e,t):warn("XFA - Nodes haven't the same type.")}_findDataByNameToConsume(e,t,i,a){if(!e)return null;let s,r;for(let a=0;a<3;a++){s=i[sr](e,!1,!0);for(;;){r=s.next().value;if(!r)break;if(t===r[fr]())return r}if(i[Sr]===_r.datasets.id&&"data"===i[kr])break;i=i[Ir]()}if(!a)return null;s=this.data[sr](e,!0,!1);r=s.next().value;if(r)return r;s=this.data[zs](e,!0);r=s.next().value;return r?.[fr]()?r:null}_setProperties(e,t){if(e.hasOwnProperty("setProperty"))for(const{ref:i,target:a,connection:s}of e.setProperty.children){if(s)continue;if(!i)continue;const r=searchNode(this.root,t,i,!1,!1);if(!r){warn(`XFA - Invalid reference: ${i}.`);continue}const[n]=r;if(!n[pr](this.data)){warn("XFA - Invalid node: must be a data node.");continue}const g=searchNode(this.root,e,a,!1,!1);if(!g){warn(`XFA - Invalid target: ${a}.`);continue}const[o]=g;if(!o[pr](e)){warn("XFA - Invalid target: must be a property or subproperty.");continue}const c=o[Ir]();if(o instanceof SetProperty||c instanceof SetProperty){warn("XFA - Invalid target: cannot be a setProperty or one of its properties.");continue}if(o instanceof BindItems||c instanceof BindItems){warn("XFA - Invalid target: cannot be a bindItems or one of its properties.");continue}const C=n[Pr](),h=o[kr];if(o instanceof XFAAttribute){const e=Object.create(null);e[h]=C;const t=Reflect.construct(Object.getPrototypeOf(c).constructor,[e]);c[h]=t[h]}else if(o.hasOwnProperty(Os)){o[Ws]=n;o[Os]=C;o[Zs]()}else warn("XFA - Invalid node to use in setProperty")}}_bindItems(e,t){if(!e.hasOwnProperty("items")||!e.hasOwnProperty("bindItems")||e.bindItems.isEmpty())return;for(const t of e.items.children)e[Hr](t);e.items.clear();const i=new Items({}),a=new Items({});e[Hs](i);e.items.push(i);e[Hs](a);e.items.push(a);for(const{ref:s,labelRef:r,valueRef:n,connection:g}of e.bindItems.children){if(g)continue;if(!s)continue;const e=searchNode(this.root,t,s,!1,!1);if(e)for(const t of e){if(!t[pr](this.datasets)){warn(`XFA - Invalid ref (${s}): must be a datasets child.`);continue}const e=searchNode(this.root,t,r,!0,!1);if(!e){warn(`XFA - Invalid label: ${r}.`);continue}const[g]=e;if(!g[pr](this.datasets)){warn("XFA - Invalid label: must be a datasets child.");continue}const o=searchNode(this.root,t,n,!0,!1);if(!o){warn(`XFA - Invalid value: ${n}.`);continue}const[c]=o;if(!c[pr](this.datasets)){warn("XFA - Invalid value: must be a datasets child.");continue}const C=createText(g[Pr]()),h=createText(c[Pr]());i[Hs](C);i.text.push(C);a[Hs](h);a.text.push(h)}else warn(`XFA - Invalid reference: ${s}.`)}}_bindOccurrences(e,t,i){let a;if(t.length>1){a=e[Ts]();a[Hr](a.occur);a.occur=null}this._bindValue(e,t[0],i);this._setProperties(e,t[0]);this._bindItems(e,t[0]);if(1===t.length)return;const s=e[Ir](),r=e[kr],n=s[Qr](e);for(let e=1,g=t.length;et.name===e.name)).length:i[a].children.length;const r=i[Qr](e)+1,n=t.initial-s;if(n){const t=e[Ts]();t[Hr](t.occur);t.occur=null;i[a].push(t);i[Er](r,t);for(let e=1;e0)this._bindOccurrences(a,[e[0]],null);else if(this.emptyMerge){const e=t[Sr]===Ln?-1:t[Sr],i=a[Ws]=new XmlObject(e,a.name||"root");t[Hs](i);this._bindElement(a,i)}continue}if(!a[dr]())continue;let e=!1,s=null,r=null,n=null;if(a.bind){switch(a.bind.match){case"none":this._setAndBind(a,t);continue;case"global":e=!0;break;case"dataRef":if(!a.bind.ref){warn(`XFA - ref is empty in node ${a[kr]}.`);this._setAndBind(a,t);continue}r=a.bind.ref}a.bind.picture&&(s=a.bind.picture[Os])}const[g,o]=this._getOccurInfo(a);if(r){n=searchNode(this.root,t,r,!0,!1);if(null===n){n=createDataNode(this.data,t,r);if(!n)continue;this._isConsumeData()&&(n[qs]=!0);this._setAndBind(a,n);continue}this._isConsumeData()&&(n=n.filter((e=>!e[qs])));n.length>o?n=n.slice(0,o):0===n.length&&(n=null);n&&this._isConsumeData()&&n.forEach((e=>{e[qs]=!0}))}else{if(!a.name){this._setAndBind(a,t);continue}if(this._isConsumeData()){const i=[];for(;i.length0?i:null}else{n=t[sr](a.name,!1,this.emptyMerge).next().value;if(!n){if(0===g){i.push(a);continue}const e=t[Sr]===Ln?-1:t[Sr];n=a[Ws]=new XmlObject(e,a.name);this.emptyMerge&&(n[qs]=!0);t[Hs](n);this._setAndBind(a,n);continue}this.emptyMerge&&(n[qs]=!0);n=[n]}}n?this._bindOccurrences(a,n,s):g>0?this._setAndBind(a,t):i.push(a)}i.forEach((e=>e[Ir]()[Hr](e)))}}class DataHandler{constructor(e,t){this.data=t;this.dataset=e.datasets||null}serialize(e){const t=[[-1,this.data[rr]()]];for(;t.length>0;){const i=t.at(-1),[a,s]=i;if(a+1===s.length){t.pop();continue}const r=s[++i[0]],n=e.get(r[Vr]);if(n)r[qr](n);else{const t=r[_s]();for(const i of t.values()){const t=e.get(i[Vr]);if(t){i[qr](t);break}}}const g=r[rr]();g.length>0&&t.push([-1,g])}const i=[''];if(this.dataset)for(const e of this.dataset[rr]())"data"!==e[kr]&&e[Xr](i);this.data[Xr](i);i.push("");return i.join("")}}const Hn=_r.config.id;class Acrobat extends XFAObject{constructor(e){super(Hn,"acrobat",!0);this.acrobat7=null;this.autoSave=null;this.common=null;this.validate=null;this.validateApprovalSignatures=null;this.submitUrl=new XFAObjectArray}}class Acrobat7 extends XFAObject{constructor(e){super(Hn,"acrobat7",!0);this.dynamicRender=null}}class ADBE_JSConsole extends OptionObject{constructor(e){super(Hn,"ADBE_JSConsole",["delegate","Enable","Disable"])}}class ADBE_JSDebugger extends OptionObject{constructor(e){super(Hn,"ADBE_JSDebugger",["delegate","Enable","Disable"])}}class AddSilentPrint extends Option01{constructor(e){super(Hn,"addSilentPrint")}}class AddViewerPreferences extends Option01{constructor(e){super(Hn,"addViewerPreferences")}}class AdjustData extends Option10{constructor(e){super(Hn,"adjustData")}}class AdobeExtensionLevel extends IntegerObject{constructor(e){super(Hn,"adobeExtensionLevel",0,(e=>e>=1&&e<=8))}}class Agent extends XFAObject{constructor(e){super(Hn,"agent",!0);this.name=e.name?e.name.trim():"";this.common=new XFAObjectArray}}class AlwaysEmbed extends ContentObject{constructor(e){super(Hn,"alwaysEmbed")}}class Amd extends StringObject{constructor(e){super(Hn,"amd")}}class config_Area extends XFAObject{constructor(e){super(Hn,"area");this.level=getInteger({data:e.level,defaultValue:0,validate:e=>e>=1&&e<=3});this.name=getStringOption(e.name,["","barcode","coreinit","deviceDriver","font","general","layout","merge","script","signature","sourceSet","templateCache"])}}class Attributes extends OptionObject{constructor(e){super(Hn,"attributes",["preserve","delegate","ignore"])}}class AutoSave extends OptionObject{constructor(e){super(Hn,"autoSave",["disabled","enabled"])}}class Base extends StringObject{constructor(e){super(Hn,"base")}}class BatchOutput extends XFAObject{constructor(e){super(Hn,"batchOutput");this.format=getStringOption(e.format,["none","concat","zip","zipCompress"])}}class BehaviorOverride extends ContentObject{constructor(e){super(Hn,"behaviorOverride")}[Zs](){this[Os]=new Map(this[Os].trim().split(/\s+/).filter((e=>e.includes(":"))).map((e=>e.split(":",2))))}}class Cache extends XFAObject{constructor(e){super(Hn,"cache",!0);this.templateCache=null}}class Change extends Option01{constructor(e){super(Hn,"change")}}class Common extends XFAObject{constructor(e){super(Hn,"common",!0);this.data=null;this.locale=null;this.localeSet=null;this.messaging=null;this.suppressBanner=null;this.template=null;this.validationMessaging=null;this.versionControl=null;this.log=new XFAObjectArray}}class Compress extends XFAObject{constructor(e){super(Hn,"compress");this.scope=getStringOption(e.scope,["imageOnly","document"])}}class CompressLogicalStructure extends Option01{constructor(e){super(Hn,"compressLogicalStructure")}}class CompressObjectStream extends Option10{constructor(e){super(Hn,"compressObjectStream")}}class Compression extends XFAObject{constructor(e){super(Hn,"compression",!0);this.compressLogicalStructure=null;this.compressObjectStream=null;this.level=null;this.type=null}}class Config extends XFAObject{constructor(e){super(Hn,"config",!0);this.acrobat=null;this.present=null;this.trace=null;this.agent=new XFAObjectArray}}class Conformance extends OptionObject{constructor(e){super(Hn,"conformance",["A","B"])}}class ContentCopy extends Option01{constructor(e){super(Hn,"contentCopy")}}class Copies extends IntegerObject{constructor(e){super(Hn,"copies",1,(e=>e>=1))}}class Creator extends StringObject{constructor(e){super(Hn,"creator")}}class CurrentPage extends IntegerObject{constructor(e){super(Hn,"currentPage",0,(e=>e>=0))}}class Data extends XFAObject{constructor(e){super(Hn,"data",!0);this.adjustData=null;this.attributes=null;this.incrementalLoad=null;this.outputXSL=null;this.range=null;this.record=null;this.startNode=null;this.uri=null;this.window=null;this.xsl=null;this.excludeNS=new XFAObjectArray;this.transform=new XFAObjectArray}}class Debug extends XFAObject{constructor(e){super(Hn,"debug",!0);this.uri=null}}class DefaultTypeface extends ContentObject{constructor(e){super(Hn,"defaultTypeface");this.writingScript=getStringOption(e.writingScript,["*","Arabic","Cyrillic","EastEuropeanRoman","Greek","Hebrew","Japanese","Korean","Roman","SimplifiedChinese","Thai","TraditionalChinese","Vietnamese"])}}class Destination extends OptionObject{constructor(e){super(Hn,"destination",["pdf","pcl","ps","webClient","zpl"])}}class DocumentAssembly extends Option01{constructor(e){super(Hn,"documentAssembly")}}class Driver extends XFAObject{constructor(e){super(Hn,"driver",!0);this.name=e.name?e.name.trim():"";this.fontInfo=null;this.xdc=null}}class DuplexOption extends OptionObject{constructor(e){super(Hn,"duplexOption",["simplex","duplexFlipLongEdge","duplexFlipShortEdge"])}}class DynamicRender extends OptionObject{constructor(e){super(Hn,"dynamicRender",["forbidden","required"])}}class Embed extends Option01{constructor(e){super(Hn,"embed")}}class config_Encrypt extends Option01{constructor(e){super(Hn,"encrypt")}}class config_Encryption extends XFAObject{constructor(e){super(Hn,"encryption",!0);this.encrypt=null;this.encryptionLevel=null;this.permissions=null}}class EncryptionLevel extends OptionObject{constructor(e){super(Hn,"encryptionLevel",["40bit","128bit"])}}class Enforce extends StringObject{constructor(e){super(Hn,"enforce")}}class Equate extends XFAObject{constructor(e){super(Hn,"equate");this.force=getInteger({data:e.force,defaultValue:1,validate:e=>0===e});this.from=e.from||"";this.to=e.to||""}}class EquateRange extends XFAObject{constructor(e){super(Hn,"equateRange");this.from=e.from||"";this.to=e.to||"";this._unicodeRange=e.unicodeRange||""}get unicodeRange(){const e=[],t=/U\+([0-9a-fA-F]+)/,i=this._unicodeRange;for(let a of i.split(",").map((e=>e.trim())).filter((e=>!!e))){a=a.split("-",2).map((e=>{const i=e.match(t);return i?parseInt(i[1],16):0}));1===a.length&&a.push(a[0]);e.push(a)}return shadow(this,"unicodeRange",e)}}class Exclude extends ContentObject{constructor(e){super(Hn,"exclude")}[Zs](){this[Os]=this[Os].trim().split(/\s+/).filter((e=>e&&["calculate","close","enter","exit","initialize","ready","validate"].includes(e)))}}class ExcludeNS extends StringObject{constructor(e){super(Hn,"excludeNS")}}class FlipLabel extends OptionObject{constructor(e){super(Hn,"flipLabel",["usePrinterSetting","on","off"])}}class config_FontInfo extends XFAObject{constructor(e){super(Hn,"fontInfo",!0);this.embed=null;this.map=null;this.subsetBelow=null;this.alwaysEmbed=new XFAObjectArray;this.defaultTypeface=new XFAObjectArray;this.neverEmbed=new XFAObjectArray}}class FormFieldFilling extends Option01{constructor(e){super(Hn,"formFieldFilling")}}class GroupParent extends StringObject{constructor(e){super(Hn,"groupParent")}}class IfEmpty extends OptionObject{constructor(e){super(Hn,"ifEmpty",["dataValue","dataGroup","ignore","remove"])}}class IncludeXDPContent extends StringObject{constructor(e){super(Hn,"includeXDPContent")}}class IncrementalLoad extends OptionObject{constructor(e){super(Hn,"incrementalLoad",["none","forwardOnly"])}}class IncrementalMerge extends Option01{constructor(e){super(Hn,"incrementalMerge")}}class Interactive extends Option01{constructor(e){super(Hn,"interactive")}}class Jog extends OptionObject{constructor(e){super(Hn,"jog",["usePrinterSetting","none","pageSet"])}}class LabelPrinter extends XFAObject{constructor(e){super(Hn,"labelPrinter",!0);this.name=getStringOption(e.name,["zpl","dpl","ipl","tcpl"]);this.batchOutput=null;this.flipLabel=null;this.fontInfo=null;this.xdc=null}}class Layout extends OptionObject{constructor(e){super(Hn,"layout",["paginate","panel"])}}class Level extends IntegerObject{constructor(e){super(Hn,"level",0,(e=>e>0))}}class Linearized extends Option01{constructor(e){super(Hn,"linearized")}}class Locale extends StringObject{constructor(e){super(Hn,"locale")}}class LocaleSet extends StringObject{constructor(e){super(Hn,"localeSet")}}class Log extends XFAObject{constructor(e){super(Hn,"log",!0);this.mode=null;this.threshold=null;this.to=null;this.uri=null}}class MapElement extends XFAObject{constructor(e){super(Hn,"map",!0);this.equate=new XFAObjectArray;this.equateRange=new XFAObjectArray}}class MediumInfo extends XFAObject{constructor(e){super(Hn,"mediumInfo",!0);this.map=null}}class config_Message extends XFAObject{constructor(e){super(Hn,"message",!0);this.msgId=null;this.severity=null}}class Messaging extends XFAObject{constructor(e){super(Hn,"messaging",!0);this.message=new XFAObjectArray}}class Mode extends OptionObject{constructor(e){super(Hn,"mode",["append","overwrite"])}}class ModifyAnnots extends Option01{constructor(e){super(Hn,"modifyAnnots")}}class MsgId extends IntegerObject{constructor(e){super(Hn,"msgId",1,(e=>e>=1))}}class NameAttr extends StringObject{constructor(e){super(Hn,"nameAttr")}}class NeverEmbed extends ContentObject{constructor(e){super(Hn,"neverEmbed")}}class NumberOfCopies extends IntegerObject{constructor(e){super(Hn,"numberOfCopies",null,(e=>e>=2&&e<=5))}}class OpenAction extends XFAObject{constructor(e){super(Hn,"openAction",!0);this.destination=null}}class Output extends XFAObject{constructor(e){super(Hn,"output",!0);this.to=null;this.type=null;this.uri=null}}class OutputBin extends StringObject{constructor(e){super(Hn,"outputBin")}}class OutputXSL extends XFAObject{constructor(e){super(Hn,"outputXSL",!0);this.uri=null}}class Overprint extends OptionObject{constructor(e){super(Hn,"overprint",["none","both","draw","field"])}}class Packets extends StringObject{constructor(e){super(Hn,"packets")}[Zs](){"*"!==this[Os]&&(this[Os]=this[Os].trim().split(/\s+/).filter((e=>["config","datasets","template","xfdf","xslt"].includes(e))))}}class PageOffset extends XFAObject{constructor(e){super(Hn,"pageOffset");this.x=getInteger({data:e.x,defaultValue:"useXDCSetting",validate:e=>!0});this.y=getInteger({data:e.y,defaultValue:"useXDCSetting",validate:e=>!0})}}class PageRange extends StringObject{constructor(e){super(Hn,"pageRange")}[Zs](){const e=this[Os].trim().split(/\s+/).map((e=>parseInt(e,10))),t=[];for(let i=0,a=e.length;i!1))}}class Pcl extends XFAObject{constructor(e){super(Hn,"pcl",!0);this.name=e.name||"";this.batchOutput=null;this.fontInfo=null;this.jog=null;this.mediumInfo=null;this.outputBin=null;this.pageOffset=null;this.staple=null;this.xdc=null}}class Pdf extends XFAObject{constructor(e){super(Hn,"pdf",!0);this.name=e.name||"";this.adobeExtensionLevel=null;this.batchOutput=null;this.compression=null;this.creator=null;this.encryption=null;this.fontInfo=null;this.interactive=null;this.linearized=null;this.openAction=null;this.pdfa=null;this.producer=null;this.renderPolicy=null;this.scriptModel=null;this.silentPrint=null;this.submitFormat=null;this.tagged=null;this.version=null;this.viewerPreferences=null;this.xdc=null}}class Pdfa extends XFAObject{constructor(e){super(Hn,"pdfa",!0);this.amd=null;this.conformance=null;this.includeXDPContent=null;this.part=null}}class Permissions extends XFAObject{constructor(e){super(Hn,"permissions",!0);this.accessibleContent=null;this.change=null;this.contentCopy=null;this.documentAssembly=null;this.formFieldFilling=null;this.modifyAnnots=null;this.plaintextMetadata=null;this.print=null;this.printHighQuality=null}}class PickTrayByPDFSize extends Option01{constructor(e){super(Hn,"pickTrayByPDFSize")}}class config_Picture extends StringObject{constructor(e){super(Hn,"picture")}}class PlaintextMetadata extends Option01{constructor(e){super(Hn,"plaintextMetadata")}}class Presence extends OptionObject{constructor(e){super(Hn,"presence",["preserve","dissolve","dissolveStructure","ignore","remove"])}}class Present extends XFAObject{constructor(e){super(Hn,"present",!0);this.behaviorOverride=null;this.cache=null;this.common=null;this.copies=null;this.destination=null;this.incrementalMerge=null;this.layout=null;this.output=null;this.overprint=null;this.pagination=null;this.paginationOverride=null;this.script=null;this.validate=null;this.xdp=null;this.driver=new XFAObjectArray;this.labelPrinter=new XFAObjectArray;this.pcl=new XFAObjectArray;this.pdf=new XFAObjectArray;this.ps=new XFAObjectArray;this.submitUrl=new XFAObjectArray;this.webClient=new XFAObjectArray;this.zpl=new XFAObjectArray}}class Print extends Option01{constructor(e){super(Hn,"print")}}class PrintHighQuality extends Option01{constructor(e){super(Hn,"printHighQuality")}}class PrintScaling extends OptionObject{constructor(e){super(Hn,"printScaling",["appdefault","noScaling"])}}class PrinterName extends StringObject{constructor(e){super(Hn,"printerName")}}class Producer extends StringObject{constructor(e){super(Hn,"producer")}}class Ps extends XFAObject{constructor(e){super(Hn,"ps",!0);this.name=e.name||"";this.batchOutput=null;this.fontInfo=null;this.jog=null;this.mediumInfo=null;this.outputBin=null;this.staple=null;this.xdc=null}}class Range extends ContentObject{constructor(e){super(Hn,"range")}[Zs](){this[Os]=this[Os].trim().split(/\s*,\s*/,2).map((e=>e.split("-").map((e=>parseInt(e.trim(),10))))).filter((e=>e.every((e=>!isNaN(e))))).map((e=>{1===e.length&&e.push(e[0]);return e}))}}class Record extends ContentObject{constructor(e){super(Hn,"record")}[Zs](){this[Os]=this[Os].trim();const e=parseInt(this[Os],10);!isNaN(e)&&e>=0&&(this[Os]=e)}}class Relevant extends ContentObject{constructor(e){super(Hn,"relevant")}[Zs](){this[Os]=this[Os].trim().split(/\s+/)}}class Rename extends ContentObject{constructor(e){super(Hn,"rename")}[Zs](){this[Os]=this[Os].trim();(this[Os].toLowerCase().startsWith("xml")||new RegExp("[\\p{L}_][\\p{L}\\d._\\p{M}-]*","u").test(this[Os]))&&warn("XFA - Rename: invalid XFA name")}}class RenderPolicy extends OptionObject{constructor(e){super(Hn,"renderPolicy",["server","client"])}}class RunScripts extends OptionObject{constructor(e){super(Hn,"runScripts",["both","client","none","server"])}}class config_Script extends XFAObject{constructor(e){super(Hn,"script",!0);this.currentPage=null;this.exclude=null;this.runScripts=null}}class ScriptModel extends OptionObject{constructor(e){super(Hn,"scriptModel",["XFA","none"])}}class Severity extends OptionObject{constructor(e){super(Hn,"severity",["ignore","error","information","trace","warning"])}}class SilentPrint extends XFAObject{constructor(e){super(Hn,"silentPrint",!0);this.addSilentPrint=null;this.printerName=null}}class Staple extends XFAObject{constructor(e){super(Hn,"staple");this.mode=getStringOption(e.mode,["usePrinterSetting","on","off"])}}class StartNode extends StringObject{constructor(e){super(Hn,"startNode")}}class StartPage extends IntegerObject{constructor(e){super(Hn,"startPage",0,(e=>!0))}}class SubmitFormat extends OptionObject{constructor(e){super(Hn,"submitFormat",["html","delegate","fdf","xml","pdf"])}}class SubmitUrl extends StringObject{constructor(e){super(Hn,"submitUrl")}}class SubsetBelow extends IntegerObject{constructor(e){super(Hn,"subsetBelow",100,(e=>e>=0&&e<=100))}}class SuppressBanner extends Option01{constructor(e){super(Hn,"suppressBanner")}}class Tagged extends Option01{constructor(e){super(Hn,"tagged")}}class config_Template extends XFAObject{constructor(e){super(Hn,"template",!0);this.base=null;this.relevant=null;this.startPage=null;this.uri=null;this.xsl=null}}class Threshold extends OptionObject{constructor(e){super(Hn,"threshold",["trace","error","information","warning"])}}class To extends OptionObject{constructor(e){super(Hn,"to",["null","memory","stderr","stdout","system","uri"])}}class TemplateCache extends XFAObject{constructor(e){super(Hn,"templateCache");this.maxEntries=getInteger({data:e.maxEntries,defaultValue:5,validate:e=>e>=0})}}class Trace extends XFAObject{constructor(e){super(Hn,"trace",!0);this.area=new XFAObjectArray}}class Transform extends XFAObject{constructor(e){super(Hn,"transform",!0);this.groupParent=null;this.ifEmpty=null;this.nameAttr=null;this.picture=null;this.presence=null;this.rename=null;this.whitespace=null}}class Type extends OptionObject{constructor(e){super(Hn,"type",["none","ascii85","asciiHex","ccittfax","flate","lzw","runLength","native","xdp","mergedXDP"])}}class Uri extends StringObject{constructor(e){super(Hn,"uri")}}class config_Validate extends OptionObject{constructor(e){super(Hn,"validate",["preSubmit","prePrint","preExecute","preSave"])}}class ValidateApprovalSignatures extends ContentObject{constructor(e){super(Hn,"validateApprovalSignatures")}[Zs](){this[Os]=this[Os].trim().split(/\s+/).filter((e=>["docReady","postSign"].includes(e)))}}class ValidationMessaging extends OptionObject{constructor(e){super(Hn,"validationMessaging",["allMessagesIndividually","allMessagesTogether","firstMessageOnly","noMessages"])}}class Version extends OptionObject{constructor(e){super(Hn,"version",["1.7","1.6","1.5","1.4","1.3","1.2"])}}class VersionControl extends XFAObject{constructor(e){super(Hn,"VersionControl");this.outputBelow=getStringOption(e.outputBelow,["warn","error","update"]);this.sourceAbove=getStringOption(e.sourceAbove,["warn","error"]);this.sourceBelow=getStringOption(e.sourceBelow,["update","maintain"])}}class ViewerPreferences extends XFAObject{constructor(e){super(Hn,"viewerPreferences",!0);this.ADBE_JSConsole=null;this.ADBE_JSDebugger=null;this.addViewerPreferences=null;this.duplexOption=null;this.enforce=null;this.numberOfCopies=null;this.pageRange=null;this.pickTrayByPDFSize=null;this.printScaling=null}}class WebClient extends XFAObject{constructor(e){super(Hn,"webClient",!0);this.name=e.name?e.name.trim():"";this.fontInfo=null;this.xdc=null}}class Whitespace extends OptionObject{constructor(e){super(Hn,"whitespace",["preserve","ltrim","normalize","rtrim","trim"])}}class Window extends ContentObject{constructor(e){super(Hn,"window")}[Zs](){const e=this[Os].trim().split(/\s*,\s*/,2).map((e=>parseInt(e,10)));if(e.some((e=>isNaN(e))))this[Os]=[0,0];else{1===e.length&&e.push(e[0]);this[Os]=e}}}class Xdc extends XFAObject{constructor(e){super(Hn,"xdc",!0);this.uri=new XFAObjectArray;this.xsl=new XFAObjectArray}}class Xdp extends XFAObject{constructor(e){super(Hn,"xdp",!0);this.packets=null}}class Xsl extends XFAObject{constructor(e){super(Hn,"xsl",!0);this.debug=null;this.uri=null}}class Zpl extends XFAObject{constructor(e){super(Hn,"zpl",!0);this.name=e.name?e.name.trim():"";this.batchOutput=null;this.flipLabel=null;this.fontInfo=null;this.xdc=null}}class ConfigNamespace{static[zr](e,t){if(ConfigNamespace.hasOwnProperty(e))return ConfigNamespace[e](t)}static acrobat(e){return new Acrobat(e)}static acrobat7(e){return new Acrobat7(e)}static ADBE_JSConsole(e){return new ADBE_JSConsole(e)}static ADBE_JSDebugger(e){return new ADBE_JSDebugger(e)}static addSilentPrint(e){return new AddSilentPrint(e)}static addViewerPreferences(e){return new AddViewerPreferences(e)}static adjustData(e){return new AdjustData(e)}static adobeExtensionLevel(e){return new AdobeExtensionLevel(e)}static agent(e){return new Agent(e)}static alwaysEmbed(e){return new AlwaysEmbed(e)}static amd(e){return new Amd(e)}static area(e){return new config_Area(e)}static attributes(e){return new Attributes(e)}static autoSave(e){return new AutoSave(e)}static base(e){return new Base(e)}static batchOutput(e){return new BatchOutput(e)}static behaviorOverride(e){return new BehaviorOverride(e)}static cache(e){return new Cache(e)}static change(e){return new Change(e)}static common(e){return new Common(e)}static compress(e){return new Compress(e)}static compressLogicalStructure(e){return new CompressLogicalStructure(e)}static compressObjectStream(e){return new CompressObjectStream(e)}static compression(e){return new Compression(e)}static config(e){return new Config(e)}static conformance(e){return new Conformance(e)}static contentCopy(e){return new ContentCopy(e)}static copies(e){return new Copies(e)}static creator(e){return new Creator(e)}static currentPage(e){return new CurrentPage(e)}static data(e){return new Data(e)}static debug(e){return new Debug(e)}static defaultTypeface(e){return new DefaultTypeface(e)}static destination(e){return new Destination(e)}static documentAssembly(e){return new DocumentAssembly(e)}static driver(e){return new Driver(e)}static duplexOption(e){return new DuplexOption(e)}static dynamicRender(e){return new DynamicRender(e)}static embed(e){return new Embed(e)}static encrypt(e){return new config_Encrypt(e)}static encryption(e){return new config_Encryption(e)}static encryptionLevel(e){return new EncryptionLevel(e)}static enforce(e){return new Enforce(e)}static equate(e){return new Equate(e)}static equateRange(e){return new EquateRange(e)}static exclude(e){return new Exclude(e)}static excludeNS(e){return new ExcludeNS(e)}static flipLabel(e){return new FlipLabel(e)}static fontInfo(e){return new config_FontInfo(e)}static formFieldFilling(e){return new FormFieldFilling(e)}static groupParent(e){return new GroupParent(e)}static ifEmpty(e){return new IfEmpty(e)}static includeXDPContent(e){return new IncludeXDPContent(e)}static incrementalLoad(e){return new IncrementalLoad(e)}static incrementalMerge(e){return new IncrementalMerge(e)}static interactive(e){return new Interactive(e)}static jog(e){return new Jog(e)}static labelPrinter(e){return new LabelPrinter(e)}static layout(e){return new Layout(e)}static level(e){return new Level(e)}static linearized(e){return new Linearized(e)}static locale(e){return new Locale(e)}static localeSet(e){return new LocaleSet(e)}static log(e){return new Log(e)}static map(e){return new MapElement(e)}static mediumInfo(e){return new MediumInfo(e)}static message(e){return new config_Message(e)}static messaging(e){return new Messaging(e)}static mode(e){return new Mode(e)}static modifyAnnots(e){return new ModifyAnnots(e)}static msgId(e){return new MsgId(e)}static nameAttr(e){return new NameAttr(e)}static neverEmbed(e){return new NeverEmbed(e)}static numberOfCopies(e){return new NumberOfCopies(e)}static openAction(e){return new OpenAction(e)}static output(e){return new Output(e)}static outputBin(e){return new OutputBin(e)}static outputXSL(e){return new OutputXSL(e)}static overprint(e){return new Overprint(e)}static packets(e){return new Packets(e)}static pageOffset(e){return new PageOffset(e)}static pageRange(e){return new PageRange(e)}static pagination(e){return new Pagination(e)}static paginationOverride(e){return new PaginationOverride(e)}static part(e){return new Part(e)}static pcl(e){return new Pcl(e)}static pdf(e){return new Pdf(e)}static pdfa(e){return new Pdfa(e)}static permissions(e){return new Permissions(e)}static pickTrayByPDFSize(e){return new PickTrayByPDFSize(e)}static picture(e){return new config_Picture(e)}static plaintextMetadata(e){return new PlaintextMetadata(e)}static presence(e){return new Presence(e)}static present(e){return new Present(e)}static print(e){return new Print(e)}static printHighQuality(e){return new PrintHighQuality(e)}static printScaling(e){return new PrintScaling(e)}static printerName(e){return new PrinterName(e)}static producer(e){return new Producer(e)}static ps(e){return new Ps(e)}static range(e){return new Range(e)}static record(e){return new Record(e)}static relevant(e){return new Relevant(e)}static rename(e){return new Rename(e)}static renderPolicy(e){return new RenderPolicy(e)}static runScripts(e){return new RunScripts(e)}static script(e){return new config_Script(e)}static scriptModel(e){return new ScriptModel(e)}static severity(e){return new Severity(e)}static silentPrint(e){return new SilentPrint(e)}static staple(e){return new Staple(e)}static startNode(e){return new StartNode(e)}static startPage(e){return new StartPage(e)}static submitFormat(e){return new SubmitFormat(e)}static submitUrl(e){return new SubmitUrl(e)}static subsetBelow(e){return new SubsetBelow(e)}static suppressBanner(e){return new SuppressBanner(e)}static tagged(e){return new Tagged(e)}static template(e){return new config_Template(e)}static templateCache(e){return new TemplateCache(e)}static threshold(e){return new Threshold(e)}static to(e){return new To(e)}static trace(e){return new Trace(e)}static transform(e){return new Transform(e)}static type(e){return new Type(e)}static uri(e){return new Uri(e)}static validate(e){return new config_Validate(e)}static validateApprovalSignatures(e){return new ValidateApprovalSignatures(e)}static validationMessaging(e){return new ValidationMessaging(e)}static version(e){return new Version(e)}static versionControl(e){return new VersionControl(e)}static viewerPreferences(e){return new ViewerPreferences(e)}static webClient(e){return new WebClient(e)}static whitespace(e){return new Whitespace(e)}static window(e){return new Window(e)}static xdc(e){return new Xdc(e)}static xdp(e){return new Xdp(e)}static xsl(e){return new Xsl(e)}static zpl(e){return new Zpl(e)}}const Jn=_r.connectionSet.id;class ConnectionSet extends XFAObject{constructor(e){super(Jn,"connectionSet",!0);this.wsdlConnection=new XFAObjectArray;this.xmlConnection=new XFAObjectArray;this.xsdConnection=new XFAObjectArray}}class EffectiveInputPolicy extends XFAObject{constructor(e){super(Jn,"effectiveInputPolicy");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class EffectiveOutputPolicy extends XFAObject{constructor(e){super(Jn,"effectiveOutputPolicy");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Operation extends StringObject{constructor(e){super(Jn,"operation");this.id=e.id||"";this.input=e.input||"";this.name=e.name||"";this.output=e.output||"";this.use=e.use||"";this.usehref=e.usehref||""}}class RootElement extends StringObject{constructor(e){super(Jn,"rootElement");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class SoapAction extends StringObject{constructor(e){super(Jn,"soapAction");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class SoapAddress extends StringObject{constructor(e){super(Jn,"soapAddress");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class connection_set_Uri extends StringObject{constructor(e){super(Jn,"uri");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class WsdlAddress extends StringObject{constructor(e){super(Jn,"wsdlAddress");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class WsdlConnection extends XFAObject{constructor(e){super(Jn,"wsdlConnection",!0);this.dataDescription=e.dataDescription||"";this.name=e.name||"";this.effectiveInputPolicy=null;this.effectiveOutputPolicy=null;this.operation=null;this.soapAction=null;this.soapAddress=null;this.wsdlAddress=null}}class XmlConnection extends XFAObject{constructor(e){super(Jn,"xmlConnection",!0);this.dataDescription=e.dataDescription||"";this.name=e.name||"";this.uri=null}}class XsdConnection extends XFAObject{constructor(e){super(Jn,"xsdConnection",!0);this.dataDescription=e.dataDescription||"";this.name=e.name||"";this.rootElement=null;this.uri=null}}class ConnectionSetNamespace{static[zr](e,t){if(ConnectionSetNamespace.hasOwnProperty(e))return ConnectionSetNamespace[e](t)}static connectionSet(e){return new ConnectionSet(e)}static effectiveInputPolicy(e){return new EffectiveInputPolicy(e)}static effectiveOutputPolicy(e){return new EffectiveOutputPolicy(e)}static operation(e){return new Operation(e)}static rootElement(e){return new RootElement(e)}static soapAction(e){return new SoapAction(e)}static soapAddress(e){return new SoapAddress(e)}static uri(e){return new connection_set_Uri(e)}static wsdlAddress(e){return new WsdlAddress(e)}static wsdlConnection(e){return new WsdlConnection(e)}static xmlConnection(e){return new XmlConnection(e)}static xsdConnection(e){return new XsdConnection(e)}}const Yn=_r.datasets.id;class datasets_Data extends XmlObject{constructor(e){super(Yn,"data",e)}[mr](){return!0}}class Datasets extends XFAObject{constructor(e){super(Yn,"datasets",!0);this.data=null;this.Signature=null}[Nr](e){const t=e[kr];("data"===t&&e[Sr]===Yn||"Signature"===t&&e[Sr]===_r.signature.id)&&(this[t]=e);this[Hs](e)}}class DatasetsNamespace{static[zr](e,t){if(DatasetsNamespace.hasOwnProperty(e))return DatasetsNamespace[e](t)}static datasets(e){return new Datasets(e)}static data(e){return new datasets_Data(e)}}const vn=_r.localeSet.id;class CalendarSymbols extends XFAObject{constructor(e){super(vn,"calendarSymbols",!0);this.name="gregorian";this.dayNames=new XFAObjectArray(2);this.eraNames=null;this.meridiemNames=null;this.monthNames=new XFAObjectArray(2)}}class CurrencySymbol extends StringObject{constructor(e){super(vn,"currencySymbol");this.name=getStringOption(e.name,["symbol","isoname","decimal"])}}class CurrencySymbols extends XFAObject{constructor(e){super(vn,"currencySymbols",!0);this.currencySymbol=new XFAObjectArray(3)}}class DatePattern extends StringObject{constructor(e){super(vn,"datePattern");this.name=getStringOption(e.name,["full","long","med","short"])}}class DatePatterns extends XFAObject{constructor(e){super(vn,"datePatterns",!0);this.datePattern=new XFAObjectArray(4)}}class DateTimeSymbols extends ContentObject{constructor(e){super(vn,"dateTimeSymbols")}}class Day extends StringObject{constructor(e){super(vn,"day")}}class DayNames extends XFAObject{constructor(e){super(vn,"dayNames",!0);this.abbr=getInteger({data:e.abbr,defaultValue:0,validate:e=>1===e});this.day=new XFAObjectArray(7)}}class Era extends StringObject{constructor(e){super(vn,"era")}}class EraNames extends XFAObject{constructor(e){super(vn,"eraNames",!0);this.era=new XFAObjectArray(2)}}class locale_set_Locale extends XFAObject{constructor(e){super(vn,"locale",!0);this.desc=e.desc||"";this.name="isoname";this.calendarSymbols=null;this.currencySymbols=null;this.datePatterns=null;this.dateTimeSymbols=null;this.numberPatterns=null;this.numberSymbols=null;this.timePatterns=null;this.typeFaces=null}}class locale_set_LocaleSet extends XFAObject{constructor(e){super(vn,"localeSet",!0);this.locale=new XFAObjectArray}}class Meridiem extends StringObject{constructor(e){super(vn,"meridiem")}}class MeridiemNames extends XFAObject{constructor(e){super(vn,"meridiemNames",!0);this.meridiem=new XFAObjectArray(2)}}class Month extends StringObject{constructor(e){super(vn,"month")}}class MonthNames extends XFAObject{constructor(e){super(vn,"monthNames",!0);this.abbr=getInteger({data:e.abbr,defaultValue:0,validate:e=>1===e});this.month=new XFAObjectArray(12)}}class NumberPattern extends StringObject{constructor(e){super(vn,"numberPattern");this.name=getStringOption(e.name,["full","long","med","short"])}}class NumberPatterns extends XFAObject{constructor(e){super(vn,"numberPatterns",!0);this.numberPattern=new XFAObjectArray(4)}}class NumberSymbol extends StringObject{constructor(e){super(vn,"numberSymbol");this.name=getStringOption(e.name,["decimal","grouping","percent","minus","zero"])}}class NumberSymbols extends XFAObject{constructor(e){super(vn,"numberSymbols",!0);this.numberSymbol=new XFAObjectArray(5)}}class TimePattern extends StringObject{constructor(e){super(vn,"timePattern");this.name=getStringOption(e.name,["full","long","med","short"])}}class TimePatterns extends XFAObject{constructor(e){super(vn,"timePatterns",!0);this.timePattern=new XFAObjectArray(4)}}class TypeFace extends XFAObject{constructor(e){super(vn,"typeFace",!0);this.name=""|e.name}}class TypeFaces extends XFAObject{constructor(e){super(vn,"typeFaces",!0);this.typeFace=new XFAObjectArray}}class LocaleSetNamespace{static[zr](e,t){if(LocaleSetNamespace.hasOwnProperty(e))return LocaleSetNamespace[e](t)}static calendarSymbols(e){return new CalendarSymbols(e)}static currencySymbol(e){return new CurrencySymbol(e)}static currencySymbols(e){return new CurrencySymbols(e)}static datePattern(e){return new DatePattern(e)}static datePatterns(e){return new DatePatterns(e)}static dateTimeSymbols(e){return new DateTimeSymbols(e)}static day(e){return new Day(e)}static dayNames(e){return new DayNames(e)}static era(e){return new Era(e)}static eraNames(e){return new EraNames(e)}static locale(e){return new locale_set_Locale(e)}static localeSet(e){return new locale_set_LocaleSet(e)}static meridiem(e){return new Meridiem(e)}static meridiemNames(e){return new MeridiemNames(e)}static month(e){return new Month(e)}static monthNames(e){return new MonthNames(e)}static numberPattern(e){return new NumberPattern(e)}static numberPatterns(e){return new NumberPatterns(e)}static numberSymbol(e){return new NumberSymbol(e)}static numberSymbols(e){return new NumberSymbols(e)}static timePattern(e){return new TimePattern(e)}static timePatterns(e){return new TimePatterns(e)}static typeFace(e){return new TypeFace(e)}static typeFaces(e){return new TypeFaces(e)}}const Kn=_r.signature.id;class signature_Signature extends XFAObject{constructor(e){super(Kn,"signature",!0)}}class SignatureNamespace{static[zr](e,t){if(SignatureNamespace.hasOwnProperty(e))return SignatureNamespace[e](t)}static signature(e){return new signature_Signature(e)}}const Tn=_r.stylesheet.id;class Stylesheet extends XFAObject{constructor(e){super(Tn,"stylesheet",!0)}}class StylesheetNamespace{static[zr](e,t){if(StylesheetNamespace.hasOwnProperty(e))return StylesheetNamespace[e](t)}static stylesheet(e){return new Stylesheet(e)}}const qn=_r.xdp.id;class xdp_Xdp extends XFAObject{constructor(e){super(qn,"xdp",!0);this.uuid=e.uuid||"";this.timeStamp=e.timeStamp||"";this.config=null;this.connectionSet=null;this.datasets=null;this.localeSet=null;this.stylesheet=new XFAObjectArray;this.template=null}[Gr](e){const t=_r[e[kr]];return t&&e[Sr]===t.id}}class XdpNamespace{static[zr](e,t){if(XdpNamespace.hasOwnProperty(e))return XdpNamespace[e](t)}static xdp(e){return new xdp_Xdp(e)}}const On=_r.xhtml.id,Pn=Symbol(),Wn=new Set(["color","font","font-family","font-size","font-stretch","font-style","font-weight","margin","margin-bottom","margin-left","margin-right","margin-top","letter-spacing","line-height","orphans","page-break-after","page-break-before","page-break-inside","tab-interval","tab-stop","text-align","text-decoration","text-indent","vertical-align","widows","kerning-mode","xfa-font-horizontal-scale","xfa-font-vertical-scale","xfa-spacerun","xfa-tab-stops"]),jn=new Map([["page-break-after","breakAfter"],["page-break-before","breakBefore"],["page-break-inside","breakInside"],["kerning-mode",e=>"none"===e?"none":"normal"],["xfa-font-horizontal-scale",e=>`scaleX(${Math.max(0,Math.min(parseInt(e)/100)).toFixed(2)})`],["xfa-font-vertical-scale",e=>`scaleY(${Math.max(0,Math.min(parseInt(e)/100)).toFixed(2)})`],["xfa-spacerun",""],["xfa-tab-stops",""],["font-size",(e,t)=>measureToString(.99*(e=t.fontSize=Math.abs(getMeasurement(e))))],["letter-spacing",e=>measureToString(getMeasurement(e))],["line-height",e=>measureToString(getMeasurement(e))],["margin",e=>measureToString(getMeasurement(e))],["margin-bottom",e=>measureToString(getMeasurement(e))],["margin-left",e=>measureToString(getMeasurement(e))],["margin-right",e=>measureToString(getMeasurement(e))],["margin-top",e=>measureToString(getMeasurement(e))],["text-indent",e=>measureToString(getMeasurement(e))],["font-family",e=>e],["vertical-align",e=>measureToString(getMeasurement(e))]]),Xn=/\s+/g,Zn=/[\r\n]+/g,Vn=/\r\n?/g;function mapStyle(e,t,i){const a=Object.create(null);if(!e)return a;const s=Object.create(null);for(const[t,i]of e.split(";").map((e=>e.split(":",2)))){const e=jn.get(t);if(""===e)continue;let r=i;e&&(r="string"==typeof e?e:e(i,s));t.endsWith("scale")?a.transform=a.transform?`${a[t]} ${r}`:r:a[t.replaceAll(/-([a-zA-Z])/g,((e,t)=>t.toUpperCase()))]=r}a.fontFamily&&setFontFamily({typeface:a.fontFamily,weight:a.fontWeight||"normal",posture:a.fontStyle||"normal",size:s.fontSize||0},t,t[Cr].fontFinder,a);if(i&&a.verticalAlign&&"0px"!==a.verticalAlign&&a.fontSize){const e=.583,t=.333,i=getMeasurement(a.fontSize);a.fontSize=measureToString(i*e);a.verticalAlign=measureToString(Math.sign(getMeasurement(a.verticalAlign))*i*t)}i&&a.fontSize&&(a.fontSize=`calc(${a.fontSize} * var(--scale-factor))`);fixTextIndent(a);return a}const zn=new Set(["body","html"]);class XhtmlObject extends XmlObject{constructor(e,t){super(On,t);this[Pn]=!1;this.style=e.style||""}[Ys](e){super[Ys](e);this.style=function checkStyle(e){return e.style?e.style.trim().split(/\s*;\s*/).filter((e=>!!e)).map((e=>e.split(/\s*:\s*/,2))).filter((([t,i])=>{"font-family"===t&&e[Cr].usedTypefaces.add(i);return Wn.has(t)})).map((e=>e.join(":"))).join(";"):""}(this)}[xs](){return!zn.has(this[kr])}[Mr](e,t=!1){if(t)this[Pn]=!0;else{e=e.replaceAll(Zn,"");this.style.includes("xfa-spacerun:yes")||(e=e.replaceAll(Xn," "))}e&&(this[Os]+=e)}[Ur](e,t=!0){const i=Object.create(null),a={top:NaN,bottom:NaN,left:NaN,right:NaN};let s=null;for(const[e,t]of this.style.split(";").map((e=>e.split(":",2))))switch(e){case"font-family":i.typeface=stripQuotes(t);break;case"font-size":i.size=getMeasurement(t);break;case"font-weight":i.weight=t;break;case"font-style":i.posture=t;break;case"letter-spacing":i.letterSpacing=getMeasurement(t);break;case"margin":const e=t.split(/ \t/).map((e=>getMeasurement(e)));switch(e.length){case 1:a.top=a.bottom=a.left=a.right=e[0];break;case 2:a.top=a.bottom=e[0];a.left=a.right=e[1];break;case 3:a.top=e[0];a.bottom=e[2];a.left=a.right=e[1];break;case 4:a.top=e[0];a.left=e[1];a.bottom=e[2];a.right=e[3]}break;case"margin-top":a.top=getMeasurement(t);break;case"margin-bottom":a.bottom=getMeasurement(t);break;case"margin-left":a.left=getMeasurement(t);break;case"margin-right":a.right=getMeasurement(t);break;case"line-height":s=getMeasurement(t)}e.pushData(i,a,s);if(this[Os])e.addString(this[Os]);else for(const t of this[rr]())"#text"!==t[kr]?t[Ur](e):e.addString(t[Os]);t&&e.popFont()}[jr](e){const t=[];this[Xs]={children:t};this[Js]({});if(0===t.length&&!this[Os])return HTMLResult.EMPTY;let i;i=this[Pn]?this[Os]?this[Os].replaceAll(Vn,"\n"):void 0:this[Os]||void 0;return HTMLResult.success({name:this[kr],attributes:{href:this.href,style:mapStyle(this.style,this,this[Pn])},children:t,value:i})}}class A extends XhtmlObject{constructor(e){super(e,"a");this.href=fixURL(e.href)||""}}class B extends XhtmlObject{constructor(e){super(e,"b")}[Ur](e){e.pushFont({weight:"bold"});super[Ur](e);e.popFont()}}class Body extends XhtmlObject{constructor(e){super(e,"body")}[jr](e){const t=super[jr](e),{html:i}=t;if(!i)return HTMLResult.EMPTY;i.name="div";i.attributes.class=["xfaRich"];return t}}class Br extends XhtmlObject{constructor(e){super(e,"br")}[Pr](){return"\n"}[Ur](e){e.addString("\n")}[jr](e){return HTMLResult.success({name:"br"})}}class Html extends XhtmlObject{constructor(e){super(e,"html")}[jr](e){const t=[];this[Xs]={children:t};this[Js]({});if(0===t.length)return HTMLResult.success({name:"div",attributes:{class:["xfaRich"],style:{}},value:this[Os]||""});if(1===t.length){const e=t[0];if(e.attributes?.class.includes("xfaRich"))return HTMLResult.success(e)}return HTMLResult.success({name:"div",attributes:{class:["xfaRich"],style:{}},children:t})}}class I extends XhtmlObject{constructor(e){super(e,"i")}[Ur](e){e.pushFont({posture:"italic"});super[Ur](e);e.popFont()}}class Li extends XhtmlObject{constructor(e){super(e,"li")}}class Ol extends XhtmlObject{constructor(e){super(e,"ol")}}class P extends XhtmlObject{constructor(e){super(e,"p")}[Ur](e){super[Ur](e,!1);e.addString("\n");e.addPara();e.popFont()}[Pr](){return this[Ir]()[rr]().at(-1)===this?super[Pr]():super[Pr]()+"\n"}}class Span extends XhtmlObject{constructor(e){super(e,"span")}}class Sub extends XhtmlObject{constructor(e){super(e,"sub")}}class Sup extends XhtmlObject{constructor(e){super(e,"sup")}}class Ul extends XhtmlObject{constructor(e){super(e,"ul")}}class XhtmlNamespace{static[zr](e,t){if(XhtmlNamespace.hasOwnProperty(e))return XhtmlNamespace[e](t)}static a(e){return new A(e)}static b(e){return new B(e)}static body(e){return new Body(e)}static br(e){return new Br(e)}static html(e){return new Html(e)}static i(e){return new I(e)}static li(e){return new Li(e)}static ol(e){return new Ol(e)}static p(e){return new P(e)}static span(e){return new Span(e)}static sub(e){return new Sub(e)}static sup(e){return new Sup(e)}static ul(e){return new Ul(e)}}const _n={config:ConfigNamespace,connection:ConnectionSetNamespace,datasets:DatasetsNamespace,localeSet:LocaleSetNamespace,signature:SignatureNamespace,stylesheet:StylesheetNamespace,template:TemplateNamespace,xdp:XdpNamespace,xhtml:XhtmlNamespace};class UnknownNamespace{constructor(e){this.namespaceId=e}[zr](e,t){return new XmlObject(this.namespaceId,e,t)}}class Root extends XFAObject{constructor(e){super(-1,"root",Object.create(null));this.element=null;this[lr]=e}[Nr](e){this.element=e;return!0}[Zs](){super[Zs]();if(this.element.template instanceof Template){this[lr].set(Jr,this.element);this.element.template[Yr](this[lr]);this.element.template[lr]=this[lr]}}}class Empty extends XFAObject{constructor(){super(-1,"",Object.create(null))}[Nr](e){return!1}}class Builder{constructor(e=null){this._namespaceStack=[];this._nsAgnosticLevel=0;this._namespacePrefixes=new Map;this._namespaces=new Map;this._nextNsId=Math.max(...Object.values(_r).map((({id:e})=>e)));this._currentNamespace=e||new UnknownNamespace(++this._nextNsId)}buildRoot(e){return new Root(e)}build({nsPrefix:e,name:t,attributes:i,namespace:a,prefixes:s}){const r=null!==a;if(r){this._namespaceStack.push(this._currentNamespace);this._currentNamespace=this._searchNamespace(a)}s&&this._addNamespacePrefix(s);if(i.hasOwnProperty(Rr)){const e=_n.datasets,t=i[Rr];let a=null;for(const[i,s]of Object.entries(t)){if(this._getNamespaceToUse(i)===e){a={xfa:s};break}}a?i[Rr]=a:delete i[Rr]}const n=this._getNamespaceToUse(e),g=n?.[zr](t,i)||new Empty;g[mr]()&&this._nsAgnosticLevel++;(r||s||g[mr]())&&(g[Ks]={hasNamespace:r,prefixes:s,nsAgnostic:g[mr]()});return g}isNsAgnostic(){return this._nsAgnosticLevel>0}_searchNamespace(e){let t=this._namespaces.get(e);if(t)return t;for(const[i,{check:a}]of Object.entries(_r))if(a(e)){t=_n[i];if(t){this._namespaces.set(e,t);return t}break}t=new UnknownNamespace(++this._nextNsId);this._namespaces.set(e,t);return t}_addNamespacePrefix(e){for(const{prefix:t,value:i}of e){const e=this._searchNamespace(i);let a=this._namespacePrefixes.get(t);if(!a){a=[];this._namespacePrefixes.set(t,a)}a.push(e)}}_getNamespaceToUse(e){if(!e)return this._currentNamespace;const t=this._namespacePrefixes.get(e);if(t?.length>0)return t.at(-1);warn(`Unknown namespace prefix: ${e}.`);return null}clean(e){const{hasNamespace:t,prefixes:i,nsAgnostic:a}=e;t&&(this._currentNamespace=this._namespaceStack.pop());i&&i.forEach((({prefix:e})=>{this._namespacePrefixes.get(e).pop()}));a&&this._nsAgnosticLevel--}}class XFAParser extends XMLParserBase{constructor(e=null,t=!1){super();this._builder=new Builder(e);this._stack=[];this._globalData={usedTypefaces:new Set};this._ids=new Map;this._current=this._builder.buildRoot(this._ids);this._errorCode=ys;this._whiteRegex=/^\s+$/;this._nbsps=/\xa0+/g;this._richText=t}parse(e){this.parseXml(e);if(this._errorCode===ys){this._current[Zs]();return this._current.element}}onText(e){e=e.replace(this._nbsps,(e=>e.slice(1)+" "));this._richText||this._current[xs]()?this._current[Mr](e,this._richText):this._whiteRegex.test(e)||this._current[Mr](e.trim())}onCdata(e){this._current[Mr](e)}_mkAttributes(e,t){let i=null,a=null;const s=Object.create({});for(const{name:r,value:n}of e)if("xmlns"===r)i?warn(`XFA - multiple namespace definition in <${t}>`):i=n;else if(r.startsWith("xmlns:")){const e=r.substring(6);a||(a=[]);a.push({prefix:e,value:n})}else{const e=r.indexOf(":");if(-1===e)s[r]=n;else{let t=s[Rr];t||(t=s[Rr]=Object.create(null));const[i,a]=[r.slice(0,e),r.slice(e+1)];(t[i]||=Object.create(null))[a]=n}}return[i,a,s]}_getNameAndPrefix(e,t){const i=e.indexOf(":");return-1===i?[e,null]:[e.substring(i+1),t?"":e.substring(0,i)]}onBeginElement(e,t,i){const[a,s,r]=this._mkAttributes(t,e),[n,g]=this._getNameAndPrefix(e,this._builder.isNsAgnostic()),o=this._builder.build({nsPrefix:g,name:n,attributes:r,namespace:a,prefixes:s});o[Cr]=this._globalData;if(i){o[Zs]();this._current[Nr](o)&&o[Kr](this._ids);o[Ys](this._builder)}else{this._stack.push(this._current);this._current=o}}onEndElement(e){const t=this._current;if(t[ur]()&&"string"==typeof t[Os]){const e=new XFAParser;e._globalData=this._globalData;const i=e.parse(t[Os]);t[Os]=null;t[Nr](i)}t[Zs]();this._current=this._stack.pop();this._current[Nr](t)&&t[Kr](this._ids);t[Ys](this._builder)}onError(e){this._errorCode=e}}class XFAFactory{constructor(e){try{this.root=(new XFAParser).parse(XFAFactory._createDocument(e));const t=new Binder(this.root);this.form=t.bind();this.dataHandler=new DataHandler(this.root,t.getData());this.form[Cr].template=this.form}catch(e){warn(`XFA - an error occurred during parsing and binding: ${e}`)}}isValid(){return this.root&&this.form}_createPagesHelper(){const e=this.form[Wr]();return new Promise(((t,i)=>{const nextIteration=()=>{try{const i=e.next();i.done?t(i.value):setTimeout(nextIteration,0)}catch(e){i(e)}};setTimeout(nextIteration,0)}))}async _createPages(){try{this.pages=await this._createPagesHelper();this.dims=this.pages.children.map((e=>{const{width:t,height:i}=e.attributes.style;return[0,0,parseInt(t),parseInt(i)]}))}catch(e){warn(`XFA - an error occurred during layout: ${e}`)}}getBoundingBox(e){return this.dims[e]}async getNumPages(){this.pages||await this._createPages();return this.dims.length}setImages(e){this.form[Cr].images=e}setFonts(e){this.form[Cr].fontFinder=new FontFinder(e);const t=[];for(let e of this.form[Cr].usedTypefaces){e=stripQuotes(e);this.form[Cr].fontFinder.find(e)||t.push(e)}return t.length>0?t:null}appendFonts(e,t){this.form[Cr].fontFinder.add(e,t)}async getPages(){this.pages||await this._createPages();const e=this.pages;this.pages=null;return e}serializeData(e){return this.dataHandler.serialize(e)}static _createDocument(e){return e["/xdp:xdp"]?Object.values(e).join(""):e["xdp:xdp"]}static getRichTextAsHtml(e){if(!e||"string"!=typeof e)return null;try{let t=new XFAParser(XhtmlNamespace,!0).parse(e);if(!["body","xhtml"].includes(t[kr])){const e=XhtmlNamespace.body({});e[Hs](t);t=e}const i=t[jr]();if(!i.success)return null;const{html:a}=i,{attributes:s}=a;if(s){s.class&&(s.class=s.class.filter((e=>!e.startsWith("xfa"))));s.dir="auto"}return{html:a,str:t[Pr]()}}catch(e){warn(`XFA - an error occurred during parsing of rich text: ${e}`)}return null}}class AnnotationFactory{static createGlobals(e){return Promise.all([e.ensureCatalog("acroForm"),e.ensureDoc("xfaDatasets"),e.ensureCatalog("structTreeRoot"),e.ensureCatalog("baseUrl"),e.ensureCatalog("attachments")]).then((([t,i,a,s,r])=>({pdfManager:e,acroForm:t instanceof Dict?t:Dict.empty,xfaDatasets:i,structTreeRoot:a,baseUrl:s,attachments:r})),(e=>{warn(`createGlobals: "${e}".`);return null}))}static async create(e,t,i,a,s,r,n){const g=s?await this._getPageIndex(e,t,i.pdfManager):null;return i.pdfManager.ensure(this,"_create",[e,t,i,a,s,r,g,n])}static _create(e,t,i,a,s=!1,r=null,n=null,g=null){const o=e.fetchIfRef(t);if(!(o instanceof Dict))return;const{acroForm:c,pdfManager:C}=i,h=t instanceof Ref?t.toString():`annot_${a.createObjId()}`;let l=o.get("Subtype");l=l instanceof Name?l.name:null;const Q={xref:e,ref:t,dict:o,subtype:l,id:h,annotationGlobals:i,collectFields:s,orphanFields:r,needAppearances:!s&&!0===c.get("NeedAppearances"),pageIndex:n,evaluatorOptions:C.evaluatorOptions,pageRef:g};switch(l){case"Link":return new LinkAnnotation(Q);case"Text":return new TextAnnotation(Q);case"Widget":let e=getInheritableProperty({dict:o,key:"FT"});e=e instanceof Name?e.name:null;switch(e){case"Tx":return new TextWidgetAnnotation(Q);case"Btn":return new ButtonWidgetAnnotation(Q);case"Ch":return new ChoiceWidgetAnnotation(Q);case"Sig":return new SignatureWidgetAnnotation(Q)}warn(`Unimplemented widget field type "${e}", falling back to base field type.`);return new WidgetAnnotation(Q);case"Popup":return new PopupAnnotation(Q);case"FreeText":return new FreeTextAnnotation(Q);case"Line":return new LineAnnotation(Q);case"Square":return new SquareAnnotation(Q);case"Circle":return new CircleAnnotation(Q);case"PolyLine":return new PolylineAnnotation(Q);case"Polygon":return new PolygonAnnotation(Q);case"Caret":return new CaretAnnotation(Q);case"Ink":return new InkAnnotation(Q);case"Highlight":return new HighlightAnnotation(Q);case"Underline":return new UnderlineAnnotation(Q);case"Squiggly":return new SquigglyAnnotation(Q);case"StrikeOut":return new StrikeOutAnnotation(Q);case"Stamp":return new StampAnnotation(Q);case"FileAttachment":return new FileAttachmentAnnotation(Q);default:s||warn(l?`Unimplemented annotation type "${l}", falling back to base annotation.`:"Annotation is missing the required /Subtype.");return new Annotation(Q)}}static async _getPageIndex(e,t,i){try{const a=await e.fetchIfRefAsync(t);if(!(a instanceof Dict))return-1;const s=a.getRaw("P");if(s instanceof Ref)try{return await i.ensureCatalog("getPageIndex",[s])}catch(e){info(`_getPageIndex -- not a valid page reference: "${e}".`)}if(a.has("Kids"))return-1;const r=await i.ensureDoc("numPages");for(let e=0;ee/255))}function getQuadPoints(e,t){const i=e.getArray("QuadPoints");if(!isNumberArray(i,null)||0===i.length||i.length%8>0)return null;const a=new Float32Array(i.length);for(let e=0,s=i.length;et[2]||Et[3]))return null;a.set([l,u,Q,u,l,E,Q,E],e)}return a}function getTransformMatrix(e,t,i){const[a,s,r,n]=Util.getAxialAlignedBoundingBox(t,i);if(a===r||s===n)return[1,0,0,1,e[0],e[1]];const g=(e[2]-e[0])/(r-a),o=(e[3]-e[1])/(n-s);return[g,0,0,o,e[0]-a*g,e[1]-s*o]}class Annotation{constructor(e){const{dict:t,xref:i,annotationGlobals:a,ref:s,orphanFields:r}=e,n=r?.get(s);n&&t.set("Parent",n);this.setTitle(t.get("T"));this.setContents(t.get("Contents"));this.setModificationDate(t.get("M"));this.setFlags(t.get("F"));this.setRectangle(t.getArray("Rect"));this.setColor(t.getArray("C"));this.setBorderStyle(t);this.setAppearance(t);this.setOptionalContent(t);const g=t.get("MK");this.setBorderAndBackgroundColors(g);this.setRotation(g,t);this.ref=e.ref instanceof Ref?e.ref:null;this._streams=[];this.appearance&&this._streams.push(this.appearance);const o=!!(this.flags&eA),c=!!(this.flags&tA);this.data={annotationFlags:this.flags,borderStyle:this.borderStyle,color:this.color,backgroundColor:this.backgroundColor,borderColor:this.borderColor,rotation:this.rotation,contentsObj:this._contents,hasAppearance:!!this.appearance,id:e.id,modificationDate:this.modificationDate,rect:this.rectangle,subtype:e.subtype,hasOwnCanvas:!1,noRotate:!!(this.flags&$),noHTML:o&&c,isEditable:!1,structParent:-1};if(a.structTreeRoot){let i=t.get("StructParent");this.data.structParent=i=Number.isInteger(i)&&i>=0?i:-1;a.structTreeRoot.addAnnotationIdToPage(e.pageRef,i)}if(e.collectFields){const a=t.get("Kids");if(Array.isArray(a)){const e=[];for(const t of a)t instanceof Ref&&e.push(t.toString());0!==e.length&&(this.data.kidIds=e)}this.data.actions=collectActions(i,t,dA);this.data.fieldName=this._constructFieldName(t);this.data.pageIndex=e.pageIndex}const C=t.get("IT");C instanceof Name&&(this.data.it=C.name);this._isOffscreenCanvasSupported=e.evaluatorOptions.isOffscreenCanvasSupported;this._fallbackFontDict=null;this._needAppearances=!1}_hasFlag(e,t){return!!(e&t)}_buildFlags(e,t){let{flags:i}=this;if(void 0===e){if(void 0===t)return;return t?i&~_:i&~z|_}if(e){i|=_;return t?i&~AA|z:i&~z|AA}i&=~(z|AA);return t?i&~_:i|_}_isViewable(e){return!this._hasFlag(e,V)&&!this._hasFlag(e,AA)}_isPrintable(e){return this._hasFlag(e,_)&&!this._hasFlag(e,z)&&!this._hasFlag(e,V)}mustBeViewed(e,t){const i=e?.get(this.data.id)?.noView;return void 0!==i?!i:this.viewable&&!this._hasFlag(this.flags,z)}mustBePrinted(e){const t=e?.get(this.data.id)?.noPrint;return void 0!==t?!t:this.printable}mustBeViewedWhenEditing(e,t=null){return e?!this.data.isEditable:!t?.has(this.data.id)}get viewable(){return null!==this.data.quadPoints&&(0===this.flags||this._isViewable(this.flags))}get printable(){return null!==this.data.quadPoints&&(0!==this.flags&&this._isPrintable(this.flags))}_parseStringHelper(e){const t="string"==typeof e?stringToPDFString(e):"";return{str:t,dir:t&&"rtl"===bidi(t).dir?"rtl":"ltr"}}setDefaultAppearance(e){const{dict:t,annotationGlobals:i}=e,a=getInheritableProperty({dict:t,key:"DA"})||i.acroForm.get("DA");this._defaultAppearance="string"==typeof a?a:"";this.data.defaultAppearanceData=parseDefaultAppearance(this._defaultAppearance)}setTitle(e){this._title=this._parseStringHelper(e)}setContents(e){this._contents=this._parseStringHelper(e)}setModificationDate(e){this.modificationDate="string"==typeof e?e:null}setFlags(e){this.flags=Number.isInteger(e)&&e>0?e:0;this.flags&V&&"Annotation"!==this.constructor.name&&(this.flags^=V)}hasFlag(e){return this._hasFlag(this.flags,e)}setRectangle(e){this.rectangle=lookupNormalRect(e,[0,0,0,0])}setColor(e){this.color=getRgbColor(e)}setLineEndings(e){this.lineEndings=["None","None"];if(Array.isArray(e)&&2===e.length)for(let t=0;t<2;t++){const i=e[t];if(i instanceof Name)switch(i.name){case"None":continue;case"Square":case"Circle":case"Diamond":case"OpenArrow":case"ClosedArrow":case"Butt":case"ROpenArrow":case"RClosedArrow":case"Slash":this.lineEndings[t]=i.name;continue}warn(`Ignoring invalid lineEnding: ${i}`)}}setRotation(e,t){this.rotation=0;let i=e instanceof Dict?e.get("R")||0:t.get("Rotate")||0;if(Number.isInteger(i)&&0!==i){i%=360;i<0&&(i+=360);i%90==0&&(this.rotation=i)}}setBorderAndBackgroundColors(e){if(e instanceof Dict){this.borderColor=getRgbColor(e.getArray("BC"),null);this.backgroundColor=getRgbColor(e.getArray("BG"),null)}else this.borderColor=this.backgroundColor=null}setBorderStyle(e){this.borderStyle=new AnnotationBorderStyle;if(e instanceof Dict)if(e.has("BS")){const t=e.get("BS");if(t instanceof Dict){const e=t.get("Type");if(!e||isName(e,"Border")){this.borderStyle.setWidth(t.get("W"),this.rectangle);this.borderStyle.setStyle(t.get("S"));this.borderStyle.setDashArray(t.getArray("D"))}}}else if(e.has("Border")){const t=e.getArray("Border");if(Array.isArray(t)&&t.length>=3){this.borderStyle.setHorizontalCornerRadius(t[0]);this.borderStyle.setVerticalCornerRadius(t[1]);this.borderStyle.setWidth(t[2],this.rectangle);4===t.length&&this.borderStyle.setDashArray(t[3],!0)}}else this.borderStyle.setWidth(0)}setAppearance(e){this.appearance=null;const t=e.get("AP");if(!(t instanceof Dict))return;const i=t.get("N");if(i instanceof BaseStream){this.appearance=i;return}if(!(i instanceof Dict))return;const a=e.get("AS");if(!(a instanceof Name&&i.has(a.name)))return;const s=i.get(a.name);s instanceof BaseStream&&(this.appearance=s)}setOptionalContent(e){this.oc=null;const t=e.get("OC");t instanceof Name?warn("setOptionalContent: Support for /Name-entry is not implemented."):t instanceof Dict&&(this.oc=t)}loadResources(e,t){return t.dict.getAsync("Resources").then((t=>{if(!t)return;return new ObjectLoader(t,e,t.xref).load().then((function(){return t}))}))}async getOperatorList(e,t,a,s){const{hasOwnCanvas:r,id:n,rect:g}=this.data;let c=this.appearance;const C=!!(r&&a&o);if(C&&(g[0]===g[2]||g[1]===g[3])){this.data.hasOwnCanvas=!1;return{opList:new OperatorList,separateForm:!1,separateCanvas:!1}}if(!c){if(!C)return{opList:new OperatorList,separateForm:!1,separateCanvas:!1};c=new StringStream("");c.dict=new Dict}const h=c.dict,l=await this.loadResources(["ExtGState","ColorSpace","Pattern","Shading","XObject","Font"],c),Q=lookupRect(h.getArray("BBox"),[0,0,1,1]),E=lookupMatrix(h.getArray("Matrix"),i),u=getTransformMatrix(g,Q,E),d=new OperatorList;let f;this.oc&&(f=await e.parseMarkedContentProps(this.oc,null));void 0!==f&&d.addOp(Ye,["OC",f]);d.addOp(je,[n,g,u,E,C]);await e.getOperatorList({stream:c,task:t,resources:l,operatorList:d,fallbackFontDict:this._fallbackFontDict});d.addOp(Xe,[]);void 0!==f&&d.addOp(ve,[]);this.reset();return{opList:d,separateForm:!1,separateCanvas:C}}async save(e,t,i,a){return null}get hasTextContent(){return!1}async extractTextContent(e,t,i){if(!this.appearance)return;const a=await this.loadResources(["ExtGState","Font","Properties","XObject"],this.appearance),s=[],r=[];let n=null;const g={desiredSize:Math.Infinity,ready:!0,enqueue(e,t){for(const t of e.items)if(void 0!==t.str){n||=t.transform.slice(-2);r.push(t.str);if(t.hasEOL){s.push(r.join("").trimEnd());r.length=0}}}};await e.getTextContent({stream:this.appearance,task:t,resources:a,includeMarkedContent:!0,keepWhiteSpace:!0,sink:g,viewBox:i});this.reset();r.length&&s.push(r.join("").trimEnd());if(s.length>1||s[0]){const e=this.appearance.dict,t=lookupRect(e.getArray("BBox"),null),i=lookupMatrix(e.getArray("Matrix"),null);this.data.textPosition=this._transformPoint(n,t,i);this.data.textContent=s}}_transformPoint(e,t,i){const{rect:a}=this.data;t||=[0,0,1,1];i||=[1,0,0,1,0,0];const s=getTransformMatrix(a,t,i);s[4]-=a[0];s[5]-=a[1];e=Util.applyTransform(e,s);return Util.applyTransform(e,i)}getFieldObject(){return this.data.kidIds?{id:this.data.id,actions:this.data.actions,name:this.data.fieldName,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,type:"",kidIds:this.data.kidIds,page:this.data.pageIndex,rotation:this.rotation}:null}reset(){for(const e of this._streams)e.reset()}_constructFieldName(e){if(!e.has("T")&&!e.has("Parent")){warn("Unknown field name, falling back to empty field name.");return""}if(!e.has("Parent"))return stringToPDFString(e.get("T"));const t=[];e.has("T")&&t.unshift(stringToPDFString(e.get("T")));let i=e;const a=new RefSet;e.objId&&a.put(e.objId);for(;i.has("Parent");){i=i.get("Parent");if(!(i instanceof Dict)||i.objId&&a.has(i.objId))break;i.objId&&a.put(i.objId);i.has("T")&&t.unshift(stringToPDFString(i.get("T")))}return t.join(".")}}class AnnotationBorderStyle{constructor(){this.width=1;this.rawWidth=1;this.style=lA;this.dashArray=[3];this.horizontalCornerRadius=0;this.verticalCornerRadius=0}setWidth(e,t=[0,0,0,0]){if(e instanceof Name)this.width=0;else if("number"==typeof e){if(e>0){this.rawWidth=e;const i=(t[2]-t[0])/2,a=(t[3]-t[1])/2;if(i>0&&a>0&&(e>i||e>a)){warn(`AnnotationBorderStyle.setWidth - ignoring width: ${e}`);e=1}}this.width=e}}setStyle(e){if(e instanceof Name)switch(e.name){case"S":this.style=lA;break;case"D":this.style=BA;break;case"B":this.style=QA;break;case"I":this.style=EA;break;case"U":this.style=uA}}setDashArray(e,t=!1){if(Array.isArray(e)){let i=!0,a=!0;for(const t of e){if(!(+t>=0)){i=!1;break}t>0&&(a=!1)}if(0===e.length||i&&!a){this.dashArray=e;t&&this.setStyle(Name.get("D"))}else this.width=0}else e&&(this.width=0)}setHorizontalCornerRadius(e){Number.isInteger(e)&&(this.horizontalCornerRadius=e)}setVerticalCornerRadius(e){Number.isInteger(e)&&(this.verticalCornerRadius=e)}}class MarkupAnnotation extends Annotation{constructor(e){super(e);const{dict:t}=e;if(t.has("IRT")){const e=t.getRaw("IRT");this.data.inReplyTo=e instanceof Ref?e.toString():null;const i=t.get("RT");this.data.replyType=i instanceof Name?i.name:Z}let i=null;if(this.data.replyType===X){const e=t.get("IRT");this.setTitle(e.get("T"));this.data.titleObj=this._title;this.setContents(e.get("Contents"));this.data.contentsObj=this._contents;if(e.has("CreationDate")){this.setCreationDate(e.get("CreationDate"));this.data.creationDate=this.creationDate}else this.data.creationDate=null;if(e.has("M")){this.setModificationDate(e.get("M"));this.data.modificationDate=this.modificationDate}else this.data.modificationDate=null;i=e.getRaw("Popup");if(e.has("C")){this.setColor(e.getArray("C"));this.data.color=this.color}else this.data.color=null}else{this.data.titleObj=this._title;this.setCreationDate(t.get("CreationDate"));this.data.creationDate=this.creationDate;i=t.getRaw("Popup");t.has("C")||(this.data.color=null)}this.data.popupRef=i instanceof Ref?i.toString():null;t.has("RC")&&(this.data.richText=XFAFactory.getRichTextAsHtml(t.get("RC")))}setCreationDate(e){this.creationDate="string"==typeof e?e:null}_setDefaultAppearance({xref:e,extra:t,strokeColor:i,fillColor:a,blendMode:s,strokeAlpha:r,fillAlpha:n,pointsCallback:g}){let o=Number.MAX_VALUE,c=Number.MAX_VALUE,C=Number.MIN_VALUE,h=Number.MIN_VALUE;const l=["q"];t&&l.push(t);i&&l.push(`${i[0]} ${i[1]} ${i[2]} RG`);a&&l.push(`${a[0]} ${a[1]} ${a[2]} rg`);const Q=this.data.quadPoints||Float32Array.from([this.rectangle[0],this.rectangle[3],this.rectangle[2],this.rectangle[3],this.rectangle[0],this.rectangle[1],this.rectangle[2],this.rectangle[1]]);for(let e=0,t=Q.length;e"string"==typeof e)).map((e=>stringToPDFString(e))):e instanceof Name?stringToPDFString(e.name):"string"==typeof e?stringToPDFString(e):null}hasFieldFlag(e){return!!(this.data.fieldFlags&e)}_isViewable(e){return!0}mustBeViewed(e,t){return t?this.viewable:super.mustBeViewed(e,t)&&!this._hasFlag(this.flags,AA)}getRotationMatrix(e){let t=e?.get(this.data.id)?.rotation;void 0===t&&(t=this.rotation);if(0===t)return i;return getRotationMatrix(t,this.data.rect[2]-this.data.rect[0],this.data.rect[3]-this.data.rect[1])}getBorderAndBackgroundAppearances(e){let t=e?.get(this.data.id)?.rotation;void 0===t&&(t=this.rotation);if(!this.backgroundColor&&!this.borderColor)return"";const i=this.data.rect[2]-this.data.rect[0],a=this.data.rect[3]-this.data.rect[1],s=0===t||180===t?`0 0 ${i} ${a} re`:`0 0 ${a} ${i} re`;let r="";this.backgroundColor&&(r=`${getPdfColor(this.backgroundColor,!0)} ${s} f `);if(this.borderColor){r+=`${this.borderStyle.width||1} w ${getPdfColor(this.borderColor,!1)} ${s} S `}return r}async getOperatorList(e,t,i,a){if(i&h&&!(this instanceof SignatureWidgetAnnotation)&&!this.data.noHTML&&!this.data.hasOwnCanvas)return{opList:new OperatorList,separateForm:!0,separateCanvas:!1};if(!this._hasText)return super.getOperatorList(e,t,i,a);const s=await this._getAppearance(e,t,i,a);if(this.appearance&&null===s)return super.getOperatorList(e,t,i,a);const r=new OperatorList;if(!this._defaultAppearance||null===s)return{opList:r,separateForm:!1,separateCanvas:!1};const n=!!(this.data.hasOwnCanvas&&i&o),g=[0,0,this.data.rect[2]-this.data.rect[0],this.data.rect[3]-this.data.rect[1]],c=getTransformMatrix(this.data.rect,g,[1,0,0,1,0,0]);let C;this.oc&&(C=await e.parseMarkedContentProps(this.oc,null));void 0!==C&&r.addOp(Ye,["OC",C]);r.addOp(je,[this.data.id,this.data.rect,c,this.getRotationMatrix(a),n]);const l=new StringStream(s);await e.getOperatorList({stream:l,task:t,resources:this._fieldResources.mergedResources,operatorList:r});r.addOp(Xe,[]);void 0!==C&&r.addOp(ve,[]);return{opList:r,separateForm:!1,separateCanvas:n}}_getMKDict(e){const t=new Dict(null);e&&t.set("R",e);this.borderColor&&t.set("BC",getPdfColorArray(this.borderColor));this.backgroundColor&&t.set("BG",getPdfColorArray(this.backgroundColor));return t.size>0?t:null}amendSavedDict(e,t){}setValue(e,t,i,a){const{dict:s,ref:r}=function getParentToUpdate(e,t,i){const a=new RefSet,s=e,r={dict:null,ref:null};for(;e instanceof Dict&&!a.has(t);){a.put(t);if(e.has("T"))break;if(!((t=e.getRaw("Parent"))instanceof Ref))return r;e=i.fetch(t)}if(e instanceof Dict&&e!==s){r.dict=e;r.ref=t}return r}(e,this.ref,i);if(s){if(!a.has(r)){const e=s.clone();e.set("V",t);a.put(r,{data:e});return e}}else e.set("V",t);return null}async save(e,t,a,s){const r=a?.get(this.data.id),n=this._buildFlags(r?.noView,r?.noPrint);let g=r?.value,o=r?.rotation;if(g===this.data.fieldValue||void 0===g){if(!this._hasValueFromXFA&&void 0===o&&void 0===n)return;g||=this.data.fieldValue}if(void 0===o&&!this._hasValueFromXFA&&Array.isArray(g)&&Array.isArray(this.data.fieldValue)&&isArrayEqual(g,this.data.fieldValue)&&void 0===n)return;void 0===o&&(o=this.rotation);let c=null;if(!this._needAppearances){c=await this._getAppearance(e,t,C,a);if(null===c&&void 0===n)return}let h=!1;if(c?.needAppearances){h=!0;c=null}const{xref:l}=e,Q=l.fetchIfRef(this.ref);if(!(Q instanceof Dict))return;const E=new Dict(l);for(const e of Q.getKeys())"AP"!==e&&E.set(e,Q.getRaw(e));if(void 0!==n){E.set("F",n);if(null===c&&!h){const e=Q.getRaw("AP");e&&E.set("AP",e)}}const u={path:this.data.fieldName,value:g},d=this.setValue(E,Array.isArray(g)?g.map(stringToAsciiOrUTF16BE):stringToAsciiOrUTF16BE(g),l,s);this.amendSavedDict(a,d||E);const f=this._getMKDict(o);f&&E.set("MK",f);s.put(this.ref,{data:E,xfa:u,needAppearances:h});if(null!==c){const e=l.getNewTemporaryRef(),t=new Dict(l);E.set("AP",t);t.set("N",e);const r=this._getSaveFieldResources(l),n=new StringStream(c),g=n.dict=new Dict(l);g.set("Subtype",Name.get("Form"));g.set("Resources",r);g.set("BBox",[0,0,this.data.rect[2]-this.data.rect[0],this.data.rect[3]-this.data.rect[1]]);const o=this.getRotationMatrix(a);o!==i&&g.set("Matrix",o);s.put(e,{data:n,xfa:null,needAppearances:!1})}E.set("M",`D:${getModificationDate()}`)}async _getAppearance(e,t,i,a){if(this.hasFieldFlag(rA))return null;const s=a?.get(this.data.id);let r,g;if(s){r=s.formattedValue||s.value;g=s.rotation}if(void 0===g&&void 0===r&&!this._needAppearances&&(!this._hasValueFromXFA||this.appearance))return null;const o=this.getBorderAndBackgroundAppearances(a);if(void 0===r){r=this.data.fieldValue;if(!r)return`/Tx BMC q ${o}Q EMC`}Array.isArray(r)&&1===r.length&&(r=r[0]);assert("string"==typeof r,"Expected `value` to be a string.");r=r.trimEnd();if(this.data.combo){const e=this.data.options.find((({exportValue:e})=>r===e));r=e?.displayValue||r}if(""===r)return`/Tx BMC q ${o}Q EMC`;void 0===g&&(g=this.rotation);let c,h=-1;if(this.data.multiLine){c=r.split(/\r\n?|\n/).map((e=>e.normalize("NFC")));h=c.length}else c=[r.replace(/\r\n?|\n/,"").normalize("NFC")];let l=this.data.rect[3]-this.data.rect[1],Q=this.data.rect[2]-this.data.rect[0];90!==g&&270!==g||([Q,l]=[l,Q]);this._defaultAppearance||(this.data.defaultAppearanceData=parseDefaultAppearance(this._defaultAppearance="/Helvetica 0 Tf 0 g"));let E,u,d,f=await WidgetAnnotation._getFontData(e,t,this.data.defaultAppearanceData,this._fieldResources.mergedResources);const p=[];let m=!1;for(const e of c){const t=f.encodeString(e);t.length>1&&(m=!0);p.push(t.join(""))}if(m&&i&C)return{needAppearances:!0};if(m&&this._isOffscreenCanvasSupported){const i=this.data.comb?"monospace":"sans-serif",a=new FakeUnicodeFont(e.xref,i),s=a.createFontResources(c.join("")),n=s.getRaw("Font");if(this._fieldResources.mergedResources.has("Font")){const e=this._fieldResources.mergedResources.get("Font");for(const t of n.getKeys())e.set(t,n.getRaw(t))}else this._fieldResources.mergedResources.set("Font",n);const g=a.fontName.name;f=await WidgetAnnotation._getFontData(e,t,{fontName:g,fontSize:0},s);for(let e=0,t=p.length;e2)return`/Tx BMC q ${o}BT `+E+` 1 0 0 1 ${numberToString(2)} ${numberToString(b)} Tm (${escapeString(p[0])}) Tj ET Q EMC`;return`/Tx BMC q ${o}BT `+E+` 1 0 0 1 0 0 Tm ${this._renderText(p[0],f,u,Q,D,{shift:0},2,b)} ET Q EMC`}static async _getFontData(e,t,i,a){const s=new OperatorList,r={font:null,clone(){return this}},{fontName:n,fontSize:g}=i;await e.handleSetFont(a,[n&&Name.get(n),g],null,s,t,r,null);return r.font}_getTextWidth(e,t){return t.charsToGlyphs(e).reduce(((e,t)=>e+t.width),0)/1e3}_computeFontSize(e,t,i,a,r){let{fontSize:n}=this.data.defaultAppearanceData,g=(n||12)*s,o=Math.round(e/g);if(!n){const roundWithTwoDigits=e=>Math.floor(100*e)/100;if(-1===r){const r=this._getTextWidth(i,a);n=roundWithTwoDigits(Math.min(e/s,t/r));o=1}else{const c=i.split(/\r\n?|\n/),C=[];for(const e of c){const t=a.encodeString(e).join(""),i=a.charsToGlyphs(t),s=a.getCharPositions(t);C.push({line:t,glyphs:i,positions:s})}const isTooBig=i=>{let s=0;for(const r of C){s+=this._splitLine(null,a,i,t,r).length*i;if(s>e)return!0}return!1};o=Math.max(o,r);for(;;){g=e/o;n=roundWithTwoDigits(g/s);if(!isTooBig(n))break;o++}}const{fontName:c,fontColor:C}=this.data.defaultAppearanceData;this._defaultAppearance=function createDefaultAppearance({fontSize:e,fontName:t,fontColor:i}){return`/${escapePDFName(t)} ${e} Tf ${getPdfColor(i,!0)}`}({fontSize:n,fontName:c,fontColor:C})}return[this._defaultAppearance,n,e/o]}_renderText(e,t,i,a,s,r,n,g){let o;if(1===s){o=(a-this._getTextWidth(e,t)*i)/2}else if(2===s){o=a-this._getTextWidth(e,t)*i-n}else o=n;const c=numberToString(o-r.shift);r.shift=o;return`${c} ${g=numberToString(g)} Td (${escapeString(e)}) Tj`}_getSaveFieldResources(e){const{localResources:t,appearanceResources:i,acroFormResources:a}=this._fieldResources,s=this.data.defaultAppearanceData?.fontName;if(!s)return t||Dict.empty;for(const e of[t,i])if(e instanceof Dict){const t=e.get("Font");if(t instanceof Dict&&t.has(s))return e}if(a instanceof Dict){const i=a.get("Font");if(i instanceof Dict&&i.has(s)){const a=new Dict(e);a.set(s,i.getRaw(s));const r=new Dict(e);r.set("Font",a);return Dict.merge({xref:e,dictArray:[r,t],mergeSubDicts:!0})}}return t||Dict.empty}getFieldObject(){return null}}class TextWidgetAnnotation extends WidgetAnnotation{constructor(e){super(e);const{dict:t}=e;if(t.has("PMD")){this.flags|=z;this.data.hidden=!0;warn("Barcodes are not supported")}this.data.hasOwnCanvas=this.data.readOnly&&!this.data.noHTML;this._hasText=!0;"string"!=typeof this.data.fieldValue&&(this.data.fieldValue="");let i=getInheritableProperty({dict:t,key:"Q"});(!Number.isInteger(i)||i<0||i>2)&&(i=null);this.data.textAlignment=i;let a=getInheritableProperty({dict:t,key:"MaxLen"});(!Number.isInteger(a)||a<0)&&(a=0);this.data.maxLen=a;this.data.multiLine=this.hasFieldFlag(sA);this.data.comb=this.hasFieldFlag(hA)&&!this.hasFieldFlag(sA)&&!this.hasFieldFlag(rA)&&!this.hasFieldFlag(IA)&&0!==this.data.maxLen;this.data.doNotScroll=this.hasFieldFlag(CA)}get hasTextContent(){return!!this.appearance&&!this._needAppearances}_getCombAppearance(e,t,i,a,s,r,n,g,o,c,C){const h=s/this.data.maxLen,l=this.getBorderAndBackgroundAppearances(C),Q=[],E=t.getCharPositions(i);for(const[e,t]of E)Q.push(`(${escapeString(i.substring(e,t))}) Tj`);const u=Q.join(` ${numberToString(h)} 0 Td `);return`/Tx BMC q ${l}BT `+e+` 1 0 0 1 ${numberToString(n)} ${numberToString(g+o)} Tm ${u} ET Q EMC`}_getMultilineAppearance(e,t,i,a,s,r,n,g,o,c,C,h){const l=[],Q=s-2*g,E={shift:0};for(let e=0,r=t.length;ea){o.push(e.substring(l,i));l=i;Q=u;c=-1;h=-1}else{Q+=u;c=i;C=s;h=t}else if(Q+u>a)if(-1!==c){o.push(e.substring(l,C));l=C;t=h+1;c=-1;Q=0}else{o.push(e.substring(l,i));l=i;Q=u}else Q+=u}lt?`\\${t}`:"\\s+"));new RegExp(`^\\s*${r}\\s*$`).test(this.data.fieldValue)&&(this.data.textContent=this.data.fieldValue.split("\n"))}getFieldObject(){return{id:this.data.id,value:this.data.fieldValue,defaultValue:this.data.defaultFieldValue||"",multiline:this.data.multiLine,password:this.hasFieldFlag(rA),charLimit:this.data.maxLen,comb:this.data.comb,editable:!this.data.readOnly,hidden:this.data.hidden,name:this.data.fieldName,rect:this.data.rect,actions:this.data.actions,page:this.data.pageIndex,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,rotation:this.rotation,type:"text"}}}class ButtonWidgetAnnotation extends WidgetAnnotation{constructor(e){super(e);this.checkedAppearance=null;this.uncheckedAppearance=null;this.data.checkBox=!this.hasFieldFlag(nA)&&!this.hasFieldFlag(gA);this.data.radioButton=this.hasFieldFlag(nA)&&!this.hasFieldFlag(gA);this.data.pushButton=this.hasFieldFlag(gA);this.data.isTooltipOnly=!1;if(this.data.checkBox)this._processCheckBox(e);else if(this.data.radioButton)this._processRadioButton(e);else if(this.data.pushButton){this.data.hasOwnCanvas=!0;this.data.noHTML=!1;this._processPushButton(e)}else warn("Invalid field flags for button widget annotation")}async getOperatorList(e,t,a,s){if(this.data.pushButton)return super.getOperatorList(e,t,a,!1,s);let r=null,n=null;if(s){const e=s.get(this.data.id);r=e?e.value:null;n=e?e.rotation:null}if(null===r&&this.appearance)return super.getOperatorList(e,t,a,s);null==r&&(r=this.data.checkBox?this.data.fieldValue===this.data.exportValue:this.data.fieldValue===this.data.buttonValue);const g=r?this.checkedAppearance:this.uncheckedAppearance;if(g){const r=this.appearance,o=lookupMatrix(g.dict.getArray("Matrix"),i);n&&g.dict.set("Matrix",this.getRotationMatrix(s));this.appearance=g;const c=super.getOperatorList(e,t,a,s);this.appearance=r;g.dict.set("Matrix",o);return c}return{opList:new OperatorList,separateForm:!1,separateCanvas:!1}}async save(e,t,i,a){this.data.checkBox?this._saveCheckbox(e,t,i,a):this.data.radioButton&&this._saveRadioButton(e,t,i,a)}async _saveCheckbox(e,t,i,a){if(!i)return;const s=i.get(this.data.id),r=this._buildFlags(s?.noView,s?.noPrint);let n=s?.rotation,g=s?.value;if(void 0===n&&void 0===r){if(void 0===g)return;if(this.data.fieldValue===this.data.exportValue===g)return}let o=e.xref.fetchIfRef(this.ref);if(!(o instanceof Dict))return;o=o.clone();void 0===n&&(n=this.rotation);void 0===g&&(g=this.data.fieldValue===this.data.exportValue);const c={path:this.data.fieldName,value:g?this.data.exportValue:""},C=Name.get(g?this.data.exportValue:"Off");this.setValue(o,C,e.xref,a);o.set("AS",C);o.set("M",`D:${getModificationDate()}`);void 0!==r&&o.set("F",r);const h=this._getMKDict(n);h&&o.set("MK",h);a.put(this.ref,{data:o,xfa:c,needAppearances:!1})}async _saveRadioButton(e,t,i,a){if(!i)return;const s=i.get(this.data.id),r=this._buildFlags(s?.noView,s?.noPrint);let n=s?.rotation,g=s?.value;if(void 0===n&&void 0===r){if(void 0===g)return;if(this.data.fieldValue===this.data.buttonValue===g)return}let o=e.xref.fetchIfRef(this.ref);if(!(o instanceof Dict))return;o=o.clone();void 0===g&&(g=this.data.fieldValue===this.data.buttonValue);void 0===n&&(n=this.rotation);const c={path:this.data.fieldName,value:g?this.data.buttonValue:""},C=Name.get(g?this.data.buttonValue:"Off");g&&this.setValue(o,C,e.xref,a);o.set("AS",C);o.set("M",`D:${getModificationDate()}`);void 0!==r&&o.set("F",r);const h=this._getMKDict(n);h&&o.set("MK",h);a.put(this.ref,{data:o,xfa:c,needAppearances:!1})}_getDefaultCheckedAppearance(e,t){const i=this.data.rect[2]-this.data.rect[0],a=this.data.rect[3]-this.data.rect[1],s=[0,0,i,a],r=.8*Math.min(i,a);let n,g;if("check"===t){n={width:.755*r,height:.705*r};g="3"}else if("disc"===t){n={width:.791*r,height:.705*r};g="l"}else unreachable(`_getDefaultCheckedAppearance - unsupported type: ${t}`);const o=`q BT /PdfJsZaDb ${r} Tf 0 g ${numberToString((i-n.width)/2)} ${numberToString((a-n.height)/2)} Td (${g}) Tj ET Q`,c=new Dict(e.xref);c.set("FormType",1);c.set("Subtype",Name.get("Form"));c.set("Type",Name.get("XObject"));c.set("BBox",s);c.set("Matrix",[1,0,0,1,0,0]);c.set("Length",o.length);const C=new Dict(e.xref),h=new Dict(e.xref);h.set("PdfJsZaDb",this.fallbackFontDict);C.set("Font",h);c.set("Resources",C);this.checkedAppearance=new StringStream(o);this.checkedAppearance.dict=c;this._streams.push(this.checkedAppearance)}_processCheckBox(e){const t=e.dict.get("AP");if(!(t instanceof Dict))return;const i=t.get("N");if(!(i instanceof Dict))return;const a=this._decodeFormValue(e.dict.get("AS"));"string"==typeof a&&(this.data.fieldValue=a);const s=null!==this.data.fieldValue&&"Off"!==this.data.fieldValue?this.data.fieldValue:"Yes",r=i.getKeys();if(0===r.length)r.push("Off",s);else if(1===r.length)"Off"===r[0]?r.push(s):r.unshift("Off");else if(r.includes(s)){r.length=0;r.push("Off",s)}else{const e=r.find((e=>"Off"!==e));r.length=0;r.push("Off",e)}r.includes(this.data.fieldValue)||(this.data.fieldValue="Off");this.data.exportValue=r[1];const n=i.get(this.data.exportValue);this.checkedAppearance=n instanceof BaseStream?n:null;const g=i.get("Off");this.uncheckedAppearance=g instanceof BaseStream?g:null;this.checkedAppearance?this._streams.push(this.checkedAppearance):this._getDefaultCheckedAppearance(e,"check");this.uncheckedAppearance&&this._streams.push(this.uncheckedAppearance);this._fallbackFontDict=this.fallbackFontDict;null===this.data.defaultFieldValue&&(this.data.defaultFieldValue="Off")}_processRadioButton(e){this.data.buttonValue=null;const t=e.dict.get("Parent");if(t instanceof Dict){this.parent=e.dict.getRaw("Parent");const i=t.get("V");i instanceof Name&&(this.data.fieldValue=this._decodeFormValue(i))}const i=e.dict.get("AP");if(!(i instanceof Dict))return;const a=i.get("N");if(!(a instanceof Dict))return;for(const e of a.getKeys())if("Off"!==e){this.data.buttonValue=this._decodeFormValue(e);break}const s=a.get(this.data.buttonValue);this.checkedAppearance=s instanceof BaseStream?s:null;const r=a.get("Off");this.uncheckedAppearance=r instanceof BaseStream?r:null;this.checkedAppearance?this._streams.push(this.checkedAppearance):this._getDefaultCheckedAppearance(e,"disc");this.uncheckedAppearance&&this._streams.push(this.uncheckedAppearance);this._fallbackFontDict=this.fallbackFontDict;null===this.data.defaultFieldValue&&(this.data.defaultFieldValue="Off")}_processPushButton(e){const{dict:t,annotationGlobals:i}=e;if(t.has("A")||t.has("AA")||this.data.alternativeText){this.data.isTooltipOnly=!t.has("A")&&!t.has("AA");Catalog.parseDestDictionary({destDict:t,resultObj:this.data,docBaseUrl:i.baseUrl,docAttachments:i.attachments})}else warn("Push buttons without action dictionaries are not supported")}getFieldObject(){let e,t="button";if(this.data.checkBox){t="checkbox";e=this.data.exportValue}else if(this.data.radioButton){t="radiobutton";e=this.data.buttonValue}return{id:this.data.id,value:this.data.fieldValue||"Off",defaultValue:this.data.defaultFieldValue,exportValues:e,editable:!this.data.readOnly,name:this.data.fieldName,rect:this.data.rect,hidden:this.data.hidden,actions:this.data.actions,page:this.data.pageIndex,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,rotation:this.rotation,type:t}}get fallbackFontDict(){const e=new Dict;e.set("BaseFont",Name.get("ZapfDingbats"));e.set("Type",Name.get("FallbackType"));e.set("Subtype",Name.get("FallbackType"));e.set("Encoding",Name.get("ZapfDingbatsEncoding"));return shadow(this,"fallbackFontDict",e)}}class ChoiceWidgetAnnotation extends WidgetAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.indices=t.getArray("I");this.hasIndices=Array.isArray(this.indices)&&this.indices.length>0;this.data.options=[];const a=getInheritableProperty({dict:t,key:"Opt"});if(Array.isArray(a))for(let e=0,t=a.length;e=0&&t0&&(this.data.options=this.data.fieldValue.map((e=>({exportValue:e,displayValue:e}))));this.data.combo=this.hasFieldFlag(oA);this.data.multiSelect=this.hasFieldFlag(cA);this._hasText=!0}getFieldObject(){const e=this.data.combo?"combobox":"listbox",t=this.data.fieldValue.length>0?this.data.fieldValue[0]:null;return{id:this.data.id,value:t,defaultValue:this.data.defaultFieldValue,editable:!this.data.readOnly,name:this.data.fieldName,rect:this.data.rect,numItems:this.data.fieldValue.length,multipleSelection:this.data.multiSelect,hidden:this.data.hidden,actions:this.data.actions,items:this.data.options,page:this.data.pageIndex,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,rotation:this.rotation,type:e}}amendSavedDict(e,t){if(!this.hasIndices)return;let i=e?.get(this.data.id)?.value;Array.isArray(i)||(i=[i]);const a=[],{options:s}=this.data;for(let e=0,t=0,r=s.length;ei){i=a;t=e}}[Q,E]=this._computeFontSize(e,c-4,t,l,-1)}const u=E*s,d=(u-E)/2,f=Math.floor(o/u);let p=0;if(h.length>0){const e=Math.min(...h),t=Math.max(...h);p=Math.max(0,t-f+1);p>e&&(p=e)}const m=Math.min(p+f+1,C),y=["/Tx BMC q",`1 1 ${c} ${o} re W n`];if(h.length){y.push("0.600006 0.756866 0.854904 rg");for(const e of h)p<=e&&ee.trimEnd()));const{coords:e,bbox:t,matrix:i}=FakeUnicodeFont.getFirstPositionInfo(this.rectangle,this.rotation,a);this.data.textPosition=this._transformPoint(e,t,i)}if(this._isOffscreenCanvasSupported){const s=e.dict.get("CA"),r=new FakeUnicodeFont(i,"sans-serif");this.appearance=r.createAppearance(this._contents.str,this.rectangle,this.rotation,a,t,s);this._streams.push(this.appearance)}else warn("FreeTextAnnotation: OffscreenCanvas is not supported, annotation may not render correctly.")}}get hasTextContent(){return this._hasAppearance}static createNewDict(e,t,{apRef:i,ap:a}){const{color:s,fontSize:r,oldAnnotation:n,rect:g,rotation:o,user:c,value:C}=e,h=n||new Dict(t);h.set("Type",Name.get("Annot"));h.set("Subtype",Name.get("FreeText"));if(n){h.set("M",`D:${getModificationDate()}`);h.delete("RC")}else h.set("CreationDate",`D:${getModificationDate()}`);h.set("Rect",g);const l=`/Helv ${r} Tf ${getPdfColor(s,!0)}`;h.set("DA",l);h.set("Contents",stringToAsciiOrUTF16BE(C));h.set("F",4);h.set("Border",[0,0,0]);h.set("Rotate",o);c&&h.set("T",stringToAsciiOrUTF16BE(c));if(i||a){const e=new Dict(t);h.set("AP",e);i?e.set("N",i):e.set("N",a)}return h}static async createNewAppearanceStream(e,t,i){const{baseFontRef:a,evaluator:r,task:n}=i,{color:g,fontSize:o,rect:c,rotation:C,value:h}=e,l=new Dict(t),Q=new Dict(t);if(a)Q.set("Helv",a);else{const e=new Dict(t);e.set("BaseFont",Name.get("Helvetica"));e.set("Type",Name.get("Font"));e.set("Subtype",Name.get("Type1"));e.set("Encoding",Name.get("WinAnsiEncoding"));Q.set("Helv",e)}l.set("Font",Q);const E=await WidgetAnnotation._getFontData(r,n,{fontName:"Helv",fontSize:o},l),[u,d,f,p]=c;let m=f-u,y=p-d;C%180!=0&&([m,y]=[y,m]);const w=h.split("\n"),D=o/1e3;let b=-1/0;const F=[];for(let e of w){const t=E.encodeString(e);if(t.length>1)return null;e=t.join("");F.push(e);let i=0;const a=E.charsToGlyphs(e);for(const e of a)i+=e.width*D;b=Math.max(b,i)}let S=1;b>m&&(S=m/b);let k=1;const R=s*o,N=1*o,G=R*w.length;G>y&&(k=y/G);const M=o*Math.min(S,k);let U,x,L;switch(C){case 0:L=[1,0,0,1];x=[c[0],c[1],m,y];U=[c[0],c[3]-N];break;case 90:L=[0,1,-1,0];x=[c[1],-c[2],m,y];U=[c[1],-c[0]-N];break;case 180:L=[-1,0,0,-1];x=[-c[2],-c[3],m,y];U=[-c[2],-c[1]-N];break;case 270:L=[0,-1,1,0];x=[-c[3],c[0],m,y];U=[-c[3],c[2]-N]}const H=["q",`${L.join(" ")} 0 0 cm`,`${x.join(" ")} re W n`,"BT",`${getPdfColor(g,!0)}`,`0 Tc /Helv ${numberToString(M)} Tf`];H.push(`${U.join(" ")} Td (${escapeString(F[0])}) Tj`);const J=numberToString(R);for(let e=1,t=F.length;e{e.push(`${a[0]} ${a[1]} m`,`${a[2]} ${a[3]} l`,"S");return[t[0]-o,t[2]+o,t[7]-o,t[3]+o]}})}}}class SquareAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=M;this.data.hasOwnCanvas=this.data.noRotate;this.data.noHTML=!1;if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA"),s=getRgbColor(t.getArray("IC"),null),r=s?getPdfColorArray(s):null,n=r?a:null;if(0===this.borderStyle.width&&!r)return;this._setDefaultAppearance({xref:i,extra:`${this.borderStyle.width} w`,strokeColor:e,fillColor:r,strokeAlpha:a,fillAlpha:n,pointsCallback:(e,t)=>{const i=t[4]+this.borderStyle.width/2,a=t[5]+this.borderStyle.width/2,s=t[6]-t[4]-this.borderStyle.width,n=t[3]-t[7]-this.borderStyle.width;e.push(`${i} ${a} ${s} ${n} re`);r?e.push("B"):e.push("S");return[t[0],t[2],t[7],t[3]]}})}}}class CircleAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=U;if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA"),s=getRgbColor(t.getArray("IC"),null),r=s?getPdfColorArray(s):null,n=r?a:null;if(0===this.borderStyle.width&&!r)return;const g=4/3*Math.tan(Math.PI/8);this._setDefaultAppearance({xref:i,extra:`${this.borderStyle.width} w`,strokeColor:e,fillColor:r,strokeAlpha:a,fillAlpha:n,pointsCallback:(e,t)=>{const i=t[0]+this.borderStyle.width/2,a=t[1]-this.borderStyle.width/2,s=t[6]-this.borderStyle.width/2,n=t[7]+this.borderStyle.width/2,o=i+(s-i)/2,c=a+(n-a)/2,C=(s-i)/2*g,h=(n-a)/2*g;e.push(`${o} ${n} m`,`${o+C} ${n} ${s} ${c+h} ${s} ${c} c`,`${s} ${c-h} ${o+C} ${a} ${o} ${a} c`,`${o-C} ${a} ${i} ${c-h} ${i} ${c} c`,`${i} ${c+h} ${o-C} ${n} ${o} ${n} c`,"h");r?e.push("B"):e.push("S");return[t[0],t[2],t[7],t[3]]}})}}}class PolylineAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=L;this.data.hasOwnCanvas=this.data.noRotate;this.data.noHTML=!1;this.data.vertices=null;if(!(this instanceof PolygonAnnotation)){this.setLineEndings(t.getArray("LE"));this.data.lineEndings=this.lineEndings}const a=t.getArray("Vertices");if(!isNumberArray(a,null))return;const s=this.data.vertices=Float32Array.from(a);if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA"),r=this.borderStyle.width||1,n=2*r,g=[1/0,1/0,-1/0,-1/0];for(let e=0,t=s.length;e{for(let t=0,i=s.length;t{for(const t of this.data.inkLists){for(let i=0,a=t.length;ie/255)));Q.set("CA",n);const u=new Dict(t);Q.set("AP",u);i?u.set("N",i):u.set("N",a);return Q}static async createNewAppearanceStream(e,t,i){if(e.outlines)return this.createNewAppearanceStreamForHighlight(e,t,i);const{color:a,rect:s,paths:r,thickness:n,opacity:g}=e,o=[`${n} w 1 J 1 j`,`${getPdfColor(a,!1)}`];1!==g&&o.push("/R0 gs");for(const e of r.lines){o.push(`${numberToString(e[4])} ${numberToString(e[5])} m`);for(let t=6,i=e.length;t{e.push(`${t[0]} ${t[1]} m`,`${t[2]} ${t[3]} l`,`${t[6]} ${t[7]} l`,`${t[4]} ${t[5]} l`,"f");return[t[0],t[2],t[7],t[3]]}})}}else this.data.popupRef=null}static createNewDict(e,t,{apRef:i,ap:a}){const{color:s,oldAnnotation:r,opacity:n,rect:g,rotation:o,user:c,quadPoints:C}=e,h=r||new Dict(t);h.set("Type",Name.get("Annot"));h.set("Subtype",Name.get("Highlight"));h.set(r?"M":"CreationDate",`D:${getModificationDate()}`);h.set("CreationDate",`D:${getModificationDate()}`);h.set("Rect",g);h.set("F",4);h.set("Border",[0,0,0]);h.set("Rotate",o);h.set("QuadPoints",C);h.set("C",Array.from(s,(e=>e/255)));h.set("CA",n);c&&h.set("T",stringToAsciiOrUTF16BE(c));if(i||a){const e=new Dict(t);h.set("AP",e);e.set("N",i||a)}return h}static async createNewAppearanceStream(e,t,i){const{color:a,rect:s,outlines:r,opacity:n}=e,g=[`${getPdfColor(a,!0)}`,"/R0 gs"],o=[];for(const e of r){o.length=0;o.push(`${numberToString(e[0])} ${numberToString(e[1])} m`);for(let t=2,i=e.length;t{e.push(`${t[4]} ${t[5]+1.3} m`,`${t[6]} ${t[7]+1.3} l`,"S");return[t[0],t[2],t[7],t[3]]}})}}else this.data.popupRef=null}}class SquigglyAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=Y;if(this.data.quadPoints=getQuadPoints(t,null)){if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA");this._setDefaultAppearance({xref:i,extra:"[] 0 d 1 w",strokeColor:e,strokeAlpha:a,pointsCallback:(e,t)=>{const i=(t[1]-t[5])/6;let a=i,s=t[4];const r=t[5],n=t[6];e.push(`${s} ${r+a} m`);do{s+=2;a=0===a?i:0;e.push(`${s} ${r+a} l`)}while(s{e.push((t[0]+t[4])/2+" "+(t[1]+t[5])/2+" m",(t[2]+t[6])/2+" "+(t[3]+t[7])/2+" l","S");return[t[0],t[2],t[7],t[3]]}})}}else this.data.popupRef=null}}class StampAnnotation extends MarkupAnnotation{#T;constructor(e){super(e);this.data.annotationType=K;this.#T=this.data.hasOwnCanvas=this.data.noRotate;this.data.isEditable=!this.data.noHTML;this.data.noHTML=!1}mustBeViewedWhenEditing(e,t=null){if(e){if(!this.data.isEditable)return!1;this.#T=this.data.hasOwnCanvas;this.data.hasOwnCanvas=!0;return!0}this.data.hasOwnCanvas=this.#T;return!t?.has(this.data.id)}static async createImage(e,t){const{width:i,height:a}=e,s=new OffscreenCanvas(i,a),r=s.getContext("2d",{alpha:!0});r.drawImage(e,0,0);const n=r.getImageData(0,0,i,a).data,g=new Uint32Array(n.buffer),o=g.some(FeatureTest.isLittleEndian?e=>e>>>24!=255:e=>!!(255&~e));if(o){r.fillStyle="white";r.fillRect(0,0,i,a);r.drawImage(e,0,0)}const c=s.convertToBlob({type:"image/jpeg",quality:1}).then((e=>e.arrayBuffer())),C=Name.get("XObject"),h=Name.get("Image"),l=new Dict(t);l.set("Type",C);l.set("Subtype",h);l.set("BitsPerComponent",8);l.set("ColorSpace",Name.get("DeviceRGB"));l.set("Filter",Name.get("DCTDecode"));l.set("BBox",[0,0,i,a]);l.set("Width",i);l.set("Height",a);let Q=null;if(o){const e=new Uint8Array(g.length);if(FeatureTest.isLittleEndian)for(let t=0,i=g.length;t>>24;else for(let t=0,i=g.length;t=0&&r<=1?r:null}}class DecryptStream extends DecodeStream{constructor(e,t,i){super(t);this.str=e;this.dict=e.dict;this.decrypt=i;this.nextChunk=null;this.initialized=!1}readBlock(){let e;if(this.initialized)e=this.nextChunk;else{e=this.str.getBytes(512);this.initialized=!0}if(!e?.length){this.eof=!0;return}this.nextChunk=this.str.getBytes(512);const t=this.nextChunk?.length>0;e=(0,this.decrypt)(e,!t);const i=this.bufferLength,a=i+e.length;this.ensureBuffer(a).set(e,i);this.bufferLength=a}}class ARCFourCipher{constructor(e){this.a=0;this.b=0;const t=new Uint8Array(256),i=e.length;for(let e=0;e<256;++e)t[e]=e;for(let a=0,s=0;a<256;++a){const r=t[a];s=s+r+e[a%i]&255;t[a]=t[s];t[s]=r}this.s=t}encryptBlock(e){let t=this.a,i=this.b;const a=this.s,s=e.length,r=new Uint8Array(s);for(let n=0;n>5&255;C[h++]=s>>13&255;C[h++]=s>>21&255;C[h++]=s>>>29&255;C[h++]=0;C[h++]=0;C[h++]=0;const E=new Int32Array(16);for(h=0;h>>32-g)|0;s=r}r=r+s|0;n=n+c|0;g=g+Q|0;o=o+u|0}return new Uint8Array([255&r,r>>8&255,r>>16&255,r>>>24&255,255&n,n>>8&255,n>>16&255,n>>>24&255,255&g,g>>8&255,g>>16&255,g>>>24&255,255&o,o>>8&255,o>>16&255,o>>>24&255])}}();class Word64{constructor(e,t){this.high=0|e;this.low=0|t}and(e){this.high&=e.high;this.low&=e.low}xor(e){this.high^=e.high;this.low^=e.low}or(e){this.high|=e.high;this.low|=e.low}shiftRight(e){if(e>=32){this.low=this.high>>>e-32|0;this.high=0}else{this.low=this.low>>>e|this.high<<32-e;this.high=this.high>>>e|0}}shiftLeft(e){if(e>=32){this.high=this.low<>>32-e;this.low<<=e}}rotateRight(e){let t,i;if(32&e){i=this.low;t=this.high}else{t=this.low;i=this.high}e&=31;this.low=t>>>e|i<<32-e;this.high=i>>>e|t<<32-e}not(){this.high=~this.high;this.low=~this.low}add(e){const t=(this.low>>>0)+(e.low>>>0);let i=(this.high>>>0)+(e.high>>>0);t>4294967295&&(i+=1);this.low=0|t;this.high=0|i}copyTo(e,t){e[t]=this.high>>>24&255;e[t+1]=this.high>>16&255;e[t+2]=this.high>>8&255;e[t+3]=255&this.high;e[t+4]=this.low>>>24&255;e[t+5]=this.low>>16&255;e[t+6]=this.low>>8&255;e[t+7]=255&this.low}assign(e){this.high=e.high;this.low=e.low}}const Ag=function calculateSHA256Closure(){function rotr(e,t){return e>>>t|e<<32-t}function ch(e,t,i){return e&t^~e&i}function maj(e,t,i){return e&t^e&i^t&i}function sigma(e){return rotr(e,2)^rotr(e,13)^rotr(e,22)}function sigmaPrime(e){return rotr(e,6)^rotr(e,11)^rotr(e,25)}function littleSigma(e){return rotr(e,7)^rotr(e,18)^e>>>3}const e=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298];return function hash(t,i,a){let s=1779033703,r=3144134277,n=1013904242,g=2773480762,o=1359893119,c=2600822924,C=528734635,h=1541459225;const l=64*Math.ceil((a+9)/64),Q=new Uint8Array(l);let E,u;for(E=0;E>>29&255;Q[E++]=a>>21&255;Q[E++]=a>>13&255;Q[E++]=a>>5&255;Q[E++]=a<<3&255;const f=new Uint32Array(64);for(E=0;E>>10)+f[u-7]+littleSigma(f[u-15])+f[u-16]|0;let t,i,a=s,l=r,d=n,m=g,y=o,w=c,D=C,b=h;for(u=0;u<64;++u){t=b+sigmaPrime(y)+ch(y,w,D)+e[u]+f[u];i=sigma(a)+maj(a,l,d);b=D;D=w;w=y;y=m+t|0;m=d;d=l;l=a;a=t+i|0}s=s+a|0;r=r+l|0;n=n+d|0;g=g+m|0;o=o+y|0;c=c+w|0;C=C+D|0;h=h+b|0}var p;return new Uint8Array([s>>24&255,s>>16&255,s>>8&255,255&s,r>>24&255,r>>16&255,r>>8&255,255&r,n>>24&255,n>>16&255,n>>8&255,255&n,g>>24&255,g>>16&255,g>>8&255,255&g,o>>24&255,o>>16&255,o>>8&255,255&o,c>>24&255,c>>16&255,c>>8&255,255&c,C>>24&255,C>>16&255,C>>8&255,255&C,h>>24&255,h>>16&255,h>>8&255,255&h])}}(),eg=function calculateSHA512Closure(){function ch(e,t,i,a,s){e.assign(t);e.and(i);s.assign(t);s.not();s.and(a);e.xor(s)}function maj(e,t,i,a,s){e.assign(t);e.and(i);s.assign(t);s.and(a);e.xor(s);s.assign(i);s.and(a);e.xor(s)}function sigma(e,t,i){e.assign(t);e.rotateRight(28);i.assign(t);i.rotateRight(34);e.xor(i);i.assign(t);i.rotateRight(39);e.xor(i)}function sigmaPrime(e,t,i){e.assign(t);e.rotateRight(14);i.assign(t);i.rotateRight(18);e.xor(i);i.assign(t);i.rotateRight(41);e.xor(i)}function littleSigma(e,t,i){e.assign(t);e.rotateRight(1);i.assign(t);i.rotateRight(8);e.xor(i);i.assign(t);i.shiftRight(7);e.xor(i)}function littleSigmaPrime(e,t,i){e.assign(t);e.rotateRight(19);i.assign(t);i.rotateRight(61);e.xor(i);i.assign(t);i.shiftRight(6);e.xor(i)}const e=[new Word64(1116352408,3609767458),new Word64(1899447441,602891725),new Word64(3049323471,3964484399),new Word64(3921009573,2173295548),new Word64(961987163,4081628472),new Word64(1508970993,3053834265),new Word64(2453635748,2937671579),new Word64(2870763221,3664609560),new Word64(3624381080,2734883394),new Word64(310598401,1164996542),new Word64(607225278,1323610764),new Word64(1426881987,3590304994),new Word64(1925078388,4068182383),new Word64(2162078206,991336113),new Word64(2614888103,633803317),new Word64(3248222580,3479774868),new Word64(3835390401,2666613458),new Word64(4022224774,944711139),new Word64(264347078,2341262773),new Word64(604807628,2007800933),new Word64(770255983,1495990901),new Word64(1249150122,1856431235),new Word64(1555081692,3175218132),new Word64(1996064986,2198950837),new Word64(2554220882,3999719339),new Word64(2821834349,766784016),new Word64(2952996808,2566594879),new Word64(3210313671,3203337956),new Word64(3336571891,1034457026),new Word64(3584528711,2466948901),new Word64(113926993,3758326383),new Word64(338241895,168717936),new Word64(666307205,1188179964),new Word64(773529912,1546045734),new Word64(1294757372,1522805485),new Word64(1396182291,2643833823),new Word64(1695183700,2343527390),new Word64(1986661051,1014477480),new Word64(2177026350,1206759142),new Word64(2456956037,344077627),new Word64(2730485921,1290863460),new Word64(2820302411,3158454273),new Word64(3259730800,3505952657),new Word64(3345764771,106217008),new Word64(3516065817,3606008344),new Word64(3600352804,1432725776),new Word64(4094571909,1467031594),new Word64(275423344,851169720),new Word64(430227734,3100823752),new Word64(506948616,1363258195),new Word64(659060556,3750685593),new Word64(883997877,3785050280),new Word64(958139571,3318307427),new Word64(1322822218,3812723403),new Word64(1537002063,2003034995),new Word64(1747873779,3602036899),new Word64(1955562222,1575990012),new Word64(2024104815,1125592928),new Word64(2227730452,2716904306),new Word64(2361852424,442776044),new Word64(2428436474,593698344),new Word64(2756734187,3733110249),new Word64(3204031479,2999351573),new Word64(3329325298,3815920427),new Word64(3391569614,3928383900),new Word64(3515267271,566280711),new Word64(3940187606,3454069534),new Word64(4118630271,4000239992),new Word64(116418474,1914138554),new Word64(174292421,2731055270),new Word64(289380356,3203993006),new Word64(460393269,320620315),new Word64(685471733,587496836),new Word64(852142971,1086792851),new Word64(1017036298,365543100),new Word64(1126000580,2618297676),new Word64(1288033470,3409855158),new Word64(1501505948,4234509866),new Word64(1607167915,987167468),new Word64(1816402316,1246189591)];return function hash(t,i,a,s=!1){let r,n,g,o,c,C,h,l;if(s){r=new Word64(3418070365,3238371032);n=new Word64(1654270250,914150663);g=new Word64(2438529370,812702999);o=new Word64(355462360,4144912697);c=new Word64(1731405415,4290775857);C=new Word64(2394180231,1750603025);h=new Word64(3675008525,1694076839);l=new Word64(1203062813,3204075428)}else{r=new Word64(1779033703,4089235720);n=new Word64(3144134277,2227873595);g=new Word64(1013904242,4271175723);o=new Word64(2773480762,1595750129);c=new Word64(1359893119,2917565137);C=new Word64(2600822924,725511199);h=new Word64(528734635,4215389547);l=new Word64(1541459225,327033209)}const Q=128*Math.ceil((a+17)/128),E=new Uint8Array(Q);let u,d;for(u=0;u>>29&255;E[u++]=a>>21&255;E[u++]=a>>13&255;E[u++]=a>>5&255;E[u++]=a<<3&255;const p=new Array(80);for(u=0;u<80;u++)p[u]=new Word64(0,0);let m=new Word64(0,0),y=new Word64(0,0),w=new Word64(0,0),D=new Word64(0,0),b=new Word64(0,0),F=new Word64(0,0),S=new Word64(0,0),k=new Word64(0,0);const R=new Word64(0,0),N=new Word64(0,0),G=new Word64(0,0),M=new Word64(0,0);let U,x;for(u=0;u=1;--e){i=r[13];r[13]=r[9];r[9]=r[5];r[5]=r[1];r[1]=i;i=r[14];a=r[10];r[14]=r[6];r[10]=r[2];r[6]=i;r[2]=a;i=r[15];a=r[11];s=r[7];r[15]=r[3];r[11]=i;r[7]=a;r[3]=s;for(let e=0;e<16;++e)r[e]=this._inv_s[r[e]];for(let i=0,a=16*e;i<16;++i,++a)r[i]^=t[a];for(let e=0;e<16;e+=4){const t=this._mix[r[e]],a=this._mix[r[e+1]],s=this._mix[r[e+2]],n=this._mix[r[e+3]];i=t^a>>>8^a<<24^s>>>16^s<<16^n>>>24^n<<8;r[e]=i>>>24&255;r[e+1]=i>>16&255;r[e+2]=i>>8&255;r[e+3]=255&i}}i=r[13];r[13]=r[9];r[9]=r[5];r[5]=r[1];r[1]=i;i=r[14];a=r[10];r[14]=r[6];r[10]=r[2];r[6]=i;r[2]=a;i=r[15];a=r[11];s=r[7];r[15]=r[3];r[11]=i;r[7]=a;r[3]=s;for(let e=0;e<16;++e){r[e]=this._inv_s[r[e]];r[e]^=t[e]}return r}_encrypt(e,t){const i=this._s;let a,s,r;const n=new Uint8Array(16);n.set(e);for(let e=0;e<16;++e)n[e]^=t[e];for(let e=1;e=a;--i)if(e[i]!==t){t=0;break}g-=t;r[r.length-1]=e.subarray(0,16-t)}}const o=new Uint8Array(g);for(let e=0,t=0,i=r.length;e=256&&(g=255&(27^g))}for(let t=0;t<4;++t){i[e]=a^=i[e-32];e++;i[e]=s^=i[e-32];e++;i[e]=r^=i[e-32];e++;i[e]=n^=i[e-32];e++}}return i}}class PDF17{checkOwnerPassword(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);return isArrayEqual(Ag(s,0,s.length),a)}checkUserPassword(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);return isArrayEqual(Ag(a,0,a.length),i)}getOwnerKey(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);const r=Ag(s,0,s.length);return new AES256Cipher(r).decryptBlock(a,!1,new Uint8Array(16))}getUserKey(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);const s=Ag(a,0,a.length);return new AES256Cipher(s).decryptBlock(i,!1,new Uint8Array(16))}}class PDF20{_hash(e,t,i){let a=Ag(t,0,t.length).subarray(0,32),s=[0],r=0;for(;r<64||s.at(-1)>r-32;){const t=e.length+a.length+i.length,c=new Uint8Array(t);let C=0;c.set(e,C);C+=e.length;c.set(a,C);C+=a.length;c.set(i,C);const h=new Uint8Array(64*t);for(let e=0,i=0;e<64;e++,i+=t)h.set(c,i);s=new AES128Cipher(a.subarray(0,16)).encrypt(h,a.subarray(16,32));const l=s.slice(0,16).reduce(((e,t)=>e+t),0)%3;0===l?a=Ag(s,0,s.length):1===l?a=(n=s,g=0,o=s.length,eg(n,g,o,!0)):2===l&&(a=eg(s,0,s.length));r++}var n,g,o;return a.subarray(0,32)}checkOwnerPassword(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);return isArrayEqual(this._hash(e,s,i),a)}checkUserPassword(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);return isArrayEqual(this._hash(e,a,[]),i)}getOwnerKey(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);const r=this._hash(e,s,i);return new AES256Cipher(r).decryptBlock(a,!1,new Uint8Array(16))}getUserKey(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);const s=this._hash(e,a,[]);return new AES256Cipher(s).decryptBlock(i,!1,new Uint8Array(16))}}class CipherTransform{constructor(e,t){this.StringCipherConstructor=e;this.StreamCipherConstructor=t}createStream(e,t){const i=new this.StreamCipherConstructor;return new DecryptStream(e,t,(function cipherTransformDecryptStream(e,t){return i.decryptBlock(e,t)}))}decryptString(e){const t=new this.StringCipherConstructor;let i=stringToBytes(e);i=t.decryptBlock(i,!0);return bytesToString(i)}encryptString(e){const t=new this.StringCipherConstructor;if(t instanceof AESBaseCipher){const i=16-e.length%16;e+=String.fromCharCode(i).repeat(i);const a=new Uint8Array(16);if("undefined"!=typeof crypto)crypto.getRandomValues(a);else for(let e=0;e<16;e++)a[e]=Math.floor(256*Math.random());let s=stringToBytes(e);s=t.encrypt(s,a);const r=new Uint8Array(16+s.length);r.set(a);r.set(s,16);return bytesToString(r)}let i=stringToBytes(e);i=t.encrypt(i);return bytesToString(i)}}class CipherTransformFactory{static#q=new Uint8Array([40,191,78,94,78,117,138,65,100,0,78,86,255,250,1,8,46,46,0,182,208,104,62,128,47,12,169,254,100,83,105,122]);#O(e,t,i,a,s,r,n,g,o,c,C,h){if(t){const e=Math.min(127,t.length);t=t.subarray(0,e)}else t=[];const l=6===e?new PDF20:new PDF17;return l.checkUserPassword(t,g,n)?l.getUserKey(t,o,C):t.length&&l.checkOwnerPassword(t,a,r,i)?l.getOwnerKey(t,s,r,c):null}#P(e,t,i,a,s,r,n,g){const o=40+i.length+e.length,c=new Uint8Array(o);let C,h,l=0;if(t){h=Math.min(32,t.length);for(;l>8&255;c[l++]=s>>16&255;c[l++]=s>>>24&255;for(C=0,h=e.length;C=4&&!g){c[l++]=255;c[l++]=255;c[l++]=255;c[l++]=255}let Q=$n(c,0,l);const E=n>>3;if(r>=3)for(C=0;C<50;++C)Q=$n(Q,0,E);const u=Q.subarray(0,E);let d,f;if(r>=3){for(l=0;l<32;++l)c[l]=CipherTransformFactory.#q[l];for(C=0,h=e.length;C>3;if(i>=3)for(g=0;g<50;++g)o=$n(o,0,o.length);let C,h;if(i>=3){h=t;const e=new Uint8Array(c);for(g=19;g>=0;g--){for(let t=0;t>8&255;s[n++]=e>>16&255;s[n++]=255&t;s[n++]=t>>8&255;if(a){s[n++]=115;s[n++]=65;s[n++]=108;s[n++]=84}return $n(s,0,n).subarray(0,Math.min(i.length+5,16))}#X(e,t,i,a,s){if(!(t instanceof Name))throw new FormatError("Invalid crypt filter name.");const r=this,n=e.get(t.name),g=n?.get("CFM");if(!g||"None"===g.name)return function(){return new NullCipher};if("V2"===g.name)return function(){return new ARCFourCipher(r.#j(i,a,s,!1))};if("AESV2"===g.name)return function(){return new AES128Cipher(r.#j(i,a,s,!0))};if("AESV3"===g.name)return function(){return new AES256Cipher(s)};throw new FormatError("Unknown crypto method")}constructor(e,t,i){const a=e.get("Filter");if(!isName(a,"Standard"))throw new FormatError("unknown encryption method");this.filterName=a.name;this.dict=e;const s=e.get("V");if(!Number.isInteger(s)||1!==s&&2!==s&&4!==s&&5!==s)throw new FormatError("unsupported encryption algorithm");this.algorithm=s;let r=e.get("Length");if(!r)if(s<=3)r=40;else{const t=e.get("CF"),i=e.get("StmF");if(t instanceof Dict&&i instanceof Name){t.suppressEncryption=!0;const e=t.get(i.name);r=e?.get("Length")||128;r<40&&(r<<=3)}}if(!Number.isInteger(r)||r<40||r%8!=0)throw new FormatError("invalid key length");const n=stringToBytes(e.get("O")),g=stringToBytes(e.get("U")),o=n.subarray(0,32),c=g.subarray(0,32),C=e.get("P"),h=e.get("R"),l=(4===s||5===s)&&!1!==e.get("EncryptMetadata");this.encryptMetadata=l;const Q=stringToBytes(t);let E,u;if(i){if(6===h)try{i=utf8StringToString(i)}catch{warn("CipherTransformFactory: Unable to convert UTF8 encoded password.")}E=stringToBytes(i)}if(5!==s)u=this.#P(Q,E,o,c,C,h,r,l);else{const t=n.subarray(32,40),i=n.subarray(40,48),a=g.subarray(0,48),s=g.subarray(32,40),r=g.subarray(40,48),C=stringToBytes(e.get("OE")),l=stringToBytes(e.get("UE")),Q=stringToBytes(e.get("Perms"));u=this.#O(h,E,o,t,i,a,c,s,r,C,l,Q)}if(!u&&!i)throw new PasswordException("No password given",rt);if(!u&&i){const e=this.#W(E,o,h,r);u=this.#P(Q,e,o,c,C,h,r,l)}if(!u)throw new PasswordException("Incorrect Password",nt);this.encryptionKey=u;if(s>=4){const t=e.get("CF");t instanceof Dict&&(t.suppressEncryption=!0);this.cf=t;this.stmf=e.get("StmF")||Name.get("Identity");this.strf=e.get("StrF")||Name.get("Identity");this.eff=e.get("EFF")||this.stmf}}createCipherTransform(e,t){if(4===this.algorithm||5===this.algorithm)return new CipherTransform(this.#X(this.cf,this.strf,e,t,this.encryptionKey),this.#X(this.cf,this.stmf,e,t,this.encryptionKey));const i=this.#j(e,t,this.encryptionKey,!1),cipherConstructor=function(){return new ARCFourCipher(i)};return new CipherTransform(cipherConstructor,cipherConstructor)}}function decodeString(e){try{return stringToUTF8String(e)}catch(t){warn(`UTF-8 decoding failed: "${t}".`);return e}}class DatasetXMLParser extends SimpleXMLParser{constructor(e){super(e);this.node=null}onEndElement(e){const t=super.onEndElement(e);if(t&&"xfa:datasets"===e){this.node=t;throw new Error("Aborting DatasetXMLParser.")}}}class DatasetReader{constructor(e){if(e.datasets)this.node=new SimpleXMLParser({hasAttributes:!0}).parseFromString(e.datasets).documentElement;else{const t=new DatasetXMLParser({hasAttributes:!0});try{t.parseFromString(e["xdp:xdp"])}catch{}this.node=t.node}}getValue(e){if(!this.node||!e)return"";const t=this.node.searchNode(parseXFAPath(e),0);if(!t)return"";const i=t.firstChild;return"value"===i?.nodeName?t.children.map((e=>decodeString(e.textContent))):decodeString(t.textContent)}}class XRef{#Z=null;constructor(e,t){this.stream=e;this.pdfManager=t;this.entries=[];this._xrefStms=new Set;this._cacheMap=new Map;this._pendingRefs=new RefSet;this._newPersistentRefNum=null;this._newTemporaryRefNum=null;this._persistentRefsCache=null}getNewPersistentRef(e){null===this._newPersistentRefNum&&(this._newPersistentRefNum=this.entries.length||1);const t=this._newPersistentRefNum++;this._cacheMap.set(t,e);return Ref.get(t,0)}getNewTemporaryRef(){if(null===this._newTemporaryRefNum){this._newTemporaryRefNum=this.entries.length||1;if(this._newPersistentRefNum){this._persistentRefsCache=new Map;for(let e=this._newTemporaryRefNum;e0;){const[n,g]=r;if(!Number.isInteger(n)||!Number.isInteger(g))throw new FormatError(`Invalid XRef range fields: ${n}, ${g}`);if(!Number.isInteger(i)||!Number.isInteger(a)||!Number.isInteger(s))throw new FormatError(`Invalid XRef entry fields length: ${n}, ${g}`);for(let r=t.entryNum;r=e.length);){i+=String.fromCharCode(a);a=e[t]}return i}function skipUntil(e,t,i){const a=i.length,s=e.length;let r=0;for(;t=a)break;t++;r++}return r}const e=/\b(endobj|\d+\s+\d+\s+obj|xref|trailer\s*<<)\b/g,t=/\b(startxref|\d+\s+\d+\s+obj)\b/g,i=/^(\d+)\s+(\d+)\s+obj\b/,a=new Uint8Array([116,114,97,105,108,101,114]),s=new Uint8Array([115,116,97,114,116,120,114,101,102]),r=new Uint8Array([47,88,82,101,102]);this.entries.length=0;this._cacheMap.clear();const n=this.stream;n.pos=0;const g=n.getBytes(),o=bytesToString(g),c=g.length;let C=n.start;const h=[],l=[];for(;C=c)break;Q=g[C]}while(10!==Q&&13!==Q);continue}const E=readToken(g,C);let u;if(E.startsWith("xref")&&(4===E.length||/\s/.test(E[4]))){C+=skipUntil(g,C,a);h.push(C);C+=skipUntil(g,C,s)}else if(u=i.exec(E)){const t=0|u[1],i=0|u[2],a=C+E.length;let s,h=!1;if(this.entries[t]){if(this.entries[t].gen===i)try{new Parser({lexer:new Lexer(n.makeSubStream(a))}).getObj();h=!0}catch(e){e instanceof ParserEOFException?warn(`indexObjects -- checking object (${E}): "${e}".`):h=!0}}else h=!0;h&&(this.entries[t]={offset:C-n.start,gen:i,uncompressed:!0});e.lastIndex=a;const Q=e.exec(o);if(Q){s=e.lastIndex+1-C;if("endobj"!==Q[1]){warn(`indexObjects: Found "${Q[1]}" inside of another "obj", caused by missing "endobj" -- trying to recover.`);s-=Q[1].length+1}}else s=c-C;const d=g.subarray(C,C+s),f=skipUntil(d,0,r);if(f0?Math.max(...this._xrefStms):null)}getEntry(e){const t=this.entries[e];return t&&!t.free&&t.offset?t:null}fetchIfRef(e,t=!1){return e instanceof Ref?this.fetch(e,t):e}fetch(e,t=!1){if(!(e instanceof Ref))throw new Error("ref object is not a reference");const i=e.num,a=this._cacheMap.get(i);if(void 0!==a){a instanceof Dict&&!a.objId&&(a.objId=e.toString());return a}let s=this.getEntry(i);if(null===s){this._cacheMap.set(i,s);return s}if(this._pendingRefs.has(e)){this._pendingRefs.remove(e);warn(`Ignoring circular reference: ${e}.`);return lt}this._pendingRefs.put(e);try{s=s.uncompressed?this.fetchUncompressed(e,s,t):this.fetchCompressed(e,s,t);this._pendingRefs.remove(e)}catch(t){this._pendingRefs.remove(e);throw t}s instanceof Dict?s.objId=e.toString():s instanceof BaseStream&&(s.dict.objId=e.toString());return s}fetchUncompressed(e,t,i=!1){const a=e.gen;let s=e.num;if(t.gen!==a){const r=`Inconsistent generation in XRef: ${e}`;if(this._generationFallback&&t.gen0&&t[3]-t[1]>0)return t;warn(`Empty, or invalid, /${e} entry.`)}return null}get mediaBox(){return shadow(this,"mediaBox",this._getBoundingBox("MediaBox")||tg)}get cropBox(){return shadow(this,"cropBox",this._getBoundingBox("CropBox")||this.mediaBox)}get userUnit(){const e=this.pageDict.get("UserUnit");return shadow(this,"userUnit","number"==typeof e&&e>0?e:1)}get view(){const{cropBox:e,mediaBox:t}=this;if(e!==t&&!isArrayEqual(e,t)){const i=Util.intersect(e,t);if(i&&i[2]-i[0]>0&&i[3]-i[1]>0)return shadow(this,"view",i);warn("Empty /CropBox and /MediaBox intersection.")}return shadow(this,"view",t)}get rotate(){let e=this._getInheritableProperty("Rotate")||0;e%90!=0?e=0:e>=360?e%=360:e<0&&(e=(e%360+360)%360);return shadow(this,"rotate",e)}_onSubStreamError(e,t){if(!this.evaluatorOptions.ignoreErrors)throw e;warn(`getContentStream - ignoring sub-stream (${t}): "${e}".`)}getContentStream(){return this.pdfManager.ensure(this,"content").then((e=>e instanceof BaseStream?e:Array.isArray(e)?new StreamsSequenceStream(e,this._onSubStreamError.bind(this)):new NullStream))}get xfaData(){return shadow(this,"xfaData",this.xfaFactory?{bbox:this.xfaFactory.getBoundingBox(this.pageIndex)}:null)}async#V(e,t,i){const a=[];for(const s of e)if(s.id){const e=Ref.fromString(s.id);if(!e){warn(`A non-linked annotation cannot be modified: ${s.id}`);continue}if(s.deleted){t.put(e,e);if(s.popupRef){const e=Ref.fromString(s.popupRef);e&&t.put(e,e)}continue}i?.put(e);s.ref=e;a.push(this.xref.fetchAsync(e).then((e=>{e instanceof Dict&&(s.oldAnnotation=e.clone())}),(()=>{warn(`Cannot fetch \`oldAnnotation\` for: ${e}.`)})));delete s.id}await Promise.all(a)}async saveNewAnnotations(e,t,i,a,s){if(this.xfaFactory)throw new Error("XFA: Cannot save new annotations.");const r=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions}),n=new RefSetCache,g=new RefSet;await this.#V(i,n,g);const o=this.pageDict,c=this.annotations.filter((e=>!(e instanceof Ref&&n.has(e)))),C=await AnnotationFactory.saveNewAnnotations(r,t,i,a,s);for(const{ref:e}of C.annotations)e instanceof Ref&&!g.has(e)&&c.push(e);const h=o.clone();h.set("Annots",c);s.put(this.ref,{data:h});for(const e of n)s.put(e,{data:null})}save(e,t,i,a){const s=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions});return this._parsedAnnotations.then((function(e){const r=[];for(const n of e)r.push(n.save(s,t,i,a).catch((function(e){warn(`save - ignoring annotation data during "${t.name}" task: "${e}".`);return null})));return Promise.all(r)}))}loadResources(e){this.resourcesPromise||=this.pdfManager.ensure(this,"resources");return this.resourcesPromise.then((()=>new ObjectLoader(this.resources,e,this.xref).load()))}getOperatorList({handler:e,sink:t,task:i,intent:a,cacheKey:s,annotationStorage:r=null,modifiedIds:n=null}){const C=this.getContentStream(),E=this.loadResources(["ColorSpace","ExtGState","Font","Pattern","Properties","Shading","XObject"]),d=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions}),f=this.xfaFactory?null:getNewAnnotationsMap(r),p=f?.get(this.pageIndex);let m=Promise.resolve(null),y=null;if(p){const e=this.pdfManager.ensureDoc("annotationGlobals");let t;const a=new Set;for(const{bitmapId:e,bitmap:t}of p)!e||t||a.has(e)||a.add(e);const{isOffscreenCanvasSupported:s}=this.evaluatorOptions;if(a.size>0){const e=p.slice();for(const[t,i]of r)t.startsWith(u)&&i.bitmap&&a.has(i.bitmapId)&&e.push(i);t=AnnotationFactory.generateImages(e,this.xref,s)}else t=AnnotationFactory.generateImages(p,this.xref,s);y=new RefSet;m=Promise.all([e,this.#V(p,y,null)]).then((([e])=>e?AnnotationFactory.printNewAnnotations(e,d,i,p,t):null))}const w=Promise.all([C,E]).then((([r])=>{const n=new OperatorList(a,t);e.send("StartRenderPage",{transparency:d.hasBlendModes(this.resources,this.nonBlendModesSet),pageIndex:this.pageIndex,cacheKey:s});return d.getOperatorList({stream:r,task:i,resources:this.resources,operatorList:n}).then((function(){return n}))}));return Promise.all([w,this._parsedAnnotations,m]).then((function([e,t,s]){if(s){t=t.filter((e=>!(e.ref&&y.has(e.ref))));for(let e=0,i=s.length;ee.ref&&isRefsEqual(e.ref,a.refToReplace)));if(r>=0){t.splice(r,1,a);s.splice(e--,1);i--}}}t=t.concat(s)}if(0===t.length||a&l){e.flush(!0);return{length:e.totalLength}}const C=!!(a&h),E=!!(a&Q),u=!!(a&g),f=!!(a&o),p=!!(a&c),m=[];for(const e of t)(u||f&&e.mustBeViewed(r,C)&&e.mustBeViewedWhenEditing(E,n)||p&&e.mustBePrinted(r))&&m.push(e.getOperatorList(d,i,a,r).catch((function(e){warn(`getOperatorList - ignoring annotation data during "${i.name}" task: "${e}".`);return{opList:null,separateForm:!1,separateCanvas:!1}})));return Promise.all(m).then((function(t){let i=!1,a=!1;for(const{opList:s,separateForm:r,separateCanvas:n}of t){e.addOpList(s);i||=r;a||=n}e.flush(!0,{form:i,canvas:a});return{length:e.totalLength}}))}))}async extractTextContent({handler:e,task:t,includeMarkedContent:i,disableNormalization:a,sink:s}){const r=this.getContentStream(),n=this.loadResources(["ExtGState","Font","Properties","XObject"]),g=this.pdfManager.ensureCatalog("lang"),[o,,c]=await Promise.all([r,n,g]);return new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions}).getTextContent({stream:o,task:t,resources:this.resources,includeMarkedContent:i,disableNormalization:a,sink:s,viewBox:this.view,lang:c})}async getStructTree(){const e=await this.pdfManager.ensureCatalog("structTreeRoot");if(!e)return null;await this._parsedAnnotations;const t=await this.pdfManager.ensure(this,"_parseStructTree",[e]);return this.pdfManager.ensure(t,"serializable")}_parseStructTree(e){const t=new StructTreePage(e,this.pageDict);t.parse(this.ref);return t}async getAnnotationsData(e,t,i){const a=await this._parsedAnnotations;if(0===a.length)return a;const s=[],r=[];let n;const C=!!(i&g),h=!!(i&o),l=!!(i&c);for(const i of a){const a=C||h&&i.viewable;(a||l&&i.printable)&&s.push(i.data);if(i.hasTextContent&&a){n||=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions});r.push(i.extractTextContent(n,t,[-1/0,-1/0,1/0,1/0]).catch((function(e){warn(`getAnnotationsData - ignoring textContent during "${t.name}" task: "${e}".`)})))}}await Promise.all(r);return s}get annotations(){const e=this._getInheritableProperty("Annots");return shadow(this,"annotations",Array.isArray(e)?e:[])}get _parsedAnnotations(){return shadow(this,"_parsedAnnotations",this.pdfManager.ensure(this,"annotations").then((async e=>{if(0===e.length)return e;const[t,i]=await Promise.all([this.pdfManager.ensureDoc("annotationGlobals"),this.pdfManager.ensureDoc("fieldObjects")]);if(!t)return[];const a=i?.orphanFields,s=[];for(const i of e)s.push(AnnotationFactory.create(this.xref,i,t,this._localIdFactory,!1,a,this.ref).catch((function(e){warn(`_parsedAnnotations: "${e}".`);return null})));const r=[];let n,g;for(const e of await Promise.all(s))e&&(e instanceof WidgetAnnotation?(g||=[]).push(e):e instanceof PopupAnnotation?(n||=[]).push(e):r.push(e));g&&r.push(...g);n&&r.push(...n);return r})))}get jsActions(){return shadow(this,"jsActions",collectActions(this.xref,this.pageDict,pA))}}const ig=new Uint8Array([37,80,68,70,45]),ag=new Uint8Array([115,116,97,114,116,120,114,101,102]),sg=new Uint8Array([101,110,100,111,98,106]);function find(e,t,i=1024,a=!1){const s=t.length,r=e.peekBytes(i),n=r.length-s;if(n<=0)return!1;if(a){const i=s-1;let a=r.length-1;for(;a>=i;){let n=0;for(;n=s){e.pos+=a-i;return!0}a--}}else{let i=0;for(;i<=n;){let a=0;for(;a=s){e.pos+=i;return!0}i++}}return!1}class PDFDocument{constructor(e,t){if(t.length<=0)throw new InvalidPDFException("The PDF file is empty, i.e. its size is zero bytes.");this.pdfManager=e;this.stream=t;this.xref=new XRef(t,e);this._pagePromises=new Map;this._version=null;const i={font:0};this._globalIdFactory=class{static getDocId(){return`g_${e.docId}`}static createFontId(){return"f"+ ++i.font}static createObjId(){unreachable("Abstract method `createObjId` called.")}static getPageObjId(){unreachable("Abstract method `getPageObjId` called.")}}}parse(e){this.xref.parse(e);this.catalog=new Catalog(this.pdfManager,this.xref)}get linearization(){let e=null;try{e=Linearization.create(this.stream)}catch(e){if(e instanceof MissingDataException)throw e;info(e)}return shadow(this,"linearization",e)}get startXRef(){const e=this.stream;let t=0;if(this.linearization){e.reset();if(find(e,sg)){e.skip(6);let i=e.peekByte();for(;isWhiteSpace(i);){e.pos++;i=e.peekByte()}t=e.pos-e.start}}else{const i=1024,a=ag.length;let s=!1,r=e.end;for(;!s&&r>0;){r-=i-a;r<0&&(r=0);e.pos=r;s=find(e,ag,i,!0)}if(s){e.skip(9);let i;do{i=e.getByte()}while(isWhiteSpace(i));let a="";for(;i>=32&&i<=57;){a+=String.fromCharCode(i);i=e.getByte()}t=parseInt(a,10);isNaN(t)&&(t=0)}}return shadow(this,"startXRef",t)}checkHeader(){const e=this.stream;e.reset();if(!find(e,ig))return;e.moveStart();e.skip(ig.length);let t,i="";for(;(t=e.getByte())>32&&i.length<7;)i+=String.fromCharCode(t);ft.test(i)?this._version=i:warn(`Invalid PDF header version: ${i}`)}parseStartXRef(){this.xref.setStartXRef(this.startXRef)}get numPages(){let e=0;e=this.catalog.hasActualNumPages?this.catalog.numPages:this.xfaFactory?this.xfaFactory.getNumPages():this.linearization?this.linearization.numPages:this.catalog.numPages;return shadow(this,"numPages",e)}_hasOnlyDocumentSignatures(e,t=0){return!!Array.isArray(e)&&e.every((e=>{if(!((e=this.xref.fetchIfRef(e))instanceof Dict))return!1;if(e.has("Kids")){if(++t>10){warn("_hasOnlyDocumentSignatures: maximum recursion depth reached");return!1}return this._hasOnlyDocumentSignatures(e.get("Kids"),t)}const i=isName(e.get("FT"),"Sig"),a=e.get("Rect"),s=Array.isArray(a)&&a.every((e=>0===e));return i&&s}))}get _xfaStreams(){const e=this.catalog.acroForm;if(!e)return null;const t=e.get("XFA"),i={"xdp:xdp":"",template:"",datasets:"",config:"",connectionSet:"",localeSet:"",stylesheet:"","/xdp:xdp":""};if(t instanceof BaseStream&&!t.isEmpty){i["xdp:xdp"]=t;return i}if(!Array.isArray(t)||0===t.length)return null;for(let e=0,a=t.length;e0;e.hasFields=a;const s=t.get("XFA");e.hasXfa=Array.isArray(s)&&s.length>0||s instanceof BaseStream&&!s.isEmpty;const r=!!(1&t.get("SigFlags")),n=r&&this._hasOnlyDocumentSignatures(i);e.hasAcroForm=a&&!n;e.hasSignatures=r}catch(e){if(e instanceof MissingDataException)throw e;warn(`Cannot fetch form information: "${e}".`)}return shadow(this,"formInfo",e)}get documentInfo(){const e={PDFFormatVersion:this.version,Language:this.catalog.lang,EncryptFilterName:this.xref.encrypt?this.xref.encrypt.filterName:null,IsLinearized:!!this.linearization,IsAcroFormPresent:this.formInfo.hasAcroForm,IsXFAPresent:this.formInfo.hasXfa,IsCollectionPresent:!!this.catalog.collection,IsSignaturesPresent:this.formInfo.hasSignatures};let t;try{t=this.xref.trailer.get("Info")}catch(e){if(e instanceof MissingDataException)throw e;info("The document information dictionary is invalid.")}if(!(t instanceof Dict))return shadow(this,"documentInfo",e);for(const i of t.getKeys()){const a=t.get(i);switch(i){case"Title":case"Author":case"Subject":case"Keywords":case"Creator":case"Producer":case"CreationDate":case"ModDate":if("string"==typeof a){e[i]=stringToPDFString(a);continue}break;case"Trapped":if(a instanceof Name){e[i]=a;continue}break;default:let t;switch(typeof a){case"string":t=stringToPDFString(a);break;case"number":case"boolean":t=a;break;default:a instanceof Name&&(t=a)}if(void 0===t){warn(`Bad value, for custom key "${i}", in Info: ${a}.`);continue}e.Custom||(e.Custom=Object.create(null));e.Custom[i]=t;continue}warn(`Bad value, for key "${i}", in Info: ${a}.`)}return shadow(this,"documentInfo",e)}get fingerprints(){const e="\0".repeat(16);function validate(t){return"string"==typeof t&&16===t.length&&t!==e}const t=this.xref.trailer.get("ID");let i,a;if(Array.isArray(t)&&validate(t[0])){i=stringToBytes(t[0]);t[1]!==t[0]&&validate(t[1])&&(a=stringToBytes(t[1]))}else i=$n(this.stream.getByteRange(0,1024),0,1024);return shadow(this,"fingerprints",[toHexUtil(i),a?toHexUtil(a):null])}async _getLinearizationPage(e){const{catalog:t,linearization:i,xref:a}=this,s=Ref.get(i.objectNumberFirst,0);try{const e=await a.fetchAsync(s);if(e instanceof Dict){let i=e.getRaw("Type");i instanceof Ref&&(i=await a.fetchAsync(i));if(isName(i,"Page")||!e.has("Type")&&!e.has("Kids")&&e.has("Contents")){t.pageKidsCountCache.has(s)||t.pageKidsCountCache.put(s,1);t.pageIndexCache.has(s)||t.pageIndexCache.put(s,0);return[e,s]}}throw new FormatError("The Linearization dictionary doesn't point to a valid Page dictionary.")}catch(i){warn(`_getLinearizationPage: "${i.message}".`);return t.getPageDict(e)}}getPage(e){const t=this._pagePromises.get(e);if(t)return t;const{catalog:i,linearization:a,xfaFactory:s}=this;let r;r=s?Promise.resolve([Dict.empty,null]):a?.pageFirst===e?this._getLinearizationPage(e):i.getPageDict(e);r=r.then((([t,a])=>new Page({pdfManager:this.pdfManager,xref:this.xref,pageIndex:e,pageDict:t,ref:a,globalIdFactory:this._globalIdFactory,fontCache:i.fontCache,builtInCMapCache:i.builtInCMapCache,standardFontDataCache:i.standardFontDataCache,globalImageCache:i.globalImageCache,systemFontCache:i.systemFontCache,nonBlendModesSet:i.nonBlendModesSet,xfaFactory:s})));this._pagePromises.set(e,r);return r}async checkFirstPage(e=!1){if(!e)try{await this.getPage(0)}catch(e){if(e instanceof XRefEntryException){this._pagePromises.delete(0);await this.cleanup();throw new XRefParseException}}}async checkLastPage(e=!1){const{catalog:t,pdfManager:i}=this;t.setActualNumPages();let a;try{await Promise.all([i.ensureDoc("xfaFactory"),i.ensureDoc("linearization"),i.ensureCatalog("numPages")]);if(this.xfaFactory)return;a=this.linearization?this.linearization.numPages:t.numPages;if(!Number.isInteger(a))throw new FormatError("Page count is not an integer.");if(a<=1)return;await this.getPage(a-1)}catch(s){this._pagePromises.delete(a-1);await this.cleanup();if(s instanceof XRefEntryException&&!e)throw new XRefParseException;warn(`checkLastPage - invalid /Pages tree /Count: ${a}.`);let r;try{r=await t.getAllPageDicts(e)}catch(i){if(i instanceof XRefEntryException&&!e)throw new XRefParseException;t.setActualNumPages(1);return}for(const[e,[a,s]]of r){let r;if(a instanceof Error){r=Promise.reject(a);r.catch((()=>{}))}else r=Promise.resolve(new Page({pdfManager:i,xref:this.xref,pageIndex:e,pageDict:a,ref:s,globalIdFactory:this._globalIdFactory,fontCache:t.fontCache,builtInCMapCache:t.builtInCMapCache,standardFontDataCache:t.standardFontDataCache,globalImageCache:t.globalImageCache,systemFontCache:t.systemFontCache,nonBlendModesSet:t.nonBlendModesSet,xfaFactory:null}));this._pagePromises.set(e,r)}t.setActualNumPages(r.size)}}fontFallback(e,t){return this.catalog.fontFallback(e,t)}async cleanup(e=!1){return this.catalog?this.catalog.cleanup(e):clearGlobalCaches()}async#z(e,t,i,a,s,r,n){const{xref:g}=this;if(!(i instanceof Ref)||r.has(i))return;r.put(i);const o=await g.fetchAsync(i);if(!(o instanceof Dict))return;if(o.has("T")){const t=stringToPDFString(await o.getAsync("T"));e=""===e?t:`${e}.${t}`}else{let i=o;for(;;){i=i.getRaw("Parent")||t;if(i instanceof Ref){if(r.has(i))break;i=await g.fetchAsync(i)}if(!(i instanceof Dict))break;if(i.has("T")){const t=stringToPDFString(await i.getAsync("T"));e=""===e?t:`${e}.${t}`;break}}}t&&!o.has("Parent")&&isName(o.get("Subtype"),"Widget")&&n.put(i,t);a.has(e)||a.set(e,[]);a.get(e).push(AnnotationFactory.create(g,i,s,null,!0,n,null).then((e=>e?.getFieldObject())).catch((function(e){warn(`#collectFieldObjects: "${e}".`);return null})));if(!o.has("Kids"))return;const c=await o.getAsync("Kids");if(Array.isArray(c))for(const t of c)await this.#z(e,i,t,a,s,r,n)}get fieldObjects(){return shadow(this,"fieldObjects",this.pdfManager.ensureDoc("formInfo").then((async e=>{if(!e.hasFields)return null;const[t,i]=await Promise.all([this.pdfManager.ensureDoc("annotationGlobals"),this.pdfManager.ensureCatalog("acroForm")]);if(!t)return null;const a=new RefSet,s=Object.create(null),r=new Map,n=new RefSetCache;for(const e of await i.getAsync("Fields"))await this.#z("",null,e,r,t,a,n);const g=[];for(const[e,t]of r)g.push(Promise.all(t).then((t=>{(t=t.filter((e=>!!e))).length>0&&(s[e]=t)})));await Promise.all(g);return{allFields:s,orphanFields:n}})))}get hasJSActions(){return shadow(this,"hasJSActions",this.pdfManager.ensureDoc("_parseHasJSActions"))}async _parseHasJSActions(){const[e,t]=await Promise.all([this.pdfManager.ensureCatalog("jsActions"),this.pdfManager.ensureDoc("fieldObjects")]);return!!e||!!t&&Object.values(t.allFields).some((e=>e.some((e=>null!==e.actions))))}get calculationOrderIds(){const e=this.catalog.acroForm?.get("CO");if(!Array.isArray(e)||0===e.length)return shadow(this,"calculationOrderIds",null);const t=[];for(const i of e)i instanceof Ref&&t.push(i.toString());return shadow(this,"calculationOrderIds",t.length?t:null)}get annotationGlobals(){return shadow(this,"annotationGlobals",AnnotationFactory.createGlobals(this.pdfManager))}}class BasePdfManager{constructor(e){this._docBaseUrl=function parseDocBaseUrl(e){if(e){const t=createValidAbsoluteUrl(e);if(t)return t.href;warn(`Invalid absolute docBaseUrl: "${e}".`)}return null}(e.docBaseUrl);this._docId=e.docId;this._password=e.password;this.enableXfa=e.enableXfa;e.evaluatorOptions.isOffscreenCanvasSupported&&=FeatureTest.isOffscreenCanvasSupported;e.evaluatorOptions.isImageDecoderSupported&&=FeatureTest.isImageDecoderSupported;this.evaluatorOptions=Object.freeze(e.evaluatorOptions)}get docId(){return this._docId}get password(){return this._password}get docBaseUrl(){return this._docBaseUrl}get catalog(){return this.pdfDocument.catalog}ensureDoc(e,t){return this.ensure(this.pdfDocument,e,t)}ensureXRef(e,t){return this.ensure(this.pdfDocument.xref,e,t)}ensureCatalog(e,t){return this.ensure(this.pdfDocument.catalog,e,t)}getPage(e){return this.pdfDocument.getPage(e)}fontFallback(e,t){return this.pdfDocument.fontFallback(e,t)}loadXfaFonts(e,t){return this.pdfDocument.loadXfaFonts(e,t)}loadXfaImages(){return this.pdfDocument.loadXfaImages()}serializeXfaData(e){return this.pdfDocument.serializeXfaData(e)}cleanup(e=!1){return this.pdfDocument.cleanup(e)}async ensure(e,t,i){unreachable("Abstract method `ensure` called")}requestRange(e,t){unreachable("Abstract method `requestRange` called")}requestLoadedStream(e=!1){unreachable("Abstract method `requestLoadedStream` called")}sendProgressiveData(e){unreachable("Abstract method `sendProgressiveData` called")}updatePassword(e){this._password=e}terminate(e){unreachable("Abstract method `terminate` called")}}class LocalPdfManager extends BasePdfManager{constructor(e){super(e);const t=new Stream(e.source);this.pdfDocument=new PDFDocument(this,t);this._loadedStreamPromise=Promise.resolve(t)}async ensure(e,t,i){const a=e[t];return"function"==typeof a?a.apply(e,i):a}requestRange(e,t){return Promise.resolve()}requestLoadedStream(e=!1){return this._loadedStreamPromise}terminate(e){}}class NetworkPdfManager extends BasePdfManager{constructor(e){super(e);this.streamManager=new ChunkedStreamManager(e.source,{msgHandler:e.handler,length:e.length,disableAutoFetch:e.disableAutoFetch,rangeChunkSize:e.rangeChunkSize});this.pdfDocument=new PDFDocument(this,this.streamManager.getStream())}async ensure(e,t,i){try{const a=e[t];return"function"==typeof a?a.apply(e,i):a}catch(a){if(!(a instanceof MissingDataException))throw a;await this.requestRange(a.begin,a.end);return this.ensure(e,t,i)}}requestRange(e,t){return this.streamManager.requestRange(e,t)}requestLoadedStream(e=!1){return this.streamManager.requestAllChunks(e)}sendProgressiveData(e){this.streamManager.onReceiveData({chunk:e})}terminate(e){this.streamManager.abort(e)}}const rg=1,ng=2,gg=1,og=2,Ig=3,cg=4,Cg=5,hg=6,lg=7,Bg=8;function onFn(){}function wrapReason(e){if(e instanceof AbortException||e instanceof InvalidPDFException||e instanceof MissingPDFException||e instanceof PasswordException||e instanceof UnexpectedResponseException||e instanceof UnknownErrorException)return e;e instanceof Error||"object"==typeof e&&null!==e||unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');switch(e.name){case"AbortException":return new AbortException(e.message);case"InvalidPDFException":return new InvalidPDFException(e.message);case"MissingPDFException":return new MissingPDFException(e.message);case"PasswordException":return new PasswordException(e.message,e.code);case"UnexpectedResponseException":return new UnexpectedResponseException(e.message,e.status);case"UnknownErrorException":return new UnknownErrorException(e.message,e.details)}return new UnknownErrorException(e.message,e.toString())}class MessageHandler{#_=new AbortController;constructor(e,t,i){this.sourceName=e;this.targetName=t;this.comObj=i;this.callbackId=1;this.streamId=1;this.streamSinks=Object.create(null);this.streamControllers=Object.create(null);this.callbackCapabilities=Object.create(null);this.actionHandler=Object.create(null);i.addEventListener("message",this.#$.bind(this),{signal:this.#_.signal})}#$({data:e}){if(e.targetName!==this.sourceName)return;if(e.stream){this.#AA(e);return}if(e.callback){const t=e.callbackId,i=this.callbackCapabilities[t];if(!i)throw new Error(`Cannot resolve callback ${t}`);delete this.callbackCapabilities[t];if(e.callback===rg)i.resolve(e.data);else{if(e.callback!==ng)throw new Error("Unexpected callback case");i.reject(wrapReason(e.reason))}return}const t=this.actionHandler[e.action];if(!t)throw new Error(`Unknown action from worker: ${e.action}`);if(e.callbackId){const i=this.sourceName,a=e.sourceName,s=this.comObj;Promise.try(t,e.data).then((function(t){s.postMessage({sourceName:i,targetName:a,callback:rg,callbackId:e.callbackId,data:t})}),(function(t){s.postMessage({sourceName:i,targetName:a,callback:ng,callbackId:e.callbackId,reason:wrapReason(t)})}))}else e.streamId?this.#eA(e):t(e.data)}on(e,t){const i=this.actionHandler;if(i[e])throw new Error(`There is already an actionName called "${e}"`);i[e]=t}send(e,t,i){this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:e,data:t},i)}sendWithPromise(e,t,i){const a=this.callbackId++,s=Promise.withResolvers();this.callbackCapabilities[a]=s;try{this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:e,callbackId:a,data:t},i)}catch(e){s.reject(e)}return s.promise}sendWithStream(e,t,i,a){const s=this.streamId++,r=this.sourceName,n=this.targetName,g=this.comObj;return new ReadableStream({start:i=>{const o=Promise.withResolvers();this.streamControllers[s]={controller:i,startCall:o,pullCall:null,cancelCall:null,isClosed:!1};g.postMessage({sourceName:r,targetName:n,action:e,streamId:s,data:t,desiredSize:i.desiredSize},a);return o.promise},pull:e=>{const t=Promise.withResolvers();this.streamControllers[s].pullCall=t;g.postMessage({sourceName:r,targetName:n,stream:hg,streamId:s,desiredSize:e.desiredSize});return t.promise},cancel:e=>{assert(e instanceof Error,"cancel must have a valid reason");const t=Promise.withResolvers();this.streamControllers[s].cancelCall=t;this.streamControllers[s].isClosed=!0;g.postMessage({sourceName:r,targetName:n,stream:gg,streamId:s,reason:wrapReason(e)});return t.promise}},i)}#eA(e){const t=e.streamId,i=this.sourceName,a=e.sourceName,s=this.comObj,r=this,n=this.actionHandler[e.action],g={enqueue(e,r=1,n){if(this.isCancelled)return;const g=this.desiredSize;this.desiredSize-=r;if(g>0&&this.desiredSize<=0){this.sinkCapability=Promise.withResolvers();this.ready=this.sinkCapability.promise}s.postMessage({sourceName:i,targetName:a,stream:cg,streamId:t,chunk:e},n)},close(){if(!this.isCancelled){this.isCancelled=!0;s.postMessage({sourceName:i,targetName:a,stream:Ig,streamId:t});delete r.streamSinks[t]}},error(e){assert(e instanceof Error,"error must have a valid reason");if(!this.isCancelled){this.isCancelled=!0;s.postMessage({sourceName:i,targetName:a,stream:Cg,streamId:t,reason:wrapReason(e)})}},sinkCapability:Promise.withResolvers(),onPull:null,onCancel:null,isCancelled:!1,desiredSize:e.desiredSize,ready:null};g.sinkCapability.resolve();g.ready=g.sinkCapability.promise;this.streamSinks[t]=g;Promise.try(n,e.data,g).then((function(){s.postMessage({sourceName:i,targetName:a,stream:Bg,streamId:t,success:!0})}),(function(e){s.postMessage({sourceName:i,targetName:a,stream:Bg,streamId:t,reason:wrapReason(e)})}))}#AA(e){const t=e.streamId,i=this.sourceName,a=e.sourceName,s=this.comObj,r=this.streamControllers[t],n=this.streamSinks[t];switch(e.stream){case Bg:e.success?r.startCall.resolve():r.startCall.reject(wrapReason(e.reason));break;case lg:e.success?r.pullCall.resolve():r.pullCall.reject(wrapReason(e.reason));break;case hg:if(!n){s.postMessage({sourceName:i,targetName:a,stream:lg,streamId:t,success:!0});break}n.desiredSize<=0&&e.desiredSize>0&&n.sinkCapability.resolve();n.desiredSize=e.desiredSize;Promise.try(n.onPull||onFn).then((function(){s.postMessage({sourceName:i,targetName:a,stream:lg,streamId:t,success:!0})}),(function(e){s.postMessage({sourceName:i,targetName:a,stream:lg,streamId:t,reason:wrapReason(e)})}));break;case cg:assert(r,"enqueue should have stream controller");if(r.isClosed)break;r.controller.enqueue(e.chunk);break;case Ig:assert(r,"close should have stream controller");if(r.isClosed)break;r.isClosed=!0;r.controller.close();this.#tA(r,t);break;case Cg:assert(r,"error should have stream controller");r.controller.error(wrapReason(e.reason));this.#tA(r,t);break;case og:e.success?r.cancelCall.resolve():r.cancelCall.reject(wrapReason(e.reason));this.#tA(r,t);break;case gg:if(!n)break;const g=wrapReason(e.reason);Promise.try(n.onCancel||onFn,g).then((function(){s.postMessage({sourceName:i,targetName:a,stream:og,streamId:t,success:!0})}),(function(e){s.postMessage({sourceName:i,targetName:a,stream:og,streamId:t,reason:wrapReason(e)})}));n.sinkCapability.reject(g);n.isCancelled=!0;delete this.streamSinks[t];break;default:throw new Error("Unexpected stream case")}}async#tA(e,t){await Promise.allSettled([e.startCall?.promise,e.pullCall?.promise,e.cancelCall?.promise]);delete this.streamControllers[t]}destroy(){this.#_?.abort();this.#_=null}}async function writeObject(e,t,i,{encrypt:a=null}){const s=a?.createCipherTransform(e.num,e.gen);i.push(`${e.num} ${e.gen} obj\n`);t instanceof Dict?await writeDict(t,i,s):t instanceof BaseStream?await writeStream(t,i,s):(Array.isArray(t)||ArrayBuffer.isView(t))&&await writeArray(t,i,s);i.push("\nendobj\n")}async function writeDict(e,t,i){t.push("<<");for(const a of e.getKeys()){t.push(` /${escapePDFName(a)} `);await writeValue(e.getRaw(a),t,i)}t.push(">>")}async function writeStream(e,t,i){let a=e.getBytes();const{dict:s}=e,[r,n]=await Promise.all([s.getAsync("Filter"),s.getAsync("DecodeParms")]),g=isName(Array.isArray(r)?await s.xref.fetchIfRefAsync(r[0]):r,"FlateDecode");if(a.length>=256||g)try{const e=new CompressionStream("deflate"),t=e.writable.getWriter();await t.ready;t.write(a).then((async()=>{await t.ready;await t.close()})).catch((()=>{}));const i=await new Response(e.readable).arrayBuffer();a=new Uint8Array(i);let o,c;if(r){if(!g){o=Array.isArray(r)?[Name.get("FlateDecode"),...r]:[Name.get("FlateDecode"),r];n&&(c=Array.isArray(n)?[null,...n]:[null,n])}}else o=Name.get("FlateDecode");o&&s.set("Filter",o);c&&s.set("DecodeParms",c)}catch(e){info(`writeStream - cannot compress data: "${e}".`)}let o=bytesToString(a);i&&(o=i.encryptString(o));s.set("Length",o.length);await writeDict(s,t,i);t.push(" stream\n",o,"\nendstream")}async function writeArray(e,t,i){t.push("[");let a=!0;for(const s of e){a?a=!1:t.push(" ");await writeValue(s,t,i)}t.push("]")}async function writeValue(e,t,i){if(e instanceof Name)t.push(`/${escapePDFName(e.name)}`);else if(e instanceof Ref)t.push(`${e.num} ${e.gen} R`);else if(Array.isArray(e)||ArrayBuffer.isView(e))await writeArray(e,t,i);else if("string"==typeof e){i&&(e=i.encryptString(e));t.push(`(${escapeString(e)})`)}else"number"==typeof e?t.push(numberToString(e)):"boolean"==typeof e?t.push(e.toString()):e instanceof Dict?await writeDict(e,t,i):e instanceof BaseStream?await writeStream(e,t,i):null===e?t.push("null"):warn(`Unhandled value in writer: ${typeof e}, please file a bug.`)}function writeInt(e,t,i,a){for(let s=t+i-1;s>i-1;s--){a[s]=255&e;e>>=8}return i+t}function writeString(e,t,i){for(let a=0,s=e.length;a1&&(r=i.documentElement.searchNode([s.at(-1)],0));r?r.childNodes=Array.isArray(a)?a.map((e=>new SimpleDOMNode("value",e))):[new SimpleDOMNode("#text",a)]:warn(`Node not found for path: ${t}`)}const a=[];i.documentElement.dump(a);return a.join("")}(a.fetchIfRef(t).getString(),i)}const s=new StringStream(e);s.dict=new Dict(a);s.dict.set("Type",Name.get("EmbeddedFile"));i.put(t,{data:s})}function getIndexes(e){const t=[];for(const{ref:i}of e)i.num===t.at(-2)+t.at(-1)?t[t.length-1]+=1:t.push(i.num,1);return t}function computeIDs(e,t,i){if(Array.isArray(t.fileIds)&&t.fileIds.length>0){const a=function computeMD5(e,t){const i=Math.floor(Date.now()/1e3),a=t.filename||"",s=[i.toString(),a,e.toString()];let r=s.reduce(((e,t)=>e+t.length),0);for(const e of Object.values(t.info)){s.push(e);r+=e.length}const n=new Uint8Array(r);let g=0;for(const e of s){writeString(e,g,n);g+=e.length}return bytesToString($n(n))}(e,t);i.set("ID",[t.fileIds[0],a])}}async function incrementalUpdate({originalData:e,xrefInfo:t,changes:i,xref:a=null,hasXfa:s=!1,xfaDatasetsRef:r=null,hasXfaDatasetsEntry:n=!1,needAppearances:g,acroFormRef:o=null,acroForm:c=null,xfaData:C=null,useXrefStream:h=!1}){await async function updateAcroform({xref:e,acroForm:t,acroFormRef:i,hasXfa:a,hasXfaDatasetsEntry:s,xfaDatasetsRef:r,needAppearances:n,changes:g}){!a||s||r||warn("XFA - Cannot save it");if(!n&&(!a||!r||s))return;const o=t.clone();if(a&&!s){const e=t.get("XFA").slice();e.splice(2,0,"datasets");e.splice(3,0,r);o.set("XFA",e)}n&&o.set("NeedAppearances",!0);g.put(i,{data:o})}({xref:a,acroForm:c,acroFormRef:o,hasXfa:s,hasXfaDatasetsEntry:n,xfaDatasetsRef:r,needAppearances:g,changes:i});s&&updateXFA({xfaData:C,xfaDatasetsRef:r,changes:i,xref:a});const l=function getTrailerDict(e,t,i){const a=new Dict(null);a.set("Prev",e.startXRef);const s=e.newRef;if(i){t.put(s,{data:""});a.set("Size",s.num+1);a.set("Type",Name.get("XRef"))}else a.set("Size",s.num);null!==e.rootRef&&a.set("Root",e.rootRef);null!==e.infoRef&&a.set("Info",e.infoRef);null!==e.encryptRef&&a.set("Encrypt",e.encryptRef);return a}(t,i,h),Q=[],E=await async function writeChanges(e,t,i=[]){const a=[];for(const[s,{data:r}]of e.items())if(null!==r&&"string"!=typeof r){await writeObject(s,r,i,t);a.push({ref:s,data:i.join("")});i.length=0}else a.push({ref:s,data:r});return a.sort(((e,t)=>e.ref.num-t.ref.num))}(i,a,Q);let u=e.length;const d=e.at(-1);if(10!==d&&13!==d){Q.push("\n");u+=1}for(const{data:e}of E)null!==e&&Q.push(e);await(h?async function getXRefStreamTable(e,t,i,a,s){const r=[];let n=0,g=0;for(const{ref:e,data:a}of i){let i;n=Math.max(n,t);if(null!==a){i=Math.min(e.gen,65535);r.push([1,t,i]);t+=a.length}else{i=Math.min(e.gen+1,65535);r.push([0,0,i])}g=Math.max(g,i)}a.set("Index",getIndexes(i));const o=[1,getSizeInBytes(n),getSizeInBytes(g)];a.set("W",o);computeIDs(t,e,a);const c=o.reduce(((e,t)=>e+t),0),C=new Uint8Array(c*r.length),h=new Stream(C);h.dict=a;let l=0;for(const[e,t,i]of r){l=writeInt(e,o[0],l,C);l=writeInt(t,o[1],l,C);l=writeInt(i,o[2],l,C)}await writeObject(e.newRef,h,s,{});s.push("startxref\n",t.toString(),"\n%%EOF\n")}(t,u,E,l,Q):async function getXRefTable(e,t,i,a,s){s.push("xref\n");const r=getIndexes(i);let n=0;for(const{ref:e,data:a}of i){if(e.num===r[n]){s.push(`${r[n]} ${r[n+1]}\n`);n+=2}if(null!==a){s.push(`${t.toString().padStart(10,"0")} ${Math.min(e.gen,65535).toString().padStart(5,"0")} n\r\n`);t+=a.length}else s.push(`0000000000 ${Math.min(e.gen+1,65535).toString().padStart(5,"0")} f\r\n`)}computeIDs(t,e,a);s.push("trailer\n");await writeDict(a,s);s.push("\nstartxref\n",t.toString(),"\n%%EOF\n")}(t,u,E,l,Q));const f=Q.reduce(((e,t)=>e+t.length),e.length),p=new Uint8Array(f);p.set(e);let m=e.length;for(const e of Q){writeString(e,m,p);m+=e.length}return p}class PDFWorkerStream{constructor(e){this._msgHandler=e;this._contentLength=null;this._fullRequestReader=null;this._rangeRequestReaders=[]}getFullReader(){assert(!this._fullRequestReader,"PDFWorkerStream.getFullReader can only be called once.");this._fullRequestReader=new PDFWorkerStreamReader(this._msgHandler);return this._fullRequestReader}getRangeReader(e,t){const i=new PDFWorkerStreamRangeReader(e,t,this._msgHandler);this._rangeRequestReaders.push(i);return i}cancelAllRequests(e){this._fullRequestReader?.cancel(e);for(const t of this._rangeRequestReaders.slice(0))t.cancel(e)}}class PDFWorkerStreamReader{constructor(e){this._msgHandler=e;this.onProgress=null;this._contentLength=null;this._isRangeSupported=!1;this._isStreamingSupported=!1;const t=this._msgHandler.sendWithStream("GetReader");this._reader=t.getReader();this._headersReady=this._msgHandler.sendWithPromise("ReaderHeadersReady").then((e=>{this._isStreamingSupported=e.isStreamingSupported;this._isRangeSupported=e.isRangeSupported;this._contentLength=e.contentLength}))}get headersReady(){return this._headersReady}get contentLength(){return this._contentLength}get isStreamingSupported(){return this._isStreamingSupported}get isRangeSupported(){return this._isRangeSupported}async read(){const{value:e,done:t}=await this._reader.read();return t?{value:void 0,done:!0}:{value:e.buffer,done:!1}}cancel(e){this._reader.cancel(e)}}class PDFWorkerStreamRangeReader{constructor(e,t,i){this._msgHandler=i;this.onProgress=null;const a=this._msgHandler.sendWithStream("GetRangeReader",{begin:e,end:t});this._reader=a.getReader()}get isStreamingSupported(){return!1}async read(){const{value:e,done:t}=await this._reader.read();return t?{value:void 0,done:!0}:{value:e.buffer,done:!1}}cancel(e){this._reader.cancel(e)}}class WorkerTask{constructor(e){this.name=e;this.terminated=!1;this._capability=Promise.withResolvers()}get finished(){return this._capability.promise}finish(){this._capability.resolve()}terminate(){this.terminated=!0}ensureNotTerminated(){if(this.terminated)throw new Error("Worker task was terminated")}}class WorkerMessageHandler{static{"undefined"==typeof window&&!t&&"undefined"!=typeof self&&"function"==typeof self.postMessage&&"onmessage"in self&&this.initializeFromPort(self)}static setup(e,t){let i=!1;e.on("test",(t=>{if(!i){i=!0;e.send("test",t instanceof Uint8Array)}}));e.on("configure",(e=>{!function setVerbosityLevel(e){Number.isInteger(e)&&(gt=e)}(e.verbosity)}));e.on("GetDocRequest",(e=>this.createDocumentHandler(e,t)))}static createDocumentHandler(e,t){let i,a=!1,s=null;const r=new Set,n=getVerbosityLevel(),{docId:g,apiVersion:o}=e,c="4.10.38";if(o!==c)throw new Error(`The API version "${o}" does not match the Worker version "${c}".`);const C=[];for(const e in[])C.push(e);if(C.length)throw new Error("The `Array.prototype` contains unexpected enumerable properties: "+C.join(", ")+"; thus breaking e.g. `for...in` iteration of `Array`s.");const h=g+"_worker";let l=new MessageHandler(h,g,t);function ensureNotTerminated(){if(a)throw new Error("Worker was terminated")}function startWorkerTask(e){r.add(e)}function finishWorkerTask(e){e.finish();r.delete(e)}async function loadDocument(e){await i.ensureDoc("checkHeader");await i.ensureDoc("parseStartXRef");await i.ensureDoc("parse",[e]);await i.ensureDoc("checkFirstPage",[e]);await i.ensureDoc("checkLastPage",[e]);const t=await i.ensureDoc("isPureXfa");if(t){const e=new WorkerTask("loadXfaFonts");startWorkerTask(e);await Promise.all([i.loadXfaFonts(l,e).catch((e=>{})).then((()=>finishWorkerTask(e))),i.loadXfaImages()])}const[a,s]=await Promise.all([i.ensureDoc("numPages"),i.ensureDoc("fingerprints")]);return{numPages:a,fingerprints:s,htmlForXfa:t?await i.ensureDoc("htmlForXfa"):null}}function setupDoc(e){function onSuccess(e){ensureNotTerminated();l.send("GetDoc",{pdfInfo:e})}function onFailure(e){ensureNotTerminated();if(e instanceof PasswordException){const t=new WorkerTask(`PasswordException: response ${e.code}`);startWorkerTask(t);l.sendWithPromise("PasswordRequest",e).then((function({password:e}){finishWorkerTask(t);i.updatePassword(e);pdfManagerReady()})).catch((function(){finishWorkerTask(t);l.send("DocException",e)}))}else l.send("DocException",wrapReason(e))}function pdfManagerReady(){ensureNotTerminated();loadDocument(!1).then(onSuccess,(function(e){ensureNotTerminated();e instanceof XRefParseException?i.requestLoadedStream().then((function(){ensureNotTerminated();loadDocument(!0).then(onSuccess,onFailure)})):onFailure(e)}))}ensureNotTerminated();(async function getPdfManager({data:e,password:t,disableAutoFetch:i,rangeChunkSize:a,length:r,docBaseUrl:n,enableXfa:o,evaluatorOptions:c}){const C={source:null,disableAutoFetch:i,docBaseUrl:n,docId:g,enableXfa:o,evaluatorOptions:c,handler:l,length:r,password:t,rangeChunkSize:a};if(e){C.source=e;return new LocalPdfManager(C)}const h=new PDFWorkerStream(l),Q=h.getFullReader(),E=Promise.withResolvers();let u,d=[],f=0;Q.headersReady.then((function(){if(Q.isRangeSupported){C.source=h;C.length=Q.contentLength;C.disableAutoFetch||=Q.isStreamingSupported;u=new NetworkPdfManager(C);for(const e of d)u.sendProgressiveData(e);d=[];E.resolve(u);s=null}})).catch((function(e){E.reject(e);s=null}));new Promise((function(e,t){const readChunk=function({value:e,done:i}){try{ensureNotTerminated();if(i){if(!u){const e=arrayBuffersToBytes(d);d=[];r&&e.length!==r&&warn("reported HTTP length is different from actual");C.source=e;u=new LocalPdfManager(C);E.resolve(u)}s=null;return}f+=e.byteLength;Q.isStreamingSupported||l.send("DocProgress",{loaded:f,total:Math.max(f,Q.contentLength||0)});u?u.sendProgressiveData(e):d.push(e);Q.read().then(readChunk,t)}catch(e){t(e)}};Q.read().then(readChunk,t)})).catch((function(e){E.reject(e);s=null}));s=e=>{h.cancelAllRequests(e)};return E.promise})(e).then((function(e){if(a){e.terminate(new AbortException("Worker was terminated."));throw new Error("Worker was terminated")}i=e;i.requestLoadedStream(!0).then((e=>{l.send("DataLoaded",{length:e.bytes.byteLength})}))})).then(pdfManagerReady,onFailure)}l.on("GetPage",(function(e){return i.getPage(e.pageIndex).then((function(e){return Promise.all([i.ensure(e,"rotate"),i.ensure(e,"ref"),i.ensure(e,"userUnit"),i.ensure(e,"view")]).then((function([e,t,i,a]){return{rotate:e,ref:t,refStr:t?.toString()??null,userUnit:i,view:a}}))}))}));l.on("GetPageIndex",(function(e){const t=Ref.get(e.num,e.gen);return i.ensureCatalog("getPageIndex",[t])}));l.on("GetDestinations",(function(e){return i.ensureCatalog("destinations")}));l.on("GetDestination",(function(e){return i.ensureCatalog("getDestination",[e.id])}));l.on("GetPageLabels",(function(e){return i.ensureCatalog("pageLabels")}));l.on("GetPageLayout",(function(e){return i.ensureCatalog("pageLayout")}));l.on("GetPageMode",(function(e){return i.ensureCatalog("pageMode")}));l.on("GetViewerPreferences",(function(e){return i.ensureCatalog("viewerPreferences")}));l.on("GetOpenAction",(function(e){return i.ensureCatalog("openAction")}));l.on("GetAttachments",(function(e){return i.ensureCatalog("attachments")}));l.on("GetDocJSActions",(function(e){return i.ensureCatalog("jsActions")}));l.on("GetPageJSActions",(function({pageIndex:e}){return i.getPage(e).then((function(e){return i.ensure(e,"jsActions")}))}));l.on("GetOutline",(function(e){return i.ensureCatalog("documentOutline")}));l.on("GetOptionalContentConfig",(function(e){return i.ensureCatalog("optionalContentConfig")}));l.on("GetPermissions",(function(e){return i.ensureCatalog("permissions")}));l.on("GetMetadata",(function(e){return Promise.all([i.ensureDoc("documentInfo"),i.ensureCatalog("metadata")])}));l.on("GetMarkInfo",(function(e){return i.ensureCatalog("markInfo")}));l.on("GetData",(function(e){return i.requestLoadedStream().then((function(e){return e.bytes}))}));l.on("GetAnnotations",(function({pageIndex:e,intent:t}){return i.getPage(e).then((function(i){const a=new WorkerTask(`GetAnnotations: page ${e}`);startWorkerTask(a);return i.getAnnotationsData(l,a,t).then((e=>{finishWorkerTask(a);return e}),(e=>{finishWorkerTask(a);throw e}))}))}));l.on("GetFieldObjects",(function(e){return i.ensureDoc("fieldObjects").then((e=>e?.allFields||null))}));l.on("HasJSActions",(function(e){return i.ensureDoc("hasJSActions")}));l.on("GetCalculationOrderIds",(function(e){return i.ensureDoc("calculationOrderIds")}));l.on("SaveDocument",(async function({isPureXfa:e,numPages:t,annotationStorage:a,filename:s}){const r=[i.requestLoadedStream(),i.ensureCatalog("acroForm"),i.ensureCatalog("acroFormRef"),i.ensureDoc("startXRef"),i.ensureDoc("xref"),i.ensureDoc("linearization"),i.ensureCatalog("structTreeRoot")],n=new RefSetCache,g=[],o=e?null:getNewAnnotationsMap(a),[c,C,h,Q,E,u,d]=await Promise.all(r),f=E.trailer.getRaw("Root")||null;let p;if(o){d?await d.canUpdateStructTree({pdfManager:i,xref:E,newAnnotationsByPage:o})&&(p=d):await StructTreeRoot.canCreateStructureTree({catalogRef:f,pdfManager:i,newAnnotationsByPage:o})&&(p=null);const e=AnnotationFactory.generateImages(a.values(),E,i.evaluatorOptions.isOffscreenCanvasSupported),t=void 0===p?g:[];for(const[a,s]of o)t.push(i.getPage(a).then((t=>{const i=new WorkerTask(`Save (editor): page ${a}`);startWorkerTask(i);return t.saveNewAnnotations(l,i,s,e,n).finally((function(){finishWorkerTask(i)}))})));null===p?g.push(Promise.all(t).then((async()=>{await StructTreeRoot.createStructureTree({newAnnotationsByPage:o,xref:E,catalogRef:f,pdfManager:i,changes:n})}))):p&&g.push(Promise.all(t).then((async()=>{await p.updateStructureTree({newAnnotationsByPage:o,pdfManager:i,changes:n})})))}if(e)g.push(i.serializeXfaData(a));else for(let e=0;ee.needAppearances)),D=C instanceof Dict&&C.get("XFA")||null;let b=null,F=!1;if(Array.isArray(D)){for(let e=0,t=D.length;e{E.resetNewTemporaryRef()}))}));l.on("GetOperatorList",(function(e,t){const a=e.pageIndex;i.getPage(a).then((function(i){const s=new WorkerTask(`GetOperatorList: page ${a}`);startWorkerTask(s);const r=n>=yA?Date.now():0;i.getOperatorList({handler:l,sink:t,task:s,intent:e.intent,cacheKey:e.cacheKey,annotationStorage:e.annotationStorage,modifiedIds:e.modifiedIds}).then((function(e){finishWorkerTask(s);r&&info(`page=${a+1} - getOperatorList: time=${Date.now()-r}ms, len=${e.length}`);t.close()}),(function(e){finishWorkerTask(s);s.terminated||t.error(e)}))}))}));l.on("GetTextContent",(function(e,t){const{pageIndex:a,includeMarkedContent:s,disableNormalization:r}=e;i.getPage(a).then((function(e){const i=new WorkerTask("GetTextContent: page "+a);startWorkerTask(i);const g=n>=yA?Date.now():0;e.extractTextContent({handler:l,task:i,sink:t,includeMarkedContent:s,disableNormalization:r}).then((function(){finishWorkerTask(i);g&&info(`page=${a+1} - getTextContent: time=`+(Date.now()-g)+"ms");t.close()}),(function(e){finishWorkerTask(i);i.terminated||t.error(e)}))}))}));l.on("GetStructTree",(function(e){return i.getPage(e.pageIndex).then((function(e){return i.ensure(e,"getStructTree")}))}));l.on("FontFallback",(function(e){return i.fontFallback(e.id,l)}));l.on("Cleanup",(function(e){return i.cleanup(!0)}));l.on("Terminate",(function(e){a=!0;const t=[];if(i){i.terminate(new AbortException("Worker was terminated."));const e=i.cleanup();t.push(e);i=null}else clearGlobalCaches();s?.(new AbortException("Worker was terminated."));for(const e of r){t.push(e.finished);e.terminate()}return Promise.all(t).then((function(){l.destroy();l=null}))}));l.on("Ready",(function(t){setupDoc(e);e=null}));return h}static initializeFromPort(e){const t=new MessageHandler("worker","main",e);this.setup(t,e);t.send("ready",null)}}var Qg=__webpack_exports__.WorkerMessageHandler;export{Qg as WorkerMessageHandler}; \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif new file mode 100644 index 00000000000..1c72ebb554b Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif differ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css new file mode 100644 index 00000000000..86a3716c501 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css @@ -0,0 +1,3274 @@ +/* Copyright 2014 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.messageBar{ + --closing-button-icon:url(images/messageBar_closingButton.svg); + --message-bar-close-button-color:var(--text-primary-color); + --message-bar-close-button-color-hover:var(--text-primary-color); + --message-bar-close-button-border-radius:4px; + --message-bar-close-button-border:none; + --message-bar-close-button-hover-bg-color:rgb(21 20 26 / 0.14); + --message-bar-close-button-active-bg-color:rgb(21 20 26 / 0.21); + --message-bar-close-button-focus-bg-color:rgb(21 20 26 / 0.07); +} + +@media (prefers-color-scheme: dark){ + +.messageBar{ + --message-bar-close-button-hover-bg-color:rgb(251 251 254 / 0.14); + --message-bar-close-button-active-bg-color:rgb(251 251 254 / 0.21); + --message-bar-close-button-focus-bg-color:rgb(251 251 254 / 0.07); +} + } + +@media screen and (forced-colors: active){ + +.messageBar{ + --message-bar-close-button-color:ButtonText; + --message-bar-close-button-border:1px solid ButtonText; + --message-bar-close-button-hover-bg-color:ButtonText; + --message-bar-close-button-active-bg-color:ButtonText; + --message-bar-close-button-focus-bg-color:ButtonText; + --message-bar-close-button-color-hover:HighlightText; +} + } + +.messageBar{ + + display:flex; + position:relative; + padding:8px 8px 8px 16px; + flex-direction:column; + justify-content:center; + align-items:center; + gap:8px; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + + border-radius:4px; + + border:1px solid var(--message-bar-border-color); + background:var(--message-bar-bg-color); + color:var(--message-bar-fg-color); +} + +.messageBar > div{ + display:flex; + align-items:flex-start; + gap:8px; + align-self:stretch; + } + +:is(.messageBar > div)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--message-bar-icon); + mask-image:var(--message-bar-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--message-bar-icon-color); + flex-shrink:0; + } + +.messageBar button{ + cursor:pointer; + } + +:is(.messageBar button):focus-visible{ + outline:var(--focus-ring-outline); + outline-offset:2px; + } + +.messageBar .closeButton{ + width:32px; + height:32px; + background:none; + border-radius:var(--message-bar-close-button-border-radius); + border:var(--message-bar-close-button-border); + + display:flex; + align-items:center; + justify-content:center; + } + +:is(.messageBar .closeButton)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--closing-button-icon); + mask-image:var(--closing-button-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--message-bar-close-button-color); + } + +:is(.messageBar .closeButton):is(:hover,:active,:focus)::before{ + background-color:var(--message-bar-close-button-color-hover); + } + +:is(.messageBar .closeButton):hover{ + background-color:var(--message-bar-close-button-hover-bg-color); + } + +:is(.messageBar .closeButton):active{ + background-color:var(--message-bar-close-button-active-bg-color); + } + +:is(.messageBar .closeButton):focus{ + background-color:var(--message-bar-close-button-focus-bg-color); + } + +:is(.messageBar .closeButton) > span{ + display:inline-block; + width:0; + height:0; + overflow:hidden; + } + +#editorUndoBar{ + --text-primary-color:#15141a; + + --message-bar-icon:url(images/secondaryToolbarButton-documentProperties.svg); + --message-bar-icon-color:#0060df; + --message-bar-bg-color:#deeafc; + --message-bar-fg-color:var(--text-primary-color); + --message-bar-border-color:rgb(0 0 0 / 0.08); + + --undo-button-bg-color:rgb(21 20 26 / 0.07); + --undo-button-bg-color-hover:rgb(21 20 26 / 0.14); + --undo-button-bg-color-active:rgb(21 20 26 / 0.21); + + --undo-button-fg-color:var(--message-bar-fg-color); + --undo-button-fg-color-hover:var(--undo-button-fg-color); + --undo-button-fg-color-active:var(--undo-button-fg-color); + + --focus-ring-color:#0060df; + --focus-ring-outline:2px solid var(--focus-ring-color); +} + +@media (prefers-color-scheme: dark){ + +#editorUndoBar{ + --text-primary-color:#fbfbfe; + + --message-bar-icon-color:#73a7f3; + --message-bar-bg-color:#003070; + --message-bar-border-color:rgb(255 255 255 / 0.08); + + --undo-button-bg-color:rgb(255 255 255 / 0.08); + --undo-button-bg-color-hover:rgb(255 255 255 / 0.14); + --undo-button-bg-color-active:rgb(255 255 255 / 0.21); +} + } + +@media screen and (forced-colors: active){ + +#editorUndoBar{ + --text-primary-color:CanvasText; + + --message-bar-icon-color:CanvasText; + --message-bar-bg-color:Canvas; + --message-bar-border-color:CanvasText; + + --undo-button-bg-color:ButtonText; + --undo-button-bg-color-hover:SelectedItem; + --undo-button-bg-color-active:SelectedItem; + + --undo-button-fg-color:ButtonFace; + --undo-button-fg-color-hover:SelectedItemText; + --undo-button-fg-color-active:SelectedItemText; + + --focus-ring-color:CanvasText; +} + } + +#editorUndoBar{ + + position:fixed; + top:50px; + left:50%; + transform:translateX(-50%); + z-index:10; + + padding-block:8px; + padding-inline:16px 8px; + + font:menu; + font-size:15px; + + cursor:default; +} + +#editorUndoBar button{ + cursor:pointer; + } + +#editorUndoBar #editorUndoBarUndoButton{ + border-radius:4px; + font-weight:590; + line-height:19.5px; + color:var(--undo-button-fg-color); + border:none; + padding:4px 16px; + margin-inline-start:8px; + height:32px; + + background-color:var(--undo-button-bg-color); + } + +:is(#editorUndoBar #editorUndoBarUndoButton):hover{ + background-color:var(--undo-button-bg-color-hover); + color:var(--undo-button-fg-color-hover); + } + +:is(#editorUndoBar #editorUndoBarUndoButton):active{ + background-color:var(--undo-button-bg-color-active); + color:var(--undo-button-fg-color-active); + } + +#editorUndoBar > div{ + align-items:center; + } + +.dialog{ + --dialog-bg-color:white; + --dialog-border-color:white; + --dialog-shadow:0 2px 14px 0 rgb(58 57 68 / 0.2); + --text-primary-color:#15141a; + --text-secondary-color:#5b5b66; + --hover-filter:brightness(0.9); + --focus-ring-color:#0060df; + --focus-ring-outline:2px solid var(--focus-ring-color); + --link-fg-color:#0060df; + --link-hover-fg-color:#0250bb; + --separator-color:#f0f0f4; + + --textarea-border-color:#8f8f9d; + --textarea-bg-color:white; + --textarea-fg-color:var(--text-secondary-color); + + --radio-bg-color:#f0f0f4; + --radio-checked-bg-color:#fbfbfe; + --radio-border-color:#8f8f9d; + --radio-checked-border-color:#0060df; + + --button-secondary-bg-color:#f0f0f4; + --button-secondary-fg-color:var(--text-primary-color); + --button-secondary-border-color:var(--button-secondary-bg-color); + --button-secondary-hover-bg-color:var(--button-secondary-bg-color); + --button-secondary-hover-fg-color:var(--button-secondary-fg-color); + --button-secondary-hover-border-color:var(--button-secondary-hover-bg-color); + + --button-primary-bg-color:#0060df; + --button-primary-fg-color:#fbfbfe; + --button-primary-border-color:var(--button-primary-bg-color); + --button-primary-hover-bg-color:var(--button-primary-bg-color); + --button-primary-hover-fg-color:var(--button-primary-fg-color); + --button-primary-hover-border-color:var(--button-primary-hover-bg-color); +} + +@media (prefers-color-scheme: dark){ + +.dialog{ + --dialog-bg-color:#1c1b22; + --dialog-border-color:#1c1b22; + --dialog-shadow:0 2px 14px 0 #15141a; + --text-primary-color:#fbfbfe; + --text-secondary-color:#cfcfd8; + --focus-ring-color:#0df; + --hover-filter:brightness(1.4); + --link-fg-color:#0df; + --link-hover-fg-color:#80ebff; + --separator-color:#52525e; + + --textarea-bg-color:#42414d; + + --radio-bg-color:#2b2a33; + --radio-checked-bg-color:#15141a; + --radio-checked-border-color:#0df; + + --button-secondary-bg-color:#2b2a33; + --button-primary-bg-color:#0df; + --button-primary-fg-color:#15141a; +} + } + +@media screen and (forced-colors: active){ + +.dialog{ + --dialog-bg-color:Canvas; + --dialog-border-color:CanvasText; + --dialog-shadow:none; + --text-primary-color:CanvasText; + --text-secondary-color:CanvasText; + --hover-filter:none; + --focus-ring-color:ButtonBorder; + --link-fg-color:LinkText; + --link-hover-fg-color:LinkText; + --separator-color:CanvasText; + + --textarea-border-color:ButtonBorder; + --textarea-bg-color:Field; + --textarea-fg-color:ButtonText; + + --radio-bg-color:ButtonFace; + --radio-checked-bg-color:ButtonFace; + --radio-border-color:ButtonText; + --radio-checked-border-color:ButtonText; + + --button-secondary-bg-color:ButtonFace; + --button-secondary-fg-color:ButtonText; + --button-secondary-border-color:ButtonText; + --button-secondary-hover-bg-color:AccentColor; + --button-secondary-hover-fg-color:AccentColorText; + + --button-primary-bg-color:ButtonText; + --button-primary-fg-color:ButtonFace; + --button-primary-hover-bg-color:AccentColor; + --button-primary-hover-fg-color:AccentColorText; +} + } + +.dialog{ + + font:message-box; + font-size:13px; + font-weight:400; + line-height:150%; + border-radius:4px; + padding:12px 16px; + border:1px solid var(--dialog-border-color); + background:var(--dialog-bg-color); + color:var(--text-primary-color); + box-shadow:var(--dialog-shadow); +} + +:is(.dialog .mainContainer) *:focus-visible{ + outline:var(--focus-ring-outline); + outline-offset:2px; + } + +:is(.dialog .mainContainer) .title{ + display:flex; + width:auto; + flex-direction:column; + justify-content:flex-end; + align-items:flex-start; + gap:12px; + } + +:is(:is(.dialog .mainContainer) .title) > span{ + font-size:13px; + font-style:normal; + font-weight:590; + line-height:150%; + } + +:is(.dialog .mainContainer) .dialogSeparator{ + width:100%; + height:0; + margin-block:4px; + border-top:1px solid var(--separator-color); + border-bottom:none; + } + +:is(.dialog .mainContainer) .dialogButtonsGroup{ + display:flex; + gap:12px; + align-self:flex-end; + } + +:is(.dialog .mainContainer) .radio{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:4px; + } + +:is(:is(.dialog .mainContainer) .radio) > .radioButton{ + display:flex; + gap:8px; + align-self:stretch; + align-items:center; + } + +:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input{ + -webkit-appearance:none; + -moz-appearance:none; + appearance:none; + box-sizing:border-box; + width:16px; + height:16px; + border-radius:50%; + background-color:var(--radio-bg-color); + border:1px solid var(--radio-border-color); + } + +:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):hover{ + filter:var(--hover-filter); + } + +:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):checked{ + background-color:var(--radio-checked-bg-color); + border:4px solid var(--radio-checked-border-color); + } + +:is(:is(.dialog .mainContainer) .radio) > .radioLabel{ + display:flex; + padding-inline-start:24px; + align-items:flex-start; + gap:10px; + align-self:stretch; + } + +:is(:is(:is(.dialog .mainContainer) .radio) > .radioLabel) > span{ + flex:1 0 0; + font-size:11px; + color:var(--text-secondary-color); + } + +:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton)){ + border-radius:4px; + border:1px solid; + font:menu; + font-weight:600; + padding:4px 16px; + width:auto; + height:32px; + } + +:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))):hover{ + cursor:pointer; + filter:var(--hover-filter); + } + +.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))){ + color:var(--button-secondary-fg-color); + background-color:var(--button-secondary-bg-color); + border-color:var(--button-secondary-border-color); + } + +.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))):hover{ + color:var(--button-secondary-hover-fg-color); + background-color:var(--button-secondary-hover-bg-color); + border-color:var(--button-secondary-hover-border-color); + } + +.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))){ + color:var(--button-primary-fg-color); + background-color:var(--button-primary-bg-color); + border-color:var(--button-primary-border-color); + opacity:1; + } + +.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))):hover{ + color:var(--button-primary-hover-fg-color); + background-color:var(--button-primary-hover-bg-color); + border-color:var(--button-primary-hover-border-color); + } + +:is(.dialog .mainContainer) a{ + color:var(--link-fg-color); + } + +:is(:is(.dialog .mainContainer) a):hover{ + color:var(--link-hover-fg-color); + } + +:is(.dialog .mainContainer) textarea{ + font:inherit; + padding:8px; + resize:none; + margin:0; + box-sizing:border-box; + border-radius:4px; + border:1px solid var(--textarea-border-color); + background:var(--textarea-bg-color); + color:var(--textarea-fg-color); + } + +:is(:is(.dialog .mainContainer) textarea):focus{ + outline-offset:0; + border-color:transparent; + } + +:is(:is(.dialog .mainContainer) textarea):disabled{ + pointer-events:none; + opacity:0.4; + } + +:is(.dialog .mainContainer) .messageBar{ + --message-bar-bg-color:#ffebcd; + --message-bar-fg-color:#15141a; + --message-bar-border-color:rgb(0 0 0 / 0.08); + --message-bar-icon:url(images/messageBar_warning.svg); + --message-bar-icon-color:#cd411e; + } + +@media (prefers-color-scheme: dark){ + +:is(.dialog .mainContainer) .messageBar{ + --message-bar-bg-color:#5a3100; + --message-bar-fg-color:#fbfbfe; + --message-bar-border-color:rgb(255 255 255 / 0.08); + --message-bar-icon-color:#e49c49; + } + } + +@media screen and (forced-colors: active){ + +:is(.dialog .mainContainer) .messageBar{ + --message-bar-bg-color:HighlightText; + --message-bar-fg-color:CanvasText; + --message-bar-border-color:CanvasText; + --message-bar-icon-color:CanvasText; + } + } + +:is(.dialog .mainContainer) .messageBar{ + + align-self:stretch; + } + +:is(:is(:is(.dialog .mainContainer) .messageBar) > div)::before,:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div{ + margin-block:4px; + } + +:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:8px; + flex:1 0 0; + } + +:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .title{ + font-size:13px; + font-weight:590; + } + +:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .description{ + font-size:13px; + } + +:is(.dialog .mainContainer) .toggler{ + display:flex; + align-items:center; + gap:8px; + align-self:stretch; + } + +:is(:is(.dialog .mainContainer) .toggler) > .togglerLabel{ + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + } + +.textLayer{ + position:absolute; + text-align:initial; + inset:0; + overflow:clip; + opacity:1; + line-height:1; + -webkit-text-size-adjust:none; + -moz-text-size-adjust:none; + text-size-adjust:none; + forced-color-adjust:none; + transform-origin:0 0; + caret-color:CanvasText; + z-index:0; +} + +.textLayer.highlighting{ + touch-action:none; + } + +.textLayer :is(span,br){ + color:transparent; + position:absolute; + white-space:pre; + cursor:text; + transform-origin:0% 0%; + } + +.textLayer > :not(.markedContent),.textLayer .markedContent span:not(.markedContent){ + z-index:1; + } + +.textLayer span.markedContent{ + top:0; + height:0; + } + +.textLayer span[role="img"]{ + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + cursor:default; + } + +.textLayer .highlight{ + --highlight-bg-color:rgb(180 0 170 / 0.25); + --highlight-selected-bg-color:rgb(0 100 0 / 0.25); + --highlight-backdrop-filter:none; + --highlight-selected-backdrop-filter:none; + } + +@media screen and (forced-colors: active){ + +.textLayer .highlight{ + --highlight-bg-color:transparent; + --highlight-selected-bg-color:transparent; + --highlight-backdrop-filter:var(--hcm-highlight-filter); + --highlight-selected-backdrop-filter:var( + --hcm-highlight-selected-filter + ); + } + } + +.textLayer .highlight{ + + margin:-1px; + padding:1px; + background-color:var(--highlight-bg-color); + -webkit-backdrop-filter:var(--highlight-backdrop-filter); + backdrop-filter:var(--highlight-backdrop-filter); + border-radius:4px; + } + +.appended:is(.textLayer .highlight){ + position:initial; + } + +.begin:is(.textLayer .highlight){ + border-radius:4px 0 0 4px; + } + +.end:is(.textLayer .highlight){ + border-radius:0 4px 4px 0; + } + +.middle:is(.textLayer .highlight){ + border-radius:0; + } + +.selected:is(.textLayer .highlight){ + background-color:var(--highlight-selected-bg-color); + -webkit-backdrop-filter:var(--highlight-selected-backdrop-filter); + backdrop-filter:var(--highlight-selected-backdrop-filter); + } + +.textLayer ::-moz-selection{ + background:rgba(0 0 255 / 0.25); + background:color-mix(in srgb, AccentColor, transparent 75%); + } + +.textLayer ::selection{ + background:rgba(0 0 255 / 0.25); + background:color-mix(in srgb, AccentColor, transparent 75%); + } + +.textLayer br::-moz-selection{ + background:transparent; + } + +.textLayer br::selection{ + background:transparent; + } + +.textLayer .endOfContent{ + display:block; + position:absolute; + inset:100% 0 0; + z-index:0; + cursor:default; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + } + +.textLayer.selecting .endOfContent{ + top:0; + } + +.annotationLayer{ + --annotation-unfocused-field-background:url("data:image/svg+xml;charset=UTF-8,"); + --input-focus-border-color:Highlight; + --input-focus-outline:1px solid Canvas; + --input-unfocused-border-color:transparent; + --input-disabled-border-color:transparent; + --input-hover-border-color:black; + --link-outline:none; +} + +@media screen and (forced-colors: active){ + +.annotationLayer{ + --input-focus-border-color:CanvasText; + --input-unfocused-border-color:ActiveText; + --input-disabled-border-color:GrayText; + --input-hover-border-color:Highlight; + --link-outline:1.5px solid LinkText; +} + + .annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{ + outline:1.5px solid selectedItem; + } + + .annotationLayer .linkAnnotation{ + outline:var(--link-outline); + } + + :is(.annotationLayer .linkAnnotation):hover{ + -webkit-backdrop-filter:var(--hcm-highlight-filter); + backdrop-filter:var(--hcm-highlight-filter); + } + + :is(.annotationLayer .linkAnnotation) > a:hover{ + opacity:0 !important; + background:none !important; + box-shadow:none; + } + + .annotationLayer .popupAnnotation .popup{ + outline:calc(1.5px * var(--scale-factor)) solid CanvasText !important; + background-color:ButtonFace !important; + color:ButtonText !important; + } + + .annotationLayer .highlightArea:hover::after{ + position:absolute; + top:0; + left:0; + width:100%; + height:100%; + -webkit-backdrop-filter:var(--hcm-highlight-filter); + backdrop-filter:var(--hcm-highlight-filter); + content:""; + pointer-events:none; + } + + .annotationLayer .popupAnnotation.focused .popup{ + outline:calc(3px * var(--scale-factor)) solid Highlight !important; + } + } + +.annotationLayer{ + + position:absolute; + top:0; + left:0; + pointer-events:none; + transform-origin:0 0; +} + +.annotationLayer[data-main-rotation="90"] .norotate{ + transform:rotate(270deg) translateX(-100%); + } + +.annotationLayer[data-main-rotation="180"] .norotate{ + transform:rotate(180deg) translate(-100%, -100%); + } + +.annotationLayer[data-main-rotation="270"] .norotate{ + transform:rotate(90deg) translateY(-100%); + } + +.annotationLayer.disabled section,.annotationLayer.disabled .popup{ + pointer-events:none; + } + +.annotationLayer .annotationContent{ + position:absolute; + width:100%; + height:100%; + pointer-events:none; + } + +.freetext:is(.annotationLayer .annotationContent){ + background:transparent; + border:none; + inset:0; + overflow:visible; + white-space:nowrap; + font:10px sans-serif; + line-height:1.35; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + } + +.annotationLayer section{ + position:absolute; + text-align:initial; + pointer-events:auto; + box-sizing:border-box; + transform-origin:0 0; + } + +:is(.annotationLayer section):has(div.annotationContent) canvas.annotationContent{ + display:none; + } + +.textLayer.selecting ~ .annotationLayer section{ + pointer-events:none; + } + +.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton) > a{ + position:absolute; + font-size:1em; + top:0; + left:0; + width:100%; + height:100%; + } + +.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton):not(.hasBorder) > a:hover{ + opacity:0.2; + background-color:rgb(255 255 0); + box-shadow:0 2px 10px rgb(255 255 0); + } + +.annotationLayer .linkAnnotation.hasBorder:hover{ + background-color:rgb(255 255 0 / 0.2); + } + +.annotationLayer .hasBorder{ + background-size:100% 100%; + } + +.annotationLayer .textAnnotation img{ + position:absolute; + cursor:pointer; + width:100%; + height:100%; + top:0; + left:0; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea),.annotationLayer .choiceWidgetAnnotation select,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{ + background-image:var(--annotation-unfocused-field-background); + border:2px solid var(--input-unfocused-border-color); + box-sizing:border-box; + font:calc(9px * var(--scale-factor)) sans-serif; + height:100%; + margin:0; + vertical-align:top; + width:100%; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{ + outline:1.5px solid red; + } + +.annotationLayer .choiceWidgetAnnotation select option{ + padding:0; + } + +.annotationLayer .buttonWidgetAnnotation.radioButton input{ + border-radius:50%; + } + +.annotationLayer .textWidgetAnnotation textarea{ + resize:none; + } + +.annotationLayer .textWidgetAnnotation [disabled]:is(input,textarea),.annotationLayer .choiceWidgetAnnotation select[disabled],.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input[disabled]{ + background:none; + border:2px solid var(--input-disabled-border-color); + cursor:not-allowed; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:hover{ + border:2px solid var(--input-hover-border-color); + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation.checkBox input:hover{ + border-radius:2px; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):focus,.annotationLayer .choiceWidgetAnnotation select:focus{ + background:none; + border:2px solid var(--input-focus-border-color); + border-radius:2px; + outline:var(--input-focus-outline); + } + +.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) :focus{ + background-image:none; + background-color:transparent; + } + +.annotationLayer .buttonWidgetAnnotation.checkBox :focus{ + border:2px solid var(--input-focus-border-color); + border-radius:2px; + outline:var(--input-focus-outline); + } + +.annotationLayer .buttonWidgetAnnotation.radioButton :focus{ + border:2px solid var(--input-focus-border-color); + outline:var(--input-focus-outline); + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after,.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before{ + background-color:CanvasText; + content:""; + display:block; + position:absolute; + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after{ + height:80%; + left:45%; + width:1px; + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before{ + transform:rotate(45deg); + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after{ + transform:rotate(-45deg); + } + +.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before{ + border-radius:50%; + height:50%; + left:25%; + top:25%; + width:50%; + } + +.annotationLayer .textWidgetAnnotation input.comb{ + font-family:monospace; + padding-left:2px; + padding-right:0; + } + +.annotationLayer .textWidgetAnnotation input.comb:focus{ + width:103%; + } + +.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{ + -webkit-appearance:none; + -moz-appearance:none; + appearance:none; + } + +.annotationLayer .fileAttachmentAnnotation .popupTriggerArea{ + height:100%; + width:100%; + } + +.annotationLayer .popupAnnotation{ + position:absolute; + font-size:calc(9px * var(--scale-factor)); + pointer-events:none; + width:-moz-max-content; + width:max-content; + max-width:45%; + height:auto; + } + +.annotationLayer .popup{ + background-color:rgb(255 255 153); + box-shadow:0 calc(2px * var(--scale-factor)) calc(5px * var(--scale-factor)) rgb(136 136 136); + border-radius:calc(2px * var(--scale-factor)); + outline:1.5px solid rgb(255 255 74); + padding:calc(6px * var(--scale-factor)); + cursor:pointer; + font:message-box; + white-space:normal; + word-wrap:break-word; + pointer-events:auto; + } + +.annotationLayer .popupAnnotation.focused .popup{ + outline-width:3px; + } + +.annotationLayer .popup *{ + font-size:calc(9px * var(--scale-factor)); + } + +.annotationLayer .popup > .header{ + display:inline-block; + } + +.annotationLayer .popup > .header h1{ + display:inline; + } + +.annotationLayer .popup > .header .popupDate{ + display:inline-block; + margin-left:calc(5px * var(--scale-factor)); + width:-moz-fit-content; + width:fit-content; + } + +.annotationLayer .popupContent{ + border-top:1px solid rgb(51 51 51); + margin-top:calc(2px * var(--scale-factor)); + padding-top:calc(2px * var(--scale-factor)); + } + +.annotationLayer .richText > *{ + white-space:pre-wrap; + font-size:calc(9px * var(--scale-factor)); + } + +.annotationLayer .popupTriggerArea{ + cursor:pointer; + } + +.annotationLayer section svg{ + position:absolute; + width:100%; + height:100%; + top:0; + left:0; + } + +.annotationLayer .annotationTextContent{ + position:absolute; + width:100%; + height:100%; + opacity:0; + color:transparent; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + pointer-events:none; + } + +:is(.annotationLayer .annotationTextContent) span{ + width:100%; + display:inline-block; + } + +.annotationLayer svg.quadrilateralsContainer{ + contain:strict; + width:0; + height:0; + position:absolute; + top:0; + left:0; + z-index:-1; + } + +:root{ + --xfa-unfocused-field-background:url("data:image/svg+xml;charset=UTF-8,"); + --xfa-focus-outline:auto; +} + +@media screen and (forced-colors: active){ + :root{ + --xfa-focus-outline:2px solid CanvasText; + } + .xfaLayer *:required{ + outline:1.5px solid selectedItem; + } +} + +.xfaLayer{ + background-color:transparent; +} + +.xfaLayer .highlight{ + margin:-1px; + padding:1px; + background-color:rgb(239 203 237); + border-radius:4px; +} + +.xfaLayer .highlight.appended{ + position:initial; +} + +.xfaLayer .highlight.begin{ + border-radius:4px 0 0 4px; +} + +.xfaLayer .highlight.end{ + border-radius:0 4px 4px 0; +} + +.xfaLayer .highlight.middle{ + border-radius:0; +} + +.xfaLayer .highlight.selected{ + background-color:rgb(203 223 203); +} + +.xfaPage{ + overflow:hidden; + position:relative; +} + +.xfaContentarea{ + position:absolute; +} + +.xfaPrintOnly{ + display:none; +} + +.xfaLayer{ + position:absolute; + text-align:initial; + top:0; + left:0; + transform-origin:0 0; + line-height:1.2; +} + +.xfaLayer *{ + color:inherit; + font:inherit; + font-style:inherit; + font-weight:inherit; + font-kerning:inherit; + letter-spacing:-0.01px; + text-align:inherit; + text-decoration:inherit; + box-sizing:border-box; + background-color:transparent; + padding:0; + margin:0; + pointer-events:auto; + line-height:inherit; +} + +.xfaLayer *:required{ + outline:1.5px solid red; +} + +.xfaLayer div, +.xfaLayer svg, +.xfaLayer svg *{ + pointer-events:none; +} + +.xfaLayer a{ + color:blue; +} + +.xfaRich li{ + margin-left:3em; +} + +.xfaFont{ + color:black; + font-weight:normal; + font-kerning:none; + font-size:10px; + font-style:normal; + letter-spacing:0; + text-decoration:none; + vertical-align:0; +} + +.xfaCaption{ + overflow:hidden; + flex:0 0 auto; +} + +.xfaCaptionForCheckButton{ + overflow:hidden; + flex:1 1 auto; +} + +.xfaLabel{ + height:100%; + width:100%; +} + +.xfaLeft{ + display:flex; + flex-direction:row; + align-items:center; +} + +.xfaRight{ + display:flex; + flex-direction:row-reverse; + align-items:center; +} + +:is(.xfaLeft, .xfaRight) > :is(.xfaCaption, .xfaCaptionForCheckButton){ + max-height:100%; +} + +.xfaTop{ + display:flex; + flex-direction:column; + align-items:flex-start; +} + +.xfaBottom{ + display:flex; + flex-direction:column-reverse; + align-items:flex-start; +} + +:is(.xfaTop, .xfaBottom) > :is(.xfaCaption, .xfaCaptionForCheckButton){ + width:100%; +} + +.xfaBorder{ + background-color:transparent; + position:absolute; + pointer-events:none; +} + +.xfaWrapped{ + width:100%; + height:100%; +} + +:is(.xfaTextfield, .xfaSelect):focus{ + background-image:none; + background-color:transparent; + outline:var(--xfa-focus-outline); + outline-offset:-1px; +} + +:is(.xfaCheckbox, .xfaRadio):focus{ + outline:var(--xfa-focus-outline); +} + +.xfaTextfield, +.xfaSelect{ + height:100%; + width:100%; + flex:1 1 auto; + border:none; + resize:none; + background-image:var(--xfa-unfocused-field-background); +} + +.xfaSelect{ + padding-inline:2px; +} + +:is(.xfaTop, .xfaBottom) > :is(.xfaTextfield, .xfaSelect){ + flex:0 1 auto; +} + +.xfaButton{ + cursor:pointer; + width:100%; + height:100%; + border:none; + text-align:center; +} + +.xfaLink{ + width:100%; + height:100%; + position:absolute; + top:0; + left:0; +} + +.xfaCheckbox, +.xfaRadio{ + width:100%; + height:100%; + flex:0 0 auto; + border:none; +} + +.xfaRich{ + white-space:pre-wrap; + width:100%; + height:100%; +} + +.xfaImage{ + -o-object-position:left top; + object-position:left top; + -o-object-fit:contain; + object-fit:contain; + width:100%; + height:100%; +} + +.xfaLrTb, +.xfaRlTb, +.xfaTb{ + display:flex; + flex-direction:column; + align-items:stretch; +} + +.xfaLr{ + display:flex; + flex-direction:row; + align-items:stretch; +} + +.xfaRl{ + display:flex; + flex-direction:row-reverse; + align-items:stretch; +} + +.xfaTb > div{ + justify-content:left; +} + +.xfaPosition{ + position:relative; +} + +.xfaArea{ + position:relative; +} + +.xfaValignMiddle{ + display:flex; + align-items:center; +} + +.xfaTable{ + display:flex; + flex-direction:column; + align-items:stretch; +} + +.xfaTable .xfaRow{ + display:flex; + flex-direction:row; + align-items:stretch; +} + +.xfaTable .xfaRlRow{ + display:flex; + flex-direction:row-reverse; + align-items:stretch; + flex:1; +} + +.xfaTable .xfaRlRow > div{ + flex:1; +} + +:is(.xfaNonInteractive, .xfaDisabled, .xfaReadOnly) :is(input, textarea){ + background:initial; +} + +@media print{ + .xfaTextfield, + .xfaSelect{ + background:transparent; + } + + .xfaSelect{ + -webkit-appearance:none; + -moz-appearance:none; + appearance:none; + text-indent:1px; + text-overflow:""; + } +} + +.canvasWrapper svg{ + transform:none; + } + +.moving:is(.canvasWrapper svg){ + z-index:100000; + } + +[data-main-rotation="90"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="90"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ + transform:matrix(0, 1, -1, 0, 1, 0); + } + +[data-main-rotation="180"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="180"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ + transform:matrix(-1, 0, 0, -1, 1, 1); + } + +[data-main-rotation="270"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="270"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ + transform:matrix(0, -1, 1, 0, 0, 1); + } + +.draw:is(.canvasWrapper svg){ + position:absolute; + mix-blend-mode:normal; + } + +.draw[data-draw-rotation="90"]:is(.canvasWrapper svg){ + transform:rotate(90deg); + } + +.draw[data-draw-rotation="180"]:is(.canvasWrapper svg){ + transform:rotate(180deg); + } + +.draw[data-draw-rotation="270"]:is(.canvasWrapper svg){ + transform:rotate(270deg); + } + +.highlight:is(.canvasWrapper svg){ + --blend-mode:multiply; + } + +@media screen and (forced-colors: active){ + +.highlight:is(.canvasWrapper svg){ + --blend-mode:difference; + } + } + +.highlight:is(.canvasWrapper svg){ + + position:absolute; + mix-blend-mode:var(--blend-mode); + } + +.highlight:is(.canvasWrapper svg):not(.free){ + fill-rule:evenodd; + } + +.highlightOutline:is(.canvasWrapper svg){ + position:absolute; + mix-blend-mode:normal; + fill-rule:evenodd; + fill:none; + } + +.highlightOutline.hovered:is(.canvasWrapper svg):not(.free):not(.selected){ + stroke:var(--hover-outline-color); + stroke-width:var(--outline-width); + } + +.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .mainOutline{ + stroke:var(--outline-around-color); + stroke-width:calc( + var(--outline-width) + 2 * var(--outline-around-width) + ); + } + +.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .secondaryOutline{ + stroke:var(--outline-color); + stroke-width:var(--outline-width); + } + +.highlightOutline.free.hovered:is(.canvasWrapper svg):not(.selected){ + stroke:var(--hover-outline-color); + stroke-width:calc(2 * var(--outline-width)); + } + +.highlightOutline.free.selected:is(.canvasWrapper svg) .mainOutline{ + stroke:var(--outline-around-color); + stroke-width:calc( + 2 * (var(--outline-width) + var(--outline-around-width)) + ); + } + +.highlightOutline.free.selected:is(.canvasWrapper svg) .secondaryOutline{ + stroke:var(--outline-color); + stroke-width:calc(2 * var(--outline-width)); + } + +.toggle-button{ + --button-background-color:#f0f0f4; + --button-background-color-hover:#e0e0e6; + --button-background-color-active:#cfcfd8; + --color-accent-primary:#0060df; + --color-accent-primary-hover:#0250bb; + --color-accent-primary-active:#054096; + --border-interactive-color:#8f8f9d; + --border-radius-circle:9999px; + --border-width:1px; + --size-item-small:16px; + --size-item-large:32px; + --color-canvas:white; +} + +@media (prefers-color-scheme: dark){ + +.toggle-button{ + --button-background-color:color-mix(in srgb, currentColor 7%, transparent); + --button-background-color-hover:color-mix( + in srgb, + currentColor 14%, + transparent + ); + --button-background-color-active:color-mix( + in srgb, + currentColor 21%, + transparent + ); + --color-accent-primary:#0df; + --color-accent-primary-hover:#80ebff; + --color-accent-primary-active:#aaf2ff; + --border-interactive-color:#bfbfc9; + --color-canvas:#1c1b22; +} + } + +@media (forced-colors: active){ + +.toggle-button{ + --color-accent-primary:ButtonText; + --color-accent-primary-hover:SelectedItem; + --color-accent-primary-active:SelectedItem; + --border-interactive-color:ButtonText; + --button-background-color:ButtonFace; + --border-interactive-color-hover:SelectedItem; + --border-interactive-color-active:SelectedItem; + --border-interactive-color-disabled:GrayText; + --color-canvas:ButtonText; +} + } + +.toggle-button{ + + --toggle-background-color:var(--button-background-color); + --toggle-background-color-hover:var(--button-background-color-hover); + --toggle-background-color-active:var(--button-background-color-active); + --toggle-background-color-pressed:var(--color-accent-primary); + --toggle-background-color-pressed-hover:var(--color-accent-primary-hover); + --toggle-background-color-pressed-active:var(--color-accent-primary-active); + --toggle-border-color:var(--border-interactive-color); + --toggle-border-color-hover:var(--toggle-border-color); + --toggle-border-color-active:var(--toggle-border-color); + --toggle-border-radius:var(--border-radius-circle); + --toggle-border-width:var(--border-width); + --toggle-height:var(--size-item-small); + --toggle-width:var(--size-item-large); + --toggle-dot-background-color:var(--toggle-border-color); + --toggle-dot-background-color-hover:var(--toggle-dot-background-color); + --toggle-dot-background-color-active:var(--toggle-dot-background-color); + --toggle-dot-background-color-on-pressed:var(--color-canvas); + --toggle-dot-margin:1px; + --toggle-dot-height:calc( + var(--toggle-height) - 2 * var(--toggle-dot-margin) - 2 * + var(--toggle-border-width) + ); + --toggle-dot-width:var(--toggle-dot-height); + --toggle-dot-transform-x:calc( + var(--toggle-width) - 4 * var(--toggle-dot-margin) - var(--toggle-dot-width) + ); + + -webkit-appearance:none; + + -moz-appearance:none; + + appearance:none; + padding:0; + margin:0; + border:var(--toggle-border-width) solid var(--toggle-border-color); + height:var(--toggle-height); + width:var(--toggle-width); + border-radius:var(--toggle-border-radius); + background:var(--toggle-background-color); + box-sizing:border-box; + flex-shrink:0; +} + +.toggle-button:focus-visible{ + outline:var(--focus-outline); + outline-offset:var(--focus-outline-offset); + } + +.toggle-button:enabled:hover{ + background:var(--toggle-background-color-hover); + border-color:var(--toggle-border-color); + } + +.toggle-button:enabled:active{ + background:var(--toggle-background-color-active); + border-color:var(--toggle-border-color); + } + +.toggle-button[aria-pressed="true"]{ + background:var(--toggle-background-color-pressed); + border-color:transparent; + } + +.toggle-button[aria-pressed="true"]:enabled:hover{ + background:var(--toggle-background-color-pressed-hover); + border-color:transparent; + } + +.toggle-button[aria-pressed="true"]:enabled:active{ + background:var(--toggle-background-color-pressed-active); + border-color:transparent; + } + +.toggle-button::before{ + display:block; + content:""; + background-color:var(--toggle-dot-background-color); + height:var(--toggle-dot-height); + width:var(--toggle-dot-width); + margin:var(--toggle-dot-margin); + border-radius:var(--toggle-border-radius); + translate:0; + } + +.toggle-button[aria-pressed="true"]::before{ + translate:var(--toggle-dot-transform-x); + background-color:var(--toggle-dot-background-color-on-pressed); + } + +.toggle-button[aria-pressed="true"]:enabled:hover::before,.toggle-button[aria-pressed="true"]:enabled:active::before{ + background-color:var(--toggle-dot-background-color-on-pressed); + } + +[dir="rtl"] .toggle-button[aria-pressed="true"]::before{ + translate:calc(-1 * var(--toggle-dot-transform-x)); + } + +@media (prefers-reduced-motion: no-preference){ + .toggle-button::before{ + transition:translate 100ms; + } + } + +@media (prefers-contrast){ + .toggle-button:enabled:hover{ + border-color:var(--toggle-border-color-hover); + } + + .toggle-button:enabled:active{ + border-color:var(--toggle-border-color-active); + } + + .toggle-button[aria-pressed="true"]:enabled{ + border-color:var(--toggle-border-color); + position:relative; + } + + .toggle-button[aria-pressed="true"]:enabled:hover,.toggle-button[aria-pressed="true"]:enabled:hover:active{ + border-color:var(--toggle-border-color-hover); + } + + .toggle-button[aria-pressed="true"]:enabled:active{ + background-color:var(--toggle-dot-background-color-active); + border-color:var(--toggle-dot-background-color-hover); + } + + .toggle-button:hover::before,.toggle-button:active::before{ + background-color:var(--toggle-dot-background-color-hover); + } + } + +@media (forced-colors){ + +.toggle-button{ + --toggle-dot-background-color:var(--color-accent-primary); + --toggle-dot-background-color-hover:var(--color-accent-primary-hover); + --toggle-dot-background-color-active:var(--color-accent-primary-active); + --toggle-dot-background-color-on-pressed:var(--button-background-color); + --toggle-background-color-disabled:var(--button-background-color-disabled); + --toggle-border-color-hover:var(--border-interactive-color-hover); + --toggle-border-color-active:var(--border-interactive-color-active); + --toggle-border-color-disabled:var(--border-interactive-color-disabled); +} + + .toggle-button[aria-pressed="true"]:enabled::after{ + border:1px solid var(--button-background-color); + content:""; + position:absolute; + height:var(--toggle-height); + width:var(--toggle-width); + display:block; + border-radius:var(--toggle-border-radius); + inset:-2px; + } + + .toggle-button[aria-pressed="true"]:enabled:active::after{ + border-color:var(--toggle-border-color-active); + } + } + +:root{ + --outline-width:2px; + --outline-color:#0060df; + --outline-around-width:1px; + --outline-around-color:#f0f0f4; + --hover-outline-around-color:var(--outline-around-color); + --focus-outline:solid var(--outline-width) var(--outline-color); + --unfocus-outline:solid var(--outline-width) transparent; + --focus-outline-around:solid var(--outline-around-width) var(--outline-around-color); + --hover-outline-color:#8f8f9d; + --hover-outline:solid var(--outline-width) var(--hover-outline-color); + --hover-outline-around:solid var(--outline-around-width) var(--hover-outline-around-color); + --freetext-line-height:1.35; + --freetext-padding:2px; + --resizer-bg-color:var(--outline-color); + --resizer-size:6px; + --resizer-shift:calc( + 0px - (var(--outline-width) + var(--resizer-size)) / 2 - + var(--outline-around-width) + ); + --editorFreeText-editing-cursor:text; + --editorInk-editing-cursor:url(images/cursor-editorInk.svg) 0 16, pointer; + --editorHighlight-editing-cursor:url(images/cursor-editorTextHighlight.svg) 24 24, text; + --editorFreeHighlight-editing-cursor:url(images/cursor-editorFreeHighlight.svg) 1 18, pointer; + + --new-alt-text-warning-image:url(images/altText_warning.svg); +} +.visuallyHidden{ + position:absolute; + top:0; + left:0; + border:0; + margin:0; + padding:0; + width:0; + height:0; + overflow:hidden; + white-space:nowrap; + font-size:0; +} + +.textLayer.highlighting{ + cursor:var(--editorFreeHighlight-editing-cursor); + } + +.textLayer.highlighting:not(.free) span{ + cursor:var(--editorHighlight-editing-cursor); + } + +[role="img"]:is(.textLayer.highlighting:not(.free) span){ + cursor:var(--editorFreeHighlight-editing-cursor); + } + +.textLayer.highlighting.free span{ + cursor:var(--editorFreeHighlight-editing-cursor); + } + +:is(#viewerContainer.pdfPresentationMode:fullscreen,.annotationEditorLayer.disabled) .noAltTextBadge{ + display:none !important; + } + +@media (min-resolution: 1.1dppx){ + :root{ + --editorFreeText-editing-cursor:url(images/cursor-editorFreeText.svg) 0 16, text; + } +} + +@media screen and (forced-colors: active){ + :root{ + --outline-color:CanvasText; + --outline-around-color:ButtonFace; + --resizer-bg-color:ButtonText; + --hover-outline-color:Highlight; + --hover-outline-around-color:SelectedItemText; + } +} + +[data-editor-rotation="90"]{ + transform:rotate(90deg); +} + +[data-editor-rotation="180"]{ + transform:rotate(180deg); +} + +[data-editor-rotation="270"]{ + transform:rotate(270deg); +} + +.annotationEditorLayer{ + background:transparent; + position:absolute; + inset:0; + font-size:calc(100px * var(--scale-factor)); + transform-origin:0 0; + cursor:auto; +} + +.annotationEditorLayer .selectedEditor{ + z-index:100000 !important; + } + +.annotationEditorLayer.drawing *{ + pointer-events:none !important; + } + +.annotationEditorLayer.waiting{ + content:""; + cursor:wait; + position:absolute; + inset:0; + width:100%; + height:100%; +} + +.annotationEditorLayer.disabled{ + pointer-events:none; +} + +.annotationEditorLayer.freetextEditing{ + cursor:var(--editorFreeText-editing-cursor); +} + +.annotationEditorLayer.inkEditing{ + cursor:var(--editorInk-editing-cursor); +} + +.annotationEditorLayer .draw{ + box-sizing:border-box; +} + +.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor){ + position:absolute; + background:transparent; + z-index:1; + transform-origin:0 0; + cursor:auto; + max-width:100%; + max-height:100%; + border:var(--unfocus-outline); +} + +.draggable.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)){ + cursor:move; + } + +.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)){ + border:var(--focus-outline); + outline:var(--focus-outline-around); + } + +.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor))::before{ + content:""; + position:absolute; + inset:0; + border:var(--focus-outline-around); + pointer-events:none; + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)):hover:not(.selectedEditor){ + border:var(--hover-outline); + outline:var(--hover-outline-around); + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)):hover:not(.selectedEditor)::before{ + content:""; + position:absolute; + inset:0; + border:var(--focus-outline-around); + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + --editor-toolbar-delete-image:url(images/editor-toolbar-delete.svg); + --editor-toolbar-bg-color:#f0f0f4; + --editor-toolbar-highlight-image:url(images/toolbarButton-editorHighlight.svg); + --editor-toolbar-fg-color:#2e2e56; + --editor-toolbar-border-color:#8f8f9d; + --editor-toolbar-hover-border-color:var(--editor-toolbar-border-color); + --editor-toolbar-hover-bg-color:#e0e0e6; + --editor-toolbar-hover-fg-color:var(--editor-toolbar-fg-color); + --editor-toolbar-hover-outline:none; + --editor-toolbar-focus-outline-color:#0060df; + --editor-toolbar-shadow:0 2px 6px 0 rgb(58 57 68 / 0.2); + --editor-toolbar-vert-offset:6px; + --editor-toolbar-height:28px; + --editor-toolbar-padding:2px; + --alt-text-done-color:#2ac3a2; + --alt-text-warning-color:#0090ed; + --alt-text-hover-done-color:var(--alt-text-done-color); + --alt-text-hover-warning-color:var(--alt-text-warning-color); + } + +@media (prefers-color-scheme: dark){ + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + --editor-toolbar-bg-color:#2b2a33; + --editor-toolbar-fg-color:#fbfbfe; + --editor-toolbar-hover-bg-color:#52525e; + --editor-toolbar-focus-outline-color:#0df; + --alt-text-done-color:#54ffbd; + --alt-text-warning-color:#80ebff; + } + } + +@media screen and (forced-colors: active){ + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + --editor-toolbar-bg-color:ButtonFace; + --editor-toolbar-fg-color:ButtonText; + --editor-toolbar-border-color:ButtonText; + --editor-toolbar-hover-border-color:AccentColor; + --editor-toolbar-hover-bg-color:ButtonFace; + --editor-toolbar-hover-fg-color:AccentColor; + --editor-toolbar-hover-outline:2px solid var(--editor-toolbar-hover-border-color); + --editor-toolbar-focus-outline-color:ButtonBorder; + --editor-toolbar-shadow:none; + --alt-text-done-color:var(--editor-toolbar-fg-color); + --alt-text-warning-color:var(--editor-toolbar-fg-color); + --alt-text-hover-done-color:var(--editor-toolbar-hover-fg-color); + --alt-text-hover-warning-color:var(--editor-toolbar-hover-fg-color); + } + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + + display:flex; + width:-moz-fit-content; + width:fit-content; + height:var(--editor-toolbar-height); + flex-direction:column; + justify-content:center; + align-items:center; + cursor:default; + pointer-events:auto; + box-sizing:content-box; + padding:var(--editor-toolbar-padding); + + position:absolute; + inset-inline-end:0; + inset-block-start:calc(100% + var(--editor-toolbar-vert-offset)); + + border-radius:6px; + background-color:var(--editor-toolbar-bg-color); + border:1px solid var(--editor-toolbar-border-color); + box-shadow:var(--editor-toolbar-shadow); + } + +.hidden:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar){ + display:none; + } + +:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar):has(:focus-visible){ + border-color:transparent; + } + +[dir="ltr"] :is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar){ + transform-origin:100% 0; + } + +[dir="rtl"] :is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar){ + transform-origin:0 0; + } + +:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons{ + display:flex; + justify-content:center; + align-items:center; + gap:0; + height:100%; + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) button{ + padding:0; + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .divider{ + width:0; + height:calc( + 2 * var(--editor-toolbar-padding) + var(--editor-toolbar-height) + ); + border-left:1px solid var(--editor-toolbar-border-color); + border-right:none; + display:inline-block; + margin-inline:2px; + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .highlightButton{ + width:var(--editor-toolbar-height); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .highlightButton)::before{ + content:""; + -webkit-mask-image:var(--editor-toolbar-highlight-image); + mask-image:var(--editor-toolbar-highlight-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + background-color:var(--editor-toolbar-fg-color); + width:100%; + height:100%; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .highlightButton):hover::before{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .delete{ + width:var(--editor-toolbar-height); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .delete)::before{ + content:""; + -webkit-mask-image:var(--editor-toolbar-delete-image); + mask-image:var(--editor-toolbar-delete-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + background-color:var(--editor-toolbar-fg-color); + width:100%; + height:100%; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .delete):hover::before{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > *{ + height:var(--editor-toolbar-height); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider){ + border:none; + background-color:transparent; + cursor:pointer; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):hover{ + border-radius:2px; + background-color:var(--editor-toolbar-hover-bg-color); + color:var(--editor-toolbar-hover-fg-color); + outline:var(--editor-toolbar-hover-outline); + outline-offset:1px; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):hover:active{ + outline:none; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):focus-visible{ + border-radius:2px; + outline:2px solid var(--editor-toolbar-focus-outline-color); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText{ + --alt-text-add-image:url(images/altText_add.svg); + --alt-text-done-image:url(images/altText_done.svg); + + display:flex; + align-items:center; + justify-content:center; + width:-moz-max-content; + width:max-content; + padding-inline:8px; + pointer-events:all; + font:menu; + font-weight:590; + font-size:12px; + color:var(--editor-toolbar-fg-color); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):disabled{ + pointer-events:none; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + content:""; + -webkit-mask-image:var(--alt-text-add-image); + mask-image:var(--alt-text-add-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + width:12px; + height:13px; + background-color:var(--editor-toolbar-fg-color); + margin-inline-end:4px; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + -webkit-mask-image:var(--alt-text-done-image); + mask-image:var(--alt-text-done-image); + } + +.new:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + width:16px; + height:16px; + -webkit-mask-image:var(--new-alt-text-warning-image); + mask-image:var(--new-alt-text-warning-image); + background-color:var(--alt-text-warning-color); + -webkit-mask-size:cover; + mask-size:cover; + } + +.new:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ + background-color:var(--alt-text-hover-warning-color); + } + +.new.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + -webkit-mask-image:var(--alt-text-done-image); + mask-image:var(--alt-text-done-image); + background-color:var(--alt-text-done-color); + } + +.new.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ + background-color:var(--alt-text-hover-done-color); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip{ + display:none; + word-wrap:anywhere; + } + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + --alt-text-tooltip-bg:#f0f0f4; + --alt-text-tooltip-fg:#15141a; + --alt-text-tooltip-border:#8f8f9d; + --alt-text-tooltip-shadow:0px 2px 6px 0px rgb(58 57 68 / 0.2); + } + +@media (prefers-color-scheme: dark){ + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + --alt-text-tooltip-bg:#1c1b22; + --alt-text-tooltip-fg:#fbfbfe; + --alt-text-tooltip-shadow:0px 2px 6px 0px #15141a; + } + } + +@media screen and (forced-colors: active){ + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + --alt-text-tooltip-bg:Canvas; + --alt-text-tooltip-fg:CanvasText; + --alt-text-tooltip-border:CanvasText; + --alt-text-tooltip-shadow:none; + } + } + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + + display:inline-flex; + flex-direction:column; + align-items:center; + justify-content:center; + position:absolute; + top:calc(100% + 2px); + inset-inline-start:0; + padding-block:2px 3px; + padding-inline:3px; + max-width:300px; + width:-moz-max-content; + width:max-content; + height:auto; + font-size:12px; + + border:0.5px solid var(--alt-text-tooltip-border); + background:var(--alt-text-tooltip-bg); + box-shadow:var(--alt-text-tooltip-shadow); + color:var(--alt-text-tooltip-fg); + + pointer-events:none; + } + +.annotationEditorLayer .freeTextEditor{ + padding:calc(var(--freetext-padding) * var(--scale-factor)); + width:auto; + height:auto; + touch-action:none; +} + +.annotationEditorLayer .freeTextEditor .internal{ + background:transparent; + border:none; + inset:0; + overflow:visible; + white-space:nowrap; + font:10px sans-serif; + line-height:var(--freetext-line-height); + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; +} + +.annotationEditorLayer .freeTextEditor .overlay{ + position:absolute; + display:none; + background:transparent; + inset:0; + width:100%; + height:100%; +} + +.annotationEditorLayer freeTextEditor .overlay.enabled{ + display:block; +} + +.annotationEditorLayer .freeTextEditor .internal:empty::before{ + content:attr(default-content); + color:gray; +} + +.annotationEditorLayer .freeTextEditor .internal:focus{ + outline:none; + -webkit-user-select:auto; + -moz-user-select:auto; + user-select:auto; +} + +.annotationEditorLayer .inkEditor{ + width:100%; + height:100%; +} + +.annotationEditorLayer .inkEditor.editing{ + cursor:inherit; +} + +.annotationEditorLayer .inkEditor .inkEditorCanvas{ + position:absolute; + inset:0; + width:100%; + height:100%; + touch-action:none; +} + +.annotationEditorLayer .stampEditor{ + width:auto; + height:auto; +} + +:is(.annotationEditorLayer .stampEditor) canvas{ + position:absolute; + width:100%; + height:100%; + margin:0; + top:0; + left:0; + } + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + --no-alt-text-badge-border-color:#f0f0f4; + --no-alt-text-badge-bg-color:#cfcfd8; + --no-alt-text-badge-fg-color:#5b5b66; + } + +@media (prefers-color-scheme: dark){ + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + --no-alt-text-badge-border-color:#52525e; + --no-alt-text-badge-bg-color:#fbfbfe; + --no-alt-text-badge-fg-color:#15141a; + } + } + +@media screen and (forced-colors: active){ + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + --no-alt-text-badge-border-color:ButtonText; + --no-alt-text-badge-bg-color:ButtonFace; + --no-alt-text-badge-fg-color:ButtonText; + } + } + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + + position:absolute; + inset-inline-end:5px; + inset-block-end:5px; + display:inline-flex; + width:32px; + height:32px; + padding:3px; + justify-content:center; + align-items:center; + pointer-events:none; + z-index:1; + + border-radius:2px; + border:1px solid var(--no-alt-text-badge-border-color); + background:var(--no-alt-text-badge-bg-color); + } + +:is(:is(.annotationEditorLayer .stampEditor) .noAltTextBadge)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--new-alt-text-warning-image); + mask-image:var(--new-alt-text-warning-image); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--no-alt-text-badge-fg-color); + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers{ + position:absolute; + inset:0; + } + +.hidden:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers){ + display:none; + } + +:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer{ + width:var(--resizer-size); + height:var(--resizer-size); + background:content-box var(--resizer-bg-color); + border:var(--focus-outline-around); + border-radius:2px; + position:absolute; + } + +.topLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:var(--resizer-shift); + left:var(--resizer-shift); + } + +.topMiddle:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:var(--resizer-shift); + left:calc(50% + var(--resizer-shift)); + } + +.topRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:var(--resizer-shift); + right:var(--resizer-shift); + } + +.middleRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:calc(50% + var(--resizer-shift)); + right:var(--resizer-shift); + } + +.bottomRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + bottom:var(--resizer-shift); + right:var(--resizer-shift); + } + +.bottomMiddle:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + bottom:var(--resizer-shift); + left:calc(50% + var(--resizer-shift)); + } + +.bottomLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + bottom:var(--resizer-shift); + left:var(--resizer-shift); + } + +.middleLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:calc(50% + var(--resizer-shift)); + left:var(--resizer-shift); + } + +.topLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:nwse-resize; + } + +.topMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:ns-resize; + } + +.topRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:nesw-resize; + } + +.middleRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.middleLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:ew-resize; + } + +.topLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:nesw-resize; + } + +.topMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:ew-resize; + } + +.topRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:nwse-resize; + } + +.middleRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.middleLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:ns-resize; + } + +:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar{ + rotate:270deg; + } + +[dir="ltr"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar){ + inset-inline-end:calc(0px - var(--editor-toolbar-vert-offset)); + inset-block-start:0; + } + +[dir="rtl"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar){ + inset-inline-end:calc(100% + var(--editor-toolbar-vert-offset)); + inset-block-start:0; + } + +:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="180"],[data-main-rotation="90"] [data-editor-rotation="90"],[data-main-rotation="180"] [data-editor-rotation="0"],[data-main-rotation="270"] [data-editor-rotation="270"])) .editToolbar{ + rotate:180deg; + inset-inline-end:100%; + inset-block-start:calc(0pc - var(--editor-toolbar-vert-offset)); + } + +:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar{ + rotate:90deg; + } + +[dir="ltr"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar){ + inset-inline-end:calc(100% + var(--editor-toolbar-vert-offset)); + inset-block-start:100%; + } + +[dir="rtl"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar){ + inset-inline-start:calc(0px - var(--editor-toolbar-vert-offset)); + inset-block-start:0; + } + +.dialog.altText::backdrop{ + -webkit-mask:url(#alttext-manager-mask); + mask:url(#alttext-manager-mask); + } + +.dialog.altText.positioned{ + margin:0; + } + +.dialog.altText #altTextContainer{ + width:300px; + height:-moz-fit-content; + height:fit-content; + display:inline-flex; + flex-direction:column; + align-items:flex-start; + gap:16px; + } + +:is(.dialog.altText #altTextContainer) #overallDescription{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:4px; + align-self:stretch; + } + +:is(:is(.dialog.altText #altTextContainer) #overallDescription) span{ + align-self:stretch; + } + +:is(:is(.dialog.altText #altTextContainer) #overallDescription) .title{ + font-size:13px; + font-style:normal; + font-weight:590; + } + +:is(.dialog.altText #altTextContainer) #addDescription{ + display:flex; + flex-direction:column; + align-items:stretch; + gap:8px; + } + +:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea{ + flex:1; + padding-inline:24px 10px; + } + +:is(:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea) textarea{ + width:100%; + min-height:75px; + } + +:is(.dialog.altText #altTextContainer) #buttons{ + display:flex; + justify-content:flex-end; + align-items:flex-start; + gap:8px; + align-self:stretch; + } + +.dialog.newAltText{ + --new-alt-text-ai-disclaimer-icon:url(images/altText_disclaimer.svg); + --new-alt-text-spinner-icon:url(images/altText_spinner.svg); + --preview-image-bg-color:#f0f0f4; + --preview-image-border:none; +} + +@media (prefers-color-scheme: dark){ + +.dialog.newAltText{ + --preview-image-bg-color:#2b2a33; +} + } + +@media screen and (forced-colors: active){ + +.dialog.newAltText{ + --preview-image-bg-color:ButtonFace; + --preview-image-border:1px solid ButtonText; +} + } + +.dialog.newAltText{ + + width:80%; + max-width:570px; + min-width:300px; + padding:0; +} + +.dialog.newAltText.noAi #newAltTextDisclaimer,.dialog.newAltText.noAi #newAltTextCreateAutomatically{ + display:none !important; + } + +.dialog.newAltText.aiInstalling #newAltTextCreateAutomatically{ + display:none !important; + } + +.dialog.newAltText.aiInstalling #newAltTextDownloadModel{ + display:flex !important; + } + +.dialog.newAltText.error #newAltTextNotNow{ + display:none !important; + } + +.dialog.newAltText.error #newAltTextCancel{ + display:inline-block !important; + } + +.dialog.newAltText:not(.error) #newAltTextError{ + display:none !important; + } + +.dialog.newAltText #newAltTextContainer{ + display:flex; + width:auto; + padding:16px; + flex-direction:column; + justify-content:flex-end; + align-items:flex-start; + gap:12px; + flex:0 1 auto; + line-height:normal; + } + +:is(.dialog.newAltText #newAltTextContainer) #mainContent{ + display:flex; + justify-content:flex-end; + align-items:flex-start; + gap:12px; + align-self:stretch; + flex:1 1 auto; + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionAndSettings{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:16px; + flex:1 0 0; + align-self:stretch; + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:8px; + align-self:stretch; + flex:1 1 auto; + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer{ + width:100%; + height:70px; + position:relative; + } + +:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea{ + width:100%; + height:100%; + padding:8px; + } + +:is(:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea)::-moz-placeholder{ + color:var(--text-secondary-color); + } + +:is(:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea)::placeholder{ + color:var(--text-secondary-color); + } + +:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) .altTextSpinner{ + display:none; + position:absolute; + width:16px; + height:16px; + inset-inline-start:8px; + inset-block-start:8px; + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--text-secondary-color); + pointer-events:none; + } + +.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea::-moz-placeholder{ + color:transparent; + } + +.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea::placeholder{ + color:transparent; + } + +.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) .altTextSpinner{ + display:inline-block; + -webkit-mask-image:var(--new-alt-text-spinner-icon); + mask-image:var(--new-alt-text-spinner-icon); + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescription{ + font-size:11px; + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDisclaimer{ + display:flex; + flex-direction:row; + align-items:flex-start; + gap:4px; + font-size:11px; + } + +:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDisclaimer)::before{ + content:""; + display:inline-block; + width:17px; + height:16px; + -webkit-mask-image:var(--new-alt-text-ai-disclaimer-icon); + mask-image:var(--new-alt-text-ai-disclaimer-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--text-secondary-color); + flex:1 0 auto; + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextDownloadModel{ + display:flex; + align-items:center; + gap:4px; + align-self:stretch; + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextDownloadModel)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--new-alt-text-spinner-icon); + mask-image:var(--new-alt-text-spinner-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--text-secondary-color); + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextImagePreview{ + width:180px; + aspect-ratio:1; + display:flex; + justify-content:center; + align-items:center; + flex:0 0 auto; + background-color:var(--preview-image-bg-color); + border:var(--preview-image-border); + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextImagePreview) > canvas{ + max-width:100%; + max-height:100%; + } + +.colorPicker{ + --hover-outline-color:#0250bb; + --selected-outline-color:#0060df; + --swatch-border-color:#cfcfd8; +} + +@media (prefers-color-scheme: dark){ + +.colorPicker{ + --hover-outline-color:#80ebff; + --selected-outline-color:#aaf2ff; + --swatch-border-color:#52525e; +} + } + +@media screen and (forced-colors: active){ + +.colorPicker{ + --hover-outline-color:Highlight; + --selected-outline-color:var(--hover-outline-color); + --swatch-border-color:ButtonText; +} + } + +.colorPicker .swatch{ + width:16px; + height:16px; + border:1px solid var(--swatch-border-color); + border-radius:100%; + outline-offset:2px; + box-sizing:border-box; + forced-color-adjust:none; + } + +.colorPicker button:is(:hover,.selected) > .swatch{ + border:none; + } + +.annotationEditorLayer[data-main-rotation="0"] .highlightEditor:not(.free) > .editToolbar{ + rotate:0deg; + } + +.annotationEditorLayer[data-main-rotation="90"] .highlightEditor:not(.free) > .editToolbar{ + rotate:270deg; + } + +.annotationEditorLayer[data-main-rotation="180"] .highlightEditor:not(.free) > .editToolbar{ + rotate:180deg; + } + +.annotationEditorLayer[data-main-rotation="270"] .highlightEditor:not(.free) > .editToolbar{ + rotate:90deg; + } + +.annotationEditorLayer .highlightEditor{ + position:absolute; + background:transparent; + z-index:1; + cursor:auto; + max-width:100%; + max-height:100%; + border:none; + outline:none; + pointer-events:none; + transform-origin:0 0; + } + +:is(.annotationEditorLayer .highlightEditor):not(.free){ + transform:none; + } + +:is(.annotationEditorLayer .highlightEditor) .internal{ + position:absolute; + top:0; + left:0; + width:100%; + height:100%; + pointer-events:auto; + } + +.disabled:is(.annotationEditorLayer .highlightEditor) .internal{ + pointer-events:none; + } + +.selectedEditor:is(.annotationEditorLayer .highlightEditor) .internal{ + cursor:pointer; + } + +:is(.annotationEditorLayer .highlightEditor) .editToolbar{ + --editor-toolbar-colorpicker-arrow-image:url(images/toolbarButton-menuArrow.svg); + + transform-origin:center !important; + } + +:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker{ + position:relative; + width:auto; + display:flex; + justify-content:center; + align-items:center; + gap:4px; + padding:4px; + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker)::after{ + content:""; + -webkit-mask-image:var(--editor-toolbar-colorpicker-arrow-image); + mask-image:var(--editor-toolbar-colorpicker-arrow-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + background-color:var(--editor-toolbar-fg-color); + width:12px; + height:12px; + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):hover::after{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):has(.dropdown:not(.hidden)){ + background-color:var(--editor-toolbar-hover-bg-color); + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):has(.dropdown:not(.hidden))::after{ + scale:-1; + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown{ + position:absolute; + display:flex; + justify-content:center; + align-items:center; + flex-direction:column; + gap:11px; + padding-block:8px; + border-radius:6px; + background-color:var(--editor-toolbar-bg-color); + border:1px solid var(--editor-toolbar-border-color); + box-shadow:var(--editor-toolbar-shadow); + inset-block-start:calc(100% + 4px); + width:calc(100% + 2 * var(--editor-toolbar-padding)); + } + +:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button{ + width:100%; + height:auto; + border:none; + cursor:pointer; + display:flex; + justify-content:center; + align-items:center; + background:none; + } + +:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button):is(:active,:focus-visible){ + outline:none; + } + +:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button) > .swatch{ + outline-offset:2px; + } + +[aria-selected="true"]:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button) > .swatch{ + outline:2px solid var(--selected-outline-color); + } + +:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button):is(:hover,:active,:focus-visible) > .swatch{ + outline:2px solid var(--hover-outline-color); + } + +.editorParamsToolbar:has(#highlightParamsToolbarContainer){ + padding:unset; +} + +#highlightParamsToolbarContainer{ + gap:16px; + padding-inline:10px; + padding-block-end:12px; +} + +#highlightParamsToolbarContainer .colorPicker{ + display:flex; + flex-direction:column; + gap:8px; + } + +:is(#highlightParamsToolbarContainer .colorPicker) .dropdown{ + display:flex; + justify-content:space-between; + align-items:center; + flex-direction:row; + height:auto; + } + +:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button{ + width:auto; + height:auto; + border:none; + cursor:pointer; + display:flex; + justify-content:center; + align-items:center; + background:none; + flex:0 0 auto; + padding:0; + } + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) .swatch{ + width:24px; + height:24px; + } + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button):is(:active,:focus-visible){ + outline:none; + } + +[aria-selected="true"]:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) > .swatch{ + outline:2px solid var(--selected-outline-color); + } + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button):is(:hover,:active,:focus-visible) > .swatch{ + outline:2px solid var(--hover-outline-color); + } + +#highlightParamsToolbarContainer #editorHighlightThickness{ + display:flex; + flex-direction:column; + align-items:center; + gap:4px; + align-self:stretch; + } + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .editorParamsLabel{ + height:auto; + align-self:stretch; + } + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ + display:flex; + justify-content:space-between; + align-items:center; + align-self:stretch; + + --example-color:#bfbfc9; + } + +@media (prefers-color-scheme: dark){ + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ + --example-color:#80808e; + } + } + +@media screen and (forced-colors: active){ + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ + --example-color:CanvasText; + } + } + +:is(:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) > .editorParamsSlider[disabled]){ + opacity:0.4; + } + +:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::before,:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::after{ + content:""; + width:8px; + aspect-ratio:1; + display:block; + border-radius:100%; + background-color:var(--example-color); + } + +:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::after{ + width:24px; + } + +:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) .editorParamsSlider{ + width:unset; + height:14px; + } + +#highlightParamsToolbarContainer #editorHighlightVisibility{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:8px; + align-self:stretch; + } + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + --divider-color:#d7d7db; + } + +@media (prefers-color-scheme: dark){ + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + --divider-color:#8f8f9d; + } + } + +@media screen and (forced-colors: active){ + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + --divider-color:CanvasText; + } + } + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + + margin-block:4px; + width:100%; + height:1px; + background-color:var(--divider-color); + } + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .toggler{ + display:flex; + justify-content:space-between; + align-items:center; + align-self:stretch; + } + +#altTextSettingsDialog{ + padding:16px; +} + +#altTextSettingsDialog #altTextSettingsContainer{ + display:flex; + width:573px; + flex-direction:column; + gap:16px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) .mainContainer{ + gap:16px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) .description{ + color:var(--text-secondary-color); + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings{ + display:flex; + flex-direction:column; + gap:12px; + } + +:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) button{ + width:-moz-fit-content; + width:fit-content; + } + +.download:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) #deleteModelButton{ + display:none; + } + +:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings):not(.download) #downloadModelButton{ + display:none; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticAltText,:is(#altTextSettingsDialog #altTextSettingsContainer) #altTextEditor{ + display:flex; + flex-direction:column; + gap:8px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #createModelDescription,:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings,:is(#altTextSettingsDialog #altTextSettingsContainer) #showAltTextDialogDescription{ + padding-inline-start:40px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticSettings{ + display:flex; + flex-direction:column; + gap:16px; + } + +:root{ + --viewer-container-height:0; + --pdfViewer-padding-bottom:0; + --page-margin:1px auto -8px; + --page-border:9px solid transparent; + --spreadHorizontalWrapped-margin-LR:-3.5px; + --loading-icon-delay:400ms; +} + +@media screen and (forced-colors: active){ + :root{ + --pdfViewer-padding-bottom:9px; + --page-margin:8px auto -1px; + --page-border:1px solid CanvasText; + --spreadHorizontalWrapped-margin-LR:3.5px; + } +} + +[data-main-rotation="90"]{ + transform:rotate(90deg) translateY(-100%); +} +[data-main-rotation="180"]{ + transform:rotate(180deg) translate(-100%, -100%); +} +[data-main-rotation="270"]{ + transform:rotate(270deg) translateX(-100%); +} + +#hiddenCopyElement, +.hiddenCanvasElement{ + position:absolute; + top:0; + left:0; + width:0; + height:0; + display:none; +} + +.pdfViewer{ + --scale-factor:1; + --page-bg-color:unset; + + padding-bottom:var(--pdfViewer-padding-bottom); + + --hcm-highlight-filter:none; + --hcm-highlight-selected-filter:none; +} + +@media screen and (forced-colors: active){ + +.pdfViewer{ + --hcm-highlight-filter:invert(100%); +} + } + +.pdfViewer.copyAll{ + cursor:wait; + } + +.pdfViewer .canvasWrapper{ + overflow:hidden; + width:100%; + height:100%; + } + +:is(.pdfViewer .canvasWrapper) canvas{ + position:absolute; + top:0; + left:0; + margin:0; + display:block; + width:100%; + height:100%; + contain:content; + } + +:is(:is(.pdfViewer .canvasWrapper) canvas) .structTree{ + contain:strict; + } + +.pdfViewer .page{ + --scale-round-x:1px; + --scale-round-y:1px; + + direction:ltr; + width:816px; + height:1056px; + margin:var(--page-margin); + position:relative; + overflow:visible; + border:var(--page-border); + background-clip:content-box; + background-color:var(--page-bg-color, rgb(255 255 255)); +} + +.pdfViewer .dummyPage{ + position:relative; + width:0; + height:var(--viewer-container-height); +} + +.pdfViewer.noUserSelect{ + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; +} + +.pdfViewer.removePageBorders .page{ + margin:0 auto 10px; + border:none; +} + +.pdfViewer.singlePageView{ + display:inline-block; +} + +.pdfViewer.singlePageView .page{ + margin:0; + border:none; +} + +.pdfViewer:is(.scrollHorizontal, .scrollWrapped), +.spread{ + margin-inline:3.5px; + text-align:center; +} + +.pdfViewer.scrollHorizontal, +.spread{ + white-space:nowrap; +} + +.pdfViewer.removePageBorders, +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .spread{ + margin-inline:0; +} + +.spread :is(.page, .dummyPage), +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) :is(.page, .spread){ + display:inline-block; + vertical-align:middle; +} + +.spread .page, +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .page{ + margin-inline:var(--spreadHorizontalWrapped-margin-LR); +} + +.pdfViewer.removePageBorders .spread .page, +.pdfViewer.removePageBorders:is(.scrollHorizontal, .scrollWrapped) .page{ + margin-inline:5px; +} + +.pdfViewer .page.loadingIcon::after{ + position:absolute; + top:0; + left:0; + content:""; + width:100%; + height:100%; + background:url("images/loading-icon.gif") center no-repeat; + display:none; + transition-property:display; + transition-delay:var(--loading-icon-delay); + z-index:5; + contain:strict; +} + +.pdfViewer .page.loading::after{ + display:block; +} + +.pdfViewer .page:not(.loading)::after{ + transition-property:none; + display:none; +} + +.pdfPresentationMode .pdfViewer{ + padding-bottom:0; +} + +.pdfPresentationMode .spread{ + margin:0; +} + +.pdfPresentationMode .pdfViewer .page{ + margin:0 auto; + border:2px solid transparent; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs new file mode 100644 index 00000000000..9b2c200c99e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs @@ -0,0 +1,8435 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2024 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */ + +/******/ // The require scope +/******/ var __webpack_require__ = {}; +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = globalThis.pdfjsViewer = {}; + +// EXPORTS +__webpack_require__.d(__webpack_exports__, { + AnnotationLayerBuilder: () => (/* reexport */ AnnotationLayerBuilder), + DownloadManager: () => (/* reexport */ DownloadManager), + EventBus: () => (/* reexport */ EventBus), + FindState: () => (/* reexport */ FindState), + GenericL10n: () => (/* reexport */ genericl10n_GenericL10n), + LinkTarget: () => (/* reexport */ LinkTarget), + PDFFindController: () => (/* reexport */ PDFFindController), + PDFHistory: () => (/* reexport */ PDFHistory), + PDFLinkService: () => (/* reexport */ PDFLinkService), + PDFPageView: () => (/* reexport */ PDFPageView), + PDFScriptingManager: () => (/* reexport */ PDFScriptingManagerComponents), + PDFSinglePageViewer: () => (/* reexport */ PDFSinglePageViewer), + PDFViewer: () => (/* reexport */ PDFViewer), + ProgressBar: () => (/* reexport */ ProgressBar), + RenderingStates: () => (/* reexport */ RenderingStates), + ScrollMode: () => (/* reexport */ ScrollMode), + SimpleLinkService: () => (/* reexport */ SimpleLinkService), + SpreadMode: () => (/* reexport */ SpreadMode), + StructTreeLayerBuilder: () => (/* reexport */ StructTreeLayerBuilder), + TextLayerBuilder: () => (/* reexport */ TextLayerBuilder), + XfaLayerBuilder: () => (/* reexport */ XfaLayerBuilder), + parseQueryString: () => (/* reexport */ parseQueryString) +}); + +;// ./web/ui_utils.js +const DEFAULT_SCALE_VALUE = "auto"; +const DEFAULT_SCALE = 1.0; +const DEFAULT_SCALE_DELTA = 1.1; +const MIN_SCALE = 0.1; +const MAX_SCALE = 10.0; +const UNKNOWN_SCALE = 0; +const MAX_AUTO_SCALE = 1.25; +const SCROLLBAR_PADDING = 40; +const VERTICAL_PADDING = 5; +const RenderingStates = { + INITIAL: 0, + RUNNING: 1, + PAUSED: 2, + FINISHED: 3 +}; +const PresentationModeState = { + UNKNOWN: 0, + NORMAL: 1, + CHANGING: 2, + FULLSCREEN: 3 +}; +const SidebarView = { + UNKNOWN: -1, + NONE: 0, + THUMBS: 1, + OUTLINE: 2, + ATTACHMENTS: 3, + LAYERS: 4 +}; +const TextLayerMode = { + DISABLE: 0, + ENABLE: 1, + ENABLE_PERMISSIONS: 2 +}; +const ScrollMode = { + UNKNOWN: -1, + VERTICAL: 0, + HORIZONTAL: 1, + WRAPPED: 2, + PAGE: 3 +}; +const SpreadMode = { + UNKNOWN: -1, + NONE: 0, + ODD: 1, + EVEN: 2 +}; +const CursorTool = { + SELECT: 0, + HAND: 1, + ZOOM: 2 +}; +const AutoPrintRegExp = /\bprint\s*\(/; +function scrollIntoView(element, spot, scrollMatches = false) { + let parent = element.offsetParent; + if (!parent) { + console.error("offsetParent is not set -- cannot scroll"); + return; + } + let offsetY = element.offsetTop + element.clientTop; + let offsetX = element.offsetLeft + element.clientLeft; + while (parent.clientHeight === parent.scrollHeight && parent.clientWidth === parent.scrollWidth || scrollMatches && (parent.classList.contains("markedContent") || getComputedStyle(parent).overflow === "hidden")) { + offsetY += parent.offsetTop; + offsetX += parent.offsetLeft; + parent = parent.offsetParent; + if (!parent) { + return; + } + } + if (spot) { + if (spot.top !== undefined) { + offsetY += spot.top; + } + if (spot.left !== undefined) { + offsetX += spot.left; + parent.scrollLeft = offsetX; + } + } + parent.scrollTop = offsetY; +} +function watchScroll(viewAreaElement, callback, abortSignal = undefined) { + const debounceScroll = function (evt) { + if (rAF) { + return; + } + rAF = window.requestAnimationFrame(function viewAreaElementScrolled() { + rAF = null; + const currentX = viewAreaElement.scrollLeft; + const lastX = state.lastX; + if (currentX !== lastX) { + state.right = currentX > lastX; + } + state.lastX = currentX; + const currentY = viewAreaElement.scrollTop; + const lastY = state.lastY; + if (currentY !== lastY) { + state.down = currentY > lastY; + } + state.lastY = currentY; + callback(state); + }); + }; + const state = { + right: true, + down: true, + lastX: viewAreaElement.scrollLeft, + lastY: viewAreaElement.scrollTop, + _eventHandler: debounceScroll + }; + let rAF = null; + viewAreaElement.addEventListener("scroll", debounceScroll, { + useCapture: true, + signal: abortSignal + }); + abortSignal?.addEventListener("abort", () => window.cancelAnimationFrame(rAF), { + once: true + }); + return state; +} +function parseQueryString(query) { + const params = new Map(); + for (const [key, value] of new URLSearchParams(query)) { + params.set(key.toLowerCase(), value); + } + return params; +} +const InvisibleCharsRegExp = /[\x00-\x1F]/g; +function removeNullCharacters(str, replaceInvisible = false) { + if (!InvisibleCharsRegExp.test(str)) { + return str; + } + if (replaceInvisible) { + return str.replaceAll(InvisibleCharsRegExp, m => m === "\x00" ? "" : " "); + } + return str.replaceAll("\x00", ""); +} +function binarySearchFirstItem(items, condition, start = 0) { + let minIndex = start; + let maxIndex = items.length - 1; + if (maxIndex < 0 || !condition(items[maxIndex])) { + return items.length; + } + if (condition(items[minIndex])) { + return minIndex; + } + while (minIndex < maxIndex) { + const currentIndex = minIndex + maxIndex >> 1; + const currentItem = items[currentIndex]; + if (condition(currentItem)) { + maxIndex = currentIndex; + } else { + minIndex = currentIndex + 1; + } + } + return minIndex; +} +function approximateFraction(x) { + if (Math.floor(x) === x) { + return [x, 1]; + } + const xinv = 1 / x; + const limit = 8; + if (xinv > limit) { + return [1, limit]; + } else if (Math.floor(xinv) === xinv) { + return [1, xinv]; + } + const x_ = x > 1 ? xinv : x; + let a = 0, + b = 1, + c = 1, + d = 1; + while (true) { + const p = a + c, + q = b + d; + if (q > limit) { + break; + } + if (x_ <= p / q) { + c = p; + d = q; + } else { + a = p; + b = q; + } + } + let result; + if (x_ - a / b < c / d - x_) { + result = x_ === x ? [a, b] : [b, a]; + } else { + result = x_ === x ? [c, d] : [d, c]; + } + return result; +} +function floorToDivide(x, div) { + return x - x % div; +} +function getPageSizeInches({ + view, + userUnit, + rotate +}) { + const [x1, y1, x2, y2] = view; + const changeOrientation = rotate % 180 !== 0; + const width = (x2 - x1) / 72 * userUnit; + const height = (y2 - y1) / 72 * userUnit; + return { + width: changeOrientation ? height : width, + height: changeOrientation ? width : height + }; +} +function backtrackBeforeAllVisibleElements(index, views, top) { + if (index < 2) { + return index; + } + let elt = views[index].div; + let pageTop = elt.offsetTop + elt.clientTop; + if (pageTop >= top) { + elt = views[index - 1].div; + pageTop = elt.offsetTop + elt.clientTop; + } + for (let i = index - 2; i >= 0; --i) { + elt = views[i].div; + if (elt.offsetTop + elt.clientTop + elt.clientHeight <= pageTop) { + break; + } + index = i; + } + return index; +} +function getVisibleElements({ + scrollEl, + views, + sortByVisibility = false, + horizontal = false, + rtl = false +}) { + const top = scrollEl.scrollTop, + bottom = top + scrollEl.clientHeight; + const left = scrollEl.scrollLeft, + right = left + scrollEl.clientWidth; + function isElementBottomAfterViewTop(view) { + const element = view.div; + const elementBottom = element.offsetTop + element.clientTop + element.clientHeight; + return elementBottom > top; + } + function isElementNextAfterViewHorizontally(view) { + const element = view.div; + const elementLeft = element.offsetLeft + element.clientLeft; + const elementRight = elementLeft + element.clientWidth; + return rtl ? elementLeft < right : elementRight > left; + } + const visible = [], + ids = new Set(), + numViews = views.length; + let firstVisibleElementInd = binarySearchFirstItem(views, horizontal ? isElementNextAfterViewHorizontally : isElementBottomAfterViewTop); + if (firstVisibleElementInd > 0 && firstVisibleElementInd < numViews && !horizontal) { + firstVisibleElementInd = backtrackBeforeAllVisibleElements(firstVisibleElementInd, views, top); + } + let lastEdge = horizontal ? right : -1; + for (let i = firstVisibleElementInd; i < numViews; i++) { + const view = views[i], + element = view.div; + const currentWidth = element.offsetLeft + element.clientLeft; + const currentHeight = element.offsetTop + element.clientTop; + const viewWidth = element.clientWidth, + viewHeight = element.clientHeight; + const viewRight = currentWidth + viewWidth; + const viewBottom = currentHeight + viewHeight; + if (lastEdge === -1) { + if (viewBottom >= bottom) { + lastEdge = viewBottom; + } + } else if ((horizontal ? currentWidth : currentHeight) > lastEdge) { + break; + } + if (viewBottom <= top || currentHeight >= bottom || viewRight <= left || currentWidth >= right) { + continue; + } + const hiddenHeight = Math.max(0, top - currentHeight) + Math.max(0, viewBottom - bottom); + const hiddenWidth = Math.max(0, left - currentWidth) + Math.max(0, viewRight - right); + const fractionHeight = (viewHeight - hiddenHeight) / viewHeight, + fractionWidth = (viewWidth - hiddenWidth) / viewWidth; + const percent = fractionHeight * fractionWidth * 100 | 0; + visible.push({ + id: view.id, + x: currentWidth, + y: currentHeight, + view, + percent, + widthPercent: fractionWidth * 100 | 0 + }); + ids.add(view.id); + } + const first = visible[0], + last = visible.at(-1); + if (sortByVisibility) { + visible.sort(function (a, b) { + const pc = a.percent - b.percent; + if (Math.abs(pc) > 0.001) { + return -pc; + } + return a.id - b.id; + }); + } + return { + first, + last, + views: visible, + ids + }; +} +function normalizeWheelEventDirection(evt) { + let delta = Math.hypot(evt.deltaX, evt.deltaY); + const angle = Math.atan2(evt.deltaY, evt.deltaX); + if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) { + delta = -delta; + } + return delta; +} +function normalizeWheelEventDelta(evt) { + const deltaMode = evt.deltaMode; + let delta = normalizeWheelEventDirection(evt); + const MOUSE_PIXELS_PER_LINE = 30; + const MOUSE_LINES_PER_PAGE = 30; + if (deltaMode === WheelEvent.DOM_DELTA_PIXEL) { + delta /= MOUSE_PIXELS_PER_LINE * MOUSE_LINES_PER_PAGE; + } else if (deltaMode === WheelEvent.DOM_DELTA_LINE) { + delta /= MOUSE_LINES_PER_PAGE; + } + return delta; +} +function isValidRotation(angle) { + return Number.isInteger(angle) && angle % 90 === 0; +} +function isValidScrollMode(mode) { + return Number.isInteger(mode) && Object.values(ScrollMode).includes(mode) && mode !== ScrollMode.UNKNOWN; +} +function isValidSpreadMode(mode) { + return Number.isInteger(mode) && Object.values(SpreadMode).includes(mode) && mode !== SpreadMode.UNKNOWN; +} +function isPortraitOrientation(size) { + return size.width <= size.height; +} +const animationStarted = new Promise(function (resolve) { + window.requestAnimationFrame(resolve); +}); +const docStyle = document.documentElement.style; +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} +class ProgressBar { + #classList = null; + #disableAutoFetchTimeout = null; + #percent = 0; + #style = null; + #visible = true; + constructor(bar) { + this.#classList = bar.classList; + this.#style = bar.style; + } + get percent() { + return this.#percent; + } + set percent(val) { + this.#percent = clamp(val, 0, 100); + if (isNaN(val)) { + this.#classList.add("indeterminate"); + return; + } + this.#classList.remove("indeterminate"); + this.#style.setProperty("--progressBar-percent", `${this.#percent}%`); + } + setWidth(viewer) { + if (!viewer) { + return; + } + const container = viewer.parentNode; + const scrollbarWidth = container.offsetWidth - viewer.offsetWidth; + if (scrollbarWidth > 0) { + this.#style.setProperty("--progressBar-end-offset", `${scrollbarWidth}px`); + } + } + setDisableAutoFetch(delay = 5000) { + if (this.#percent === 100 || isNaN(this.#percent)) { + return; + } + if (this.#disableAutoFetchTimeout) { + clearTimeout(this.#disableAutoFetchTimeout); + } + this.show(); + this.#disableAutoFetchTimeout = setTimeout(() => { + this.#disableAutoFetchTimeout = null; + this.hide(); + }, delay); + } + hide() { + if (!this.#visible) { + return; + } + this.#visible = false; + this.#classList.add("hidden"); + } + show() { + if (this.#visible) { + return; + } + this.#visible = true; + this.#classList.remove("hidden"); + } +} +function getActiveOrFocusedElement() { + let curRoot = document; + let curActiveOrFocused = curRoot.activeElement || curRoot.querySelector(":focus"); + while (curActiveOrFocused?.shadowRoot) { + curRoot = curActiveOrFocused.shadowRoot; + curActiveOrFocused = curRoot.activeElement || curRoot.querySelector(":focus"); + } + return curActiveOrFocused; +} +function apiPageLayoutToViewerModes(layout) { + let scrollMode = ScrollMode.VERTICAL, + spreadMode = SpreadMode.NONE; + switch (layout) { + case "SinglePage": + scrollMode = ScrollMode.PAGE; + break; + case "OneColumn": + break; + case "TwoPageLeft": + scrollMode = ScrollMode.PAGE; + case "TwoColumnLeft": + spreadMode = SpreadMode.ODD; + break; + case "TwoPageRight": + scrollMode = ScrollMode.PAGE; + case "TwoColumnRight": + spreadMode = SpreadMode.EVEN; + break; + } + return { + scrollMode, + spreadMode + }; +} +function apiPageModeToSidebarView(mode) { + switch (mode) { + case "UseNone": + return SidebarView.NONE; + case "UseThumbs": + return SidebarView.THUMBS; + case "UseOutlines": + return SidebarView.OUTLINE; + case "UseAttachments": + return SidebarView.ATTACHMENTS; + case "UseOC": + return SidebarView.LAYERS; + } + return SidebarView.NONE; +} +function toggleCheckedBtn(button, toggle, view = null) { + button.classList.toggle("toggled", toggle); + button.setAttribute("aria-checked", toggle); + view?.classList.toggle("hidden", !toggle); +} +function toggleExpandedBtn(button, toggle, view = null) { + button.classList.toggle("toggled", toggle); + button.setAttribute("aria-expanded", toggle); + view?.classList.toggle("hidden", !toggle); +} +const calcRound = function () { + const e = document.createElement("div"); + e.style.width = "round(down, calc(1.6666666666666665 * 792px), 1px)"; + return e.style.width === "calc(1320px)" ? Math.fround : x => x; +}(); + +;// ./web/pdf_find_utils.js +const CharacterType = { + SPACE: 0, + ALPHA_LETTER: 1, + PUNCT: 2, + HAN_LETTER: 3, + KATAKANA_LETTER: 4, + HIRAGANA_LETTER: 5, + HALFWIDTH_KATAKANA_LETTER: 6, + THAI_LETTER: 7 +}; +function isAlphabeticalScript(charCode) { + return charCode < 0x2e80; +} +function isAscii(charCode) { + return (charCode & 0xff80) === 0; +} +function isAsciiAlpha(charCode) { + return charCode >= 0x61 && charCode <= 0x7a || charCode >= 0x41 && charCode <= 0x5a; +} +function isAsciiDigit(charCode) { + return charCode >= 0x30 && charCode <= 0x39; +} +function isAsciiSpace(charCode) { + return charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a; +} +function isHan(charCode) { + return charCode >= 0x3400 && charCode <= 0x9fff || charCode >= 0xf900 && charCode <= 0xfaff; +} +function isKatakana(charCode) { + return charCode >= 0x30a0 && charCode <= 0x30ff; +} +function isHiragana(charCode) { + return charCode >= 0x3040 && charCode <= 0x309f; +} +function isHalfwidthKatakana(charCode) { + return charCode >= 0xff60 && charCode <= 0xff9f; +} +function isThai(charCode) { + return (charCode & 0xff80) === 0x0e00; +} +function getCharacterType(charCode) { + if (isAlphabeticalScript(charCode)) { + if (isAscii(charCode)) { + if (isAsciiSpace(charCode)) { + return CharacterType.SPACE; + } else if (isAsciiAlpha(charCode) || isAsciiDigit(charCode) || charCode === 0x5f) { + return CharacterType.ALPHA_LETTER; + } + return CharacterType.PUNCT; + } else if (isThai(charCode)) { + return CharacterType.THAI_LETTER; + } else if (charCode === 0xa0) { + return CharacterType.SPACE; + } + return CharacterType.ALPHA_LETTER; + } + if (isHan(charCode)) { + return CharacterType.HAN_LETTER; + } else if (isKatakana(charCode)) { + return CharacterType.KATAKANA_LETTER; + } else if (isHiragana(charCode)) { + return CharacterType.HIRAGANA_LETTER; + } else if (isHalfwidthKatakana(charCode)) { + return CharacterType.HALFWIDTH_KATAKANA_LETTER; + } + return CharacterType.ALPHA_LETTER; +} +let NormalizeWithNFKC; +function getNormalizeWithNFKC() { + NormalizeWithNFKC ||= ` ¨ª¯²-µ¸-º¼-¾IJ-ijĿ-ŀʼnſDŽ-njDZ-dzʰ-ʸ˘-˝ˠ-ˤʹͺ;΄-΅·ϐ-ϖϰ-ϲϴ-ϵϹևٵ-ٸक़-य़ড়-ঢ়য়ਲ਼ਸ਼ਖ਼-ਜ਼ਫ਼ଡ଼-ଢ଼ำຳໜ-ໝ༌གྷཌྷདྷབྷཛྷཀྵჼᴬ-ᴮᴰ-ᴺᴼ-ᵍᵏ-ᵪᵸᶛ-ᶿẚ-ẛάέήίόύώΆ᾽-῁ΈΉ῍-῏ΐΊ῝-῟ΰΎ῭-`ΌΏ´-῾ - ‑‗․-… ″-‴‶-‷‼‾⁇-⁉⁗ ⁰-ⁱ⁴-₎ₐ-ₜ₨℀-℃℅-ℇ℉-ℓℕ-№ℙ-ℝ℠-™ℤΩℨK-ℭℯ-ℱℳ-ℹ℻-⅀ⅅ-ⅉ⅐-ⅿ↉∬-∭∯-∰〈-〉①-⓪⨌⩴-⩶⫝̸ⱼ-ⱽⵯ⺟⻳⼀-⿕ 〶〸-〺゛-゜ゟヿㄱ-ㆎ㆒-㆟㈀-㈞㈠-㉇㉐-㉾㊀-㏿ꚜ-ꚝꝰꟲ-ꟴꟸ-ꟹꭜ-ꭟꭩ豈-嗀塚晴凞-羽蘒諸逸-都飯-舘並-龎ff-stﬓ-ﬗיִײַ-זּטּ-לּמּנּ-סּףּ-פּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-﷼︐-︙︰-﹄﹇-﹒﹔-﹦﹨-﹫ﹰ-ﹲﹴﹶ-ﻼ!-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ¢-₩`; + return NormalizeWithNFKC; +} + +;// ./web/pdf_find_controller.js + + +const FindState = { + FOUND: 0, + NOT_FOUND: 1, + WRAPPED: 2, + PENDING: 3 +}; +const FIND_TIMEOUT = 250; +const MATCH_SCROLL_OFFSET_TOP = -50; +const MATCH_SCROLL_OFFSET_LEFT = -400; +const CHARACTERS_TO_NORMALIZE = { + "\u2010": "-", + "\u2018": "'", + "\u2019": "'", + "\u201A": "'", + "\u201B": "'", + "\u201C": '"', + "\u201D": '"', + "\u201E": '"', + "\u201F": '"', + "\u00BC": "1/4", + "\u00BD": "1/2", + "\u00BE": "3/4" +}; +const DIACRITICS_EXCEPTION = new Set([0x3099, 0x309a, 0x094d, 0x09cd, 0x0a4d, 0x0acd, 0x0b4d, 0x0bcd, 0x0c4d, 0x0ccd, 0x0d3b, 0x0d3c, 0x0d4d, 0x0dca, 0x0e3a, 0x0eba, 0x0f84, 0x1039, 0x103a, 0x1714, 0x1734, 0x17d2, 0x1a60, 0x1b44, 0x1baa, 0x1bab, 0x1bf2, 0x1bf3, 0x2d7f, 0xa806, 0xa82c, 0xa8c4, 0xa953, 0xa9c0, 0xaaf6, 0xabed, 0x0c56, 0x0f71, 0x0f72, 0x0f7a, 0x0f7b, 0x0f7c, 0x0f7d, 0x0f80, 0x0f74]); +let DIACRITICS_EXCEPTION_STR; +const DIACRITICS_REG_EXP = /\p{M}+/gu; +const SPECIAL_CHARS_REG_EXP = /([.*+?^${}()|[\]\\])|(\p{P})|(\s+)|(\p{M})|(\p{L})/gu; +const NOT_DIACRITIC_FROM_END_REG_EXP = /([^\p{M}])\p{M}*$/u; +const NOT_DIACRITIC_FROM_START_REG_EXP = /^\p{M}*([^\p{M}])/u; +const SYLLABLES_REG_EXP = /[\uAC00-\uD7AF\uFA6C\uFACF-\uFAD1\uFAD5-\uFAD7]+/g; +const SYLLABLES_LENGTHS = new Map(); +const FIRST_CHAR_SYLLABLES_REG_EXP = "[\\u1100-\\u1112\\ud7a4-\\ud7af\\ud84a\\ud84c\\ud850\\ud854\\ud857\\ud85f]"; +const NFKC_CHARS_TO_NORMALIZE = new Map(); +let noSyllablesRegExp = null; +let withSyllablesRegExp = null; +function normalize(text) { + const syllablePositions = []; + let m; + while ((m = SYLLABLES_REG_EXP.exec(text)) !== null) { + let { + index + } = m; + for (const char of m[0]) { + let len = SYLLABLES_LENGTHS.get(char); + if (!len) { + len = char.normalize("NFD").length; + SYLLABLES_LENGTHS.set(char, len); + } + syllablePositions.push([len, index++]); + } + } + let normalizationRegex; + if (syllablePositions.length === 0 && noSyllablesRegExp) { + normalizationRegex = noSyllablesRegExp; + } else if (syllablePositions.length > 0 && withSyllablesRegExp) { + normalizationRegex = withSyllablesRegExp; + } else { + const replace = Object.keys(CHARACTERS_TO_NORMALIZE).join(""); + const toNormalizeWithNFKC = getNormalizeWithNFKC(); + const CJK = "(?:\\p{Ideographic}|[\u3040-\u30FF])"; + const HKDiacritics = "(?:\u3099|\u309A)"; + const CompoundWord = "\\p{Ll}-\\n\\p{Lu}"; + const regexp = `([${replace}])|([${toNormalizeWithNFKC}])|(${HKDiacritics}\\n)|(\\p{M}+(?:-\\n)?)|(${CompoundWord})|(\\S-\\n)|(${CJK}\\n)|(\\n)`; + if (syllablePositions.length === 0) { + normalizationRegex = noSyllablesRegExp = new RegExp(regexp + "|(\\u0000)", "gum"); + } else { + normalizationRegex = withSyllablesRegExp = new RegExp(regexp + `|(${FIRST_CHAR_SYLLABLES_REG_EXP})`, "gum"); + } + } + const rawDiacriticsPositions = []; + while ((m = DIACRITICS_REG_EXP.exec(text)) !== null) { + rawDiacriticsPositions.push([m[0].length, m.index]); + } + let normalized = text.normalize("NFD"); + const positions = [0, 0]; + let rawDiacriticsIndex = 0; + let syllableIndex = 0; + let shift = 0; + let shiftOrigin = 0; + let eol = 0; + let hasDiacritics = false; + normalized = normalized.replace(normalizationRegex, (match, p1, p2, p3, p4, p5, p6, p7, p8, p9, i) => { + i -= shiftOrigin; + if (p1) { + const replacement = CHARACTERS_TO_NORMALIZE[p1]; + const jj = replacement.length; + for (let j = 1; j < jj; j++) { + positions.push(i - shift + j, shift - j); + } + shift -= jj - 1; + return replacement; + } + if (p2) { + let replacement = NFKC_CHARS_TO_NORMALIZE.get(p2); + if (!replacement) { + replacement = p2.normalize("NFKC"); + NFKC_CHARS_TO_NORMALIZE.set(p2, replacement); + } + const jj = replacement.length; + for (let j = 1; j < jj; j++) { + positions.push(i - shift + j, shift - j); + } + shift -= jj - 1; + return replacement; + } + if (p3) { + hasDiacritics = true; + if (i + eol === rawDiacriticsPositions[rawDiacriticsIndex]?.[1]) { + ++rawDiacriticsIndex; + } else { + positions.push(i - 1 - shift + 1, shift - 1); + shift -= 1; + shiftOrigin += 1; + } + positions.push(i - shift + 1, shift); + shiftOrigin += 1; + eol += 1; + return p3.charAt(0); + } + if (p4) { + const hasTrailingDashEOL = p4.endsWith("\n"); + const len = hasTrailingDashEOL ? p4.length - 2 : p4.length; + hasDiacritics = true; + let jj = len; + if (i + eol === rawDiacriticsPositions[rawDiacriticsIndex]?.[1]) { + jj -= rawDiacriticsPositions[rawDiacriticsIndex][0]; + ++rawDiacriticsIndex; + } + for (let j = 1; j <= jj; j++) { + positions.push(i - 1 - shift + j, shift - j); + } + shift -= jj; + shiftOrigin += jj; + if (hasTrailingDashEOL) { + i += len - 1; + positions.push(i - shift + 1, 1 + shift); + shift += 1; + shiftOrigin += 1; + eol += 1; + return p4.slice(0, len); + } + return p4; + } + if (p5) { + shiftOrigin += 1; + eol += 1; + return p5.replace("\n", ""); + } + if (p6) { + const len = p6.length - 2; + positions.push(i - shift + len, 1 + shift); + shift += 1; + shiftOrigin += 1; + eol += 1; + return p6.slice(0, -2); + } + if (p7) { + const len = p7.length - 1; + positions.push(i - shift + len, shift); + shiftOrigin += 1; + eol += 1; + return p7.slice(0, -1); + } + if (p8) { + positions.push(i - shift + 1, shift - 1); + shift -= 1; + shiftOrigin += 1; + eol += 1; + return " "; + } + if (i + eol === syllablePositions[syllableIndex]?.[1]) { + const newCharLen = syllablePositions[syllableIndex][0] - 1; + ++syllableIndex; + for (let j = 1; j <= newCharLen; j++) { + positions.push(i - (shift - j), shift - j); + } + shift -= newCharLen; + shiftOrigin += newCharLen; + } + return p9; + }); + positions.push(normalized.length, shift); + const starts = new Uint32Array(positions.length >> 1); + const shifts = new Int32Array(positions.length >> 1); + for (let i = 0, ii = positions.length; i < ii; i += 2) { + starts[i >> 1] = positions[i]; + shifts[i >> 1] = positions[i + 1]; + } + return [normalized, [starts, shifts], hasDiacritics]; +} +function getOriginalIndex(diffs, pos, len) { + if (!diffs) { + return [pos, len]; + } + const [starts, shifts] = diffs; + const start = pos; + const end = pos + len - 1; + let i = binarySearchFirstItem(starts, x => x >= start); + if (starts[i] > start) { + --i; + } + let j = binarySearchFirstItem(starts, x => x >= end, i); + if (starts[j] > end) { + --j; + } + const oldStart = start + shifts[i]; + const oldEnd = end + shifts[j]; + const oldLen = oldEnd + 1 - oldStart; + return [oldStart, oldLen]; +} +class PDFFindController { + #state = null; + #updateMatchesCountOnProgress = true; + #visitedPagesCount = 0; + constructor({ + linkService, + eventBus, + updateMatchesCountOnProgress = true + }) { + this._linkService = linkService; + this._eventBus = eventBus; + this.#updateMatchesCountOnProgress = updateMatchesCountOnProgress; + this.onIsPageVisible = null; + this.#reset(); + eventBus._on("find", this.#onFind.bind(this)); + eventBus._on("findbarclose", this.#onFindBarClose.bind(this)); + } + get highlightMatches() { + return this._highlightMatches; + } + get pageMatches() { + return this._pageMatches; + } + get pageMatchesLength() { + return this._pageMatchesLength; + } + get selected() { + return this._selected; + } + get state() { + return this.#state; + } + setDocument(pdfDocument) { + if (this._pdfDocument) { + this.#reset(); + } + if (!pdfDocument) { + return; + } + this._pdfDocument = pdfDocument; + this._firstPageCapability.resolve(); + } + #onFind(state) { + if (!state) { + return; + } + const pdfDocument = this._pdfDocument; + const { + type + } = state; + if (this.#state === null || this.#shouldDirtyMatch(state)) { + this._dirtyMatch = true; + } + this.#state = state; + if (type !== "highlightallchange") { + this.#updateUIState(FindState.PENDING); + } + this._firstPageCapability.promise.then(() => { + if (!this._pdfDocument || pdfDocument && this._pdfDocument !== pdfDocument) { + return; + } + this.#extractText(); + const findbarClosed = !this._highlightMatches; + const pendingTimeout = !!this._findTimeout; + if (this._findTimeout) { + clearTimeout(this._findTimeout); + this._findTimeout = null; + } + if (!type) { + this._findTimeout = setTimeout(() => { + this.#nextMatch(); + this._findTimeout = null; + }, FIND_TIMEOUT); + } else if (this._dirtyMatch) { + this.#nextMatch(); + } else if (type === "again") { + this.#nextMatch(); + if (findbarClosed && this.#state.highlightAll) { + this.#updateAllPages(); + } + } else if (type === "highlightallchange") { + if (pendingTimeout) { + this.#nextMatch(); + } else { + this._highlightMatches = true; + } + this.#updateAllPages(); + } else { + this.#nextMatch(); + } + }); + } + scrollMatchIntoView({ + element = null, + selectedLeft = 0, + pageIndex = -1, + matchIndex = -1 + }) { + if (!this._scrollMatches || !element) { + return; + } else if (matchIndex === -1 || matchIndex !== this._selected.matchIdx) { + return; + } else if (pageIndex === -1 || pageIndex !== this._selected.pageIdx) { + return; + } + this._scrollMatches = false; + const spot = { + top: MATCH_SCROLL_OFFSET_TOP, + left: selectedLeft + MATCH_SCROLL_OFFSET_LEFT + }; + scrollIntoView(element, spot, true); + } + #reset() { + this._highlightMatches = false; + this._scrollMatches = false; + this._pdfDocument = null; + this._pageMatches = []; + this._pageMatchesLength = []; + this.#visitedPagesCount = 0; + this.#state = null; + this._selected = { + pageIdx: -1, + matchIdx: -1 + }; + this._offset = { + pageIdx: null, + matchIdx: null, + wrapped: false + }; + this._extractTextPromises = []; + this._pageContents = []; + this._pageDiffs = []; + this._hasDiacritics = []; + this._matchesCountTotal = 0; + this._pagesToSearch = null; + this._pendingFindMatches = new Set(); + this._resumePageIdx = null; + this._dirtyMatch = false; + clearTimeout(this._findTimeout); + this._findTimeout = null; + this._firstPageCapability = Promise.withResolvers(); + } + get #query() { + const { + query + } = this.#state; + if (typeof query === "string") { + if (query !== this._rawQuery) { + this._rawQuery = query; + [this._normalizedQuery] = normalize(query); + } + return this._normalizedQuery; + } + return (query || []).filter(q => !!q).map(q => normalize(q)[0]); + } + #shouldDirtyMatch(state) { + const newQuery = state.query, + prevQuery = this.#state.query; + const newType = typeof newQuery, + prevType = typeof prevQuery; + if (newType !== prevType) { + return true; + } + if (newType === "string") { + if (newQuery !== prevQuery) { + return true; + } + } else if (JSON.stringify(newQuery) !== JSON.stringify(prevQuery)) { + return true; + } + switch (state.type) { + case "again": + const pageNumber = this._selected.pageIdx + 1; + const linkService = this._linkService; + return pageNumber >= 1 && pageNumber <= linkService.pagesCount && pageNumber !== linkService.page && !(this.onIsPageVisible?.(pageNumber) ?? true); + case "highlightallchange": + return false; + } + return true; + } + #isEntireWord(content, startIdx, length) { + let match = content.slice(0, startIdx).match(NOT_DIACRITIC_FROM_END_REG_EXP); + if (match) { + const first = content.charCodeAt(startIdx); + const limit = match[1].charCodeAt(0); + if (getCharacterType(first) === getCharacterType(limit)) { + return false; + } + } + match = content.slice(startIdx + length).match(NOT_DIACRITIC_FROM_START_REG_EXP); + if (match) { + const last = content.charCodeAt(startIdx + length - 1); + const limit = match[1].charCodeAt(0); + if (getCharacterType(last) === getCharacterType(limit)) { + return false; + } + } + return true; + } + #convertToRegExpString(query, hasDiacritics) { + const { + matchDiacritics + } = this.#state; + let isUnicode = false; + query = query.replaceAll(SPECIAL_CHARS_REG_EXP, (match, p1, p2, p3, p4, p5) => { + if (p1) { + return `[ ]*\\${p1}[ ]*`; + } + if (p2) { + return `[ ]*${p2}[ ]*`; + } + if (p3) { + return "[ ]+"; + } + if (matchDiacritics) { + return p4 || p5; + } + if (p4) { + return DIACRITICS_EXCEPTION.has(p4.charCodeAt(0)) ? p4 : ""; + } + if (hasDiacritics) { + isUnicode = true; + return `${p5}\\p{M}*`; + } + return p5; + }); + const trailingSpaces = "[ ]*"; + if (query.endsWith(trailingSpaces)) { + query = query.slice(0, query.length - trailingSpaces.length); + } + if (matchDiacritics) { + if (hasDiacritics) { + DIACRITICS_EXCEPTION_STR ||= String.fromCharCode(...DIACRITICS_EXCEPTION); + isUnicode = true; + query = `${query}(?=[${DIACRITICS_EXCEPTION_STR}]|[^\\p{M}]|$)`; + } + } + return [isUnicode, query]; + } + #calculateMatch(pageIndex) { + const query = this.#query; + if (query.length === 0) { + return; + } + const pageContent = this._pageContents[pageIndex]; + const matcherResult = this.match(query, pageContent, pageIndex); + const matches = this._pageMatches[pageIndex] = []; + const matchesLength = this._pageMatchesLength[pageIndex] = []; + const diffs = this._pageDiffs[pageIndex]; + matcherResult?.forEach(({ + index, + length + }) => { + const [matchPos, matchLen] = getOriginalIndex(diffs, index, length); + if (matchLen) { + matches.push(matchPos); + matchesLength.push(matchLen); + } + }); + if (this.#state.highlightAll) { + this.#updatePage(pageIndex); + } + if (this._resumePageIdx === pageIndex) { + this._resumePageIdx = null; + this.#nextPageMatch(); + } + const pageMatchesCount = matches.length; + this._matchesCountTotal += pageMatchesCount; + if (this.#updateMatchesCountOnProgress) { + if (pageMatchesCount > 0) { + this.#updateUIResultsCount(); + } + } else if (++this.#visitedPagesCount === this._linkService.pagesCount) { + this.#updateUIResultsCount(); + } + } + match(query, pageContent, pageIndex) { + const hasDiacritics = this._hasDiacritics[pageIndex]; + let isUnicode = false; + if (typeof query === "string") { + [isUnicode, query] = this.#convertToRegExpString(query, hasDiacritics); + } else { + query = query.sort().reverse().map(q => { + const [isUnicodePart, queryPart] = this.#convertToRegExpString(q, hasDiacritics); + isUnicode ||= isUnicodePart; + return `(${queryPart})`; + }).join("|"); + } + if (!query) { + return undefined; + } + const { + caseSensitive, + entireWord + } = this.#state; + const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`; + query = new RegExp(query, flags); + const matches = []; + let match; + while ((match = query.exec(pageContent)) !== null) { + if (entireWord && !this.#isEntireWord(pageContent, match.index, match[0].length)) { + continue; + } + matches.push({ + index: match.index, + length: match[0].length + }); + } + return matches; + } + #extractText() { + if (this._extractTextPromises.length > 0) { + return; + } + let deferred = Promise.resolve(); + const textOptions = { + disableNormalization: true + }; + for (let i = 0, ii = this._linkService.pagesCount; i < ii; i++) { + const { + promise, + resolve + } = Promise.withResolvers(); + this._extractTextPromises[i] = promise; + deferred = deferred.then(() => { + return this._pdfDocument.getPage(i + 1).then(pdfPage => pdfPage.getTextContent(textOptions)).then(textContent => { + const strBuf = []; + for (const textItem of textContent.items) { + strBuf.push(textItem.str); + if (textItem.hasEOL) { + strBuf.push("\n"); + } + } + [this._pageContents[i], this._pageDiffs[i], this._hasDiacritics[i]] = normalize(strBuf.join("")); + resolve(); + }, reason => { + console.error(`Unable to get text content for page ${i + 1}`, reason); + this._pageContents[i] = ""; + this._pageDiffs[i] = null; + this._hasDiacritics[i] = false; + resolve(); + }); + }); + } + } + #updatePage(index) { + if (this._scrollMatches && this._selected.pageIdx === index) { + this._linkService.page = index + 1; + } + this._eventBus.dispatch("updatetextlayermatches", { + source: this, + pageIndex: index + }); + } + #updateAllPages() { + this._eventBus.dispatch("updatetextlayermatches", { + source: this, + pageIndex: -1 + }); + } + #nextMatch() { + const previous = this.#state.findPrevious; + const currentPageIndex = this._linkService.page - 1; + const numPages = this._linkService.pagesCount; + this._highlightMatches = true; + if (this._dirtyMatch) { + this._dirtyMatch = false; + this._selected.pageIdx = this._selected.matchIdx = -1; + this._offset.pageIdx = currentPageIndex; + this._offset.matchIdx = null; + this._offset.wrapped = false; + this._resumePageIdx = null; + this._pageMatches.length = 0; + this._pageMatchesLength.length = 0; + this.#visitedPagesCount = 0; + this._matchesCountTotal = 0; + this.#updateAllPages(); + for (let i = 0; i < numPages; i++) { + if (this._pendingFindMatches.has(i)) { + continue; + } + this._pendingFindMatches.add(i); + this._extractTextPromises[i].then(() => { + this._pendingFindMatches.delete(i); + this.#calculateMatch(i); + }); + } + } + const query = this.#query; + if (query.length === 0) { + this.#updateUIState(FindState.FOUND); + return; + } + if (this._resumePageIdx) { + return; + } + const offset = this._offset; + this._pagesToSearch = numPages; + if (offset.matchIdx !== null) { + const numPageMatches = this._pageMatches[offset.pageIdx].length; + if (!previous && offset.matchIdx + 1 < numPageMatches || previous && offset.matchIdx > 0) { + offset.matchIdx = previous ? offset.matchIdx - 1 : offset.matchIdx + 1; + this.#updateMatch(true); + return; + } + this.#advanceOffsetPage(previous); + } + this.#nextPageMatch(); + } + #matchesReady(matches) { + const offset = this._offset; + const numMatches = matches.length; + const previous = this.#state.findPrevious; + if (numMatches) { + offset.matchIdx = previous ? numMatches - 1 : 0; + this.#updateMatch(true); + return true; + } + this.#advanceOffsetPage(previous); + if (offset.wrapped) { + offset.matchIdx = null; + if (this._pagesToSearch < 0) { + this.#updateMatch(false); + return true; + } + } + return false; + } + #nextPageMatch() { + if (this._resumePageIdx !== null) { + console.error("There can only be one pending page."); + } + let matches = null; + do { + const pageIdx = this._offset.pageIdx; + matches = this._pageMatches[pageIdx]; + if (!matches) { + this._resumePageIdx = pageIdx; + break; + } + } while (!this.#matchesReady(matches)); + } + #advanceOffsetPage(previous) { + const offset = this._offset; + const numPages = this._linkService.pagesCount; + offset.pageIdx = previous ? offset.pageIdx - 1 : offset.pageIdx + 1; + offset.matchIdx = null; + this._pagesToSearch--; + if (offset.pageIdx >= numPages || offset.pageIdx < 0) { + offset.pageIdx = previous ? numPages - 1 : 0; + offset.wrapped = true; + } + } + #updateMatch(found = false) { + let state = FindState.NOT_FOUND; + const wrapped = this._offset.wrapped; + this._offset.wrapped = false; + if (found) { + const previousPage = this._selected.pageIdx; + this._selected.pageIdx = this._offset.pageIdx; + this._selected.matchIdx = this._offset.matchIdx; + state = wrapped ? FindState.WRAPPED : FindState.FOUND; + if (previousPage !== -1 && previousPage !== this._selected.pageIdx) { + this.#updatePage(previousPage); + } + } + this.#updateUIState(state, this.#state.findPrevious); + if (this._selected.pageIdx !== -1) { + this._scrollMatches = true; + this.#updatePage(this._selected.pageIdx); + } + } + #onFindBarClose(evt) { + const pdfDocument = this._pdfDocument; + this._firstPageCapability.promise.then(() => { + if (!this._pdfDocument || pdfDocument && this._pdfDocument !== pdfDocument) { + return; + } + if (this._findTimeout) { + clearTimeout(this._findTimeout); + this._findTimeout = null; + } + if (this._resumePageIdx) { + this._resumePageIdx = null; + this._dirtyMatch = true; + } + this.#updateUIState(FindState.FOUND); + this._highlightMatches = false; + this.#updateAllPages(); + }); + } + #requestMatchesCount() { + const { + pageIdx, + matchIdx + } = this._selected; + let current = 0, + total = this._matchesCountTotal; + if (matchIdx !== -1) { + for (let i = 0; i < pageIdx; i++) { + current += this._pageMatches[i]?.length || 0; + } + current += matchIdx + 1; + } + if (current < 1 || current > total) { + current = total = 0; + } + return { + current, + total + }; + } + #updateUIResultsCount() { + this._eventBus.dispatch("updatefindmatchescount", { + source: this, + matchesCount: this.#requestMatchesCount() + }); + } + #updateUIState(state, previous = false) { + if (!this.#updateMatchesCountOnProgress && (this.#visitedPagesCount !== this._linkService.pagesCount || state === FindState.PENDING)) { + return; + } + this._eventBus.dispatch("updatefindcontrolstate", { + source: this, + state, + previous, + entireWord: this.#state?.entireWord ?? null, + matchesCount: this.#requestMatchesCount(), + rawQuery: this.#state?.query ?? null + }); + } +} + +;// ./web/pdf_link_service.js + +const DEFAULT_LINK_REL = "noopener noreferrer nofollow"; +const LinkTarget = { + NONE: 0, + SELF: 1, + BLANK: 2, + PARENT: 3, + TOP: 4 +}; +class PDFLinkService { + externalLinkEnabled = true; + constructor({ + eventBus, + externalLinkTarget = null, + externalLinkRel = null, + ignoreDestinationZoom = false + } = {}) { + this.eventBus = eventBus; + this.externalLinkTarget = externalLinkTarget; + this.externalLinkRel = externalLinkRel; + this._ignoreDestinationZoom = ignoreDestinationZoom; + this.baseUrl = null; + this.pdfDocument = null; + this.pdfViewer = null; + this.pdfHistory = null; + } + setDocument(pdfDocument, baseUrl = null) { + this.baseUrl = baseUrl; + this.pdfDocument = pdfDocument; + } + setViewer(pdfViewer) { + this.pdfViewer = pdfViewer; + } + setHistory(pdfHistory) { + this.pdfHistory = pdfHistory; + } + get pagesCount() { + return this.pdfDocument ? this.pdfDocument.numPages : 0; + } + get page() { + return this.pdfDocument ? this.pdfViewer.currentPageNumber : 1; + } + set page(value) { + if (this.pdfDocument) { + this.pdfViewer.currentPageNumber = value; + } + } + get rotation() { + return this.pdfDocument ? this.pdfViewer.pagesRotation : 0; + } + set rotation(value) { + if (this.pdfDocument) { + this.pdfViewer.pagesRotation = value; + } + } + get isInPresentationMode() { + return this.pdfDocument ? this.pdfViewer.isInPresentationMode : false; + } + async goToDestination(dest) { + if (!this.pdfDocument) { + return; + } + let namedDest, explicitDest, pageNumber; + if (typeof dest === "string") { + namedDest = dest; + explicitDest = await this.pdfDocument.getDestination(dest); + } else { + namedDest = null; + explicitDest = await dest; + } + if (!Array.isArray(explicitDest)) { + console.error(`goToDestination: "${explicitDest}" is not a valid destination array, for dest="${dest}".`); + return; + } + const [destRef] = explicitDest; + if (destRef && typeof destRef === "object") { + pageNumber = this.pdfDocument.cachedPageNumber(destRef); + if (!pageNumber) { + try { + pageNumber = (await this.pdfDocument.getPageIndex(destRef)) + 1; + } catch { + console.error(`goToDestination: "${destRef}" is not a valid page reference, for dest="${dest}".`); + return; + } + } + } else if (Number.isInteger(destRef)) { + pageNumber = destRef + 1; + } + if (!pageNumber || pageNumber < 1 || pageNumber > this.pagesCount) { + console.error(`goToDestination: "${pageNumber}" is not a valid page number, for dest="${dest}".`); + return; + } + if (this.pdfHistory) { + this.pdfHistory.pushCurrentPosition(); + this.pdfHistory.push({ + namedDest, + explicitDest, + pageNumber + }); + } + this.pdfViewer.scrollPageIntoView({ + pageNumber, + destArray: explicitDest, + ignoreDestinationZoom: this._ignoreDestinationZoom + }); + } + goToPage(val) { + if (!this.pdfDocument) { + return; + } + const pageNumber = typeof val === "string" && this.pdfViewer.pageLabelToPageNumber(val) || val | 0; + if (!(Number.isInteger(pageNumber) && pageNumber > 0 && pageNumber <= this.pagesCount)) { + console.error(`PDFLinkService.goToPage: "${val}" is not a valid page.`); + return; + } + if (this.pdfHistory) { + this.pdfHistory.pushCurrentPosition(); + this.pdfHistory.pushPage(pageNumber); + } + this.pdfViewer.scrollPageIntoView({ + pageNumber + }); + } + addLinkAttributes(link, url, newWindow = false) { + if (!url || typeof url !== "string") { + throw new Error('A valid "url" parameter must provided.'); + } + const target = newWindow ? LinkTarget.BLANK : this.externalLinkTarget, + rel = this.externalLinkRel; + if (this.externalLinkEnabled) { + link.href = link.title = url; + } else { + link.href = ""; + link.title = `Disabled: ${url}`; + link.onclick = () => false; + } + let targetStr = ""; + switch (target) { + case LinkTarget.NONE: + break; + case LinkTarget.SELF: + targetStr = "_self"; + break; + case LinkTarget.BLANK: + targetStr = "_blank"; + break; + case LinkTarget.PARENT: + targetStr = "_parent"; + break; + case LinkTarget.TOP: + targetStr = "_top"; + break; + } + link.target = targetStr; + link.rel = typeof rel === "string" ? rel : DEFAULT_LINK_REL; + } + getDestinationHash(dest) { + if (typeof dest === "string") { + if (dest.length > 0) { + return this.getAnchorUrl("#" + escape(dest)); + } + } else if (Array.isArray(dest)) { + const str = JSON.stringify(dest); + if (str.length > 0) { + return this.getAnchorUrl("#" + escape(str)); + } + } + return this.getAnchorUrl(""); + } + getAnchorUrl(anchor) { + return this.baseUrl ? this.baseUrl + anchor : anchor; + } + setHash(hash) { + if (!this.pdfDocument) { + return; + } + let pageNumber, dest; + if (hash.includes("=")) { + const params = parseQueryString(hash); + if (params.has("search")) { + const query = params.get("search").replaceAll('"', ""), + phrase = params.get("phrase") === "true"; + this.eventBus.dispatch("findfromurlhash", { + source: this, + query: phrase ? query : query.match(/\S+/g) + }); + } + if (params.has("page")) { + pageNumber = params.get("page") | 0 || 1; + } + if (params.has("zoom")) { + const zoomArgs = params.get("zoom").split(","); + const zoomArg = zoomArgs[0]; + const zoomArgNumber = parseFloat(zoomArg); + if (!zoomArg.includes("Fit")) { + dest = [null, { + name: "XYZ" + }, zoomArgs.length > 1 ? zoomArgs[1] | 0 : null, zoomArgs.length > 2 ? zoomArgs[2] | 0 : null, zoomArgNumber ? zoomArgNumber / 100 : zoomArg]; + } else if (zoomArg === "Fit" || zoomArg === "FitB") { + dest = [null, { + name: zoomArg + }]; + } else if (zoomArg === "FitH" || zoomArg === "FitBH" || zoomArg === "FitV" || zoomArg === "FitBV") { + dest = [null, { + name: zoomArg + }, zoomArgs.length > 1 ? zoomArgs[1] | 0 : null]; + } else if (zoomArg === "FitR") { + if (zoomArgs.length !== 5) { + console.error('PDFLinkService.setHash: Not enough parameters for "FitR".'); + } else { + dest = [null, { + name: zoomArg + }, zoomArgs[1] | 0, zoomArgs[2] | 0, zoomArgs[3] | 0, zoomArgs[4] | 0]; + } + } else { + console.error(`PDFLinkService.setHash: "${zoomArg}" is not a valid zoom value.`); + } + } + if (dest) { + this.pdfViewer.scrollPageIntoView({ + pageNumber: pageNumber || this.page, + destArray: dest, + allowNegativeOffset: true + }); + } else if (pageNumber) { + this.page = pageNumber; + } + if (params.has("pagemode")) { + this.eventBus.dispatch("pagemode", { + source: this, + mode: params.get("pagemode") + }); + } + if (params.has("nameddest")) { + this.goToDestination(params.get("nameddest")); + } + return; + } + dest = unescape(hash); + try { + dest = JSON.parse(dest); + if (!Array.isArray(dest)) { + dest = dest.toString(); + } + } catch {} + if (typeof dest === "string" || PDFLinkService.#isValidExplicitDest(dest)) { + this.goToDestination(dest); + return; + } + console.error(`PDFLinkService.setHash: "${unescape(hash)}" is not a valid destination.`); + } + executeNamedAction(action) { + if (!this.pdfDocument) { + return; + } + switch (action) { + case "GoBack": + this.pdfHistory?.back(); + break; + case "GoForward": + this.pdfHistory?.forward(); + break; + case "NextPage": + this.pdfViewer.nextPage(); + break; + case "PrevPage": + this.pdfViewer.previousPage(); + break; + case "LastPage": + this.page = this.pagesCount; + break; + case "FirstPage": + this.page = 1; + break; + default: + break; + } + this.eventBus.dispatch("namedaction", { + source: this, + action + }); + } + async executeSetOCGState(action) { + if (!this.pdfDocument) { + return; + } + const pdfDocument = this.pdfDocument, + optionalContentConfig = await this.pdfViewer.optionalContentConfigPromise; + if (pdfDocument !== this.pdfDocument) { + return; + } + optionalContentConfig.setOCGState(action); + this.pdfViewer.optionalContentConfigPromise = Promise.resolve(optionalContentConfig); + } + static #isValidExplicitDest(dest) { + if (!Array.isArray(dest) || dest.length < 2) { + return false; + } + const [page, zoom, ...args] = dest; + if (!(typeof page === "object" && Number.isInteger(page?.num) && Number.isInteger(page?.gen)) && !Number.isInteger(page)) { + return false; + } + if (!(typeof zoom === "object" && typeof zoom?.name === "string")) { + return false; + } + const argsLen = args.length; + let allowNull = true; + switch (zoom.name) { + case "XYZ": + if (argsLen < 2 || argsLen > 3) { + return false; + } + break; + case "Fit": + case "FitB": + return argsLen === 0; + case "FitH": + case "FitBH": + case "FitV": + case "FitBV": + if (argsLen > 1) { + return false; + } + break; + case "FitR": + if (argsLen !== 4) { + return false; + } + allowNull = false; + break; + default: + return false; + } + for (const arg of args) { + if (!(typeof arg === "number" || allowNull && arg === null)) { + return false; + } + } + return true; + } +} +class SimpleLinkService extends PDFLinkService { + setDocument(pdfDocument, baseUrl = null) {} +} + +;// ./web/pdfjs.js +const { + AbortException, + AnnotationEditorLayer, + AnnotationEditorParamsType, + AnnotationEditorType, + AnnotationEditorUIManager, + AnnotationLayer, + AnnotationMode, + build, + ColorPicker, + createValidAbsoluteUrl, + DOMSVGFactory, + DrawLayer, + FeatureTest, + fetchData, + getDocument, + getFilenameFromUrl, + getPdfFilenameFromUrl, + getXfaPageViewport, + GlobalWorkerOptions, + ImageKind, + InvalidPDFException, + isDataScheme, + isPdfFile, + MissingPDFException, + noContextMenu, + normalizeUnicode, + OPS, + OutputScale, + PasswordResponses, + PDFDataRangeTransport, + PDFDateString, + PDFWorker, + PermissionFlag, + PixelsPerInch, + RenderingCancelledException, + setLayerDimensions, + shadow, + stopEvent, + TextLayer, + TouchManager, + UnexpectedResponseException, + Util, + VerbosityLevel, + version, + XfaLayer +} = globalThis.pdfjsLib; + +;// ./web/annotation_layer_builder.js + + +class AnnotationLayerBuilder { + #onAppend = null; + #eventAbortController = null; + constructor({ + pdfPage, + linkService, + downloadManager, + annotationStorage = null, + imageResourcesPath = "", + renderForms = true, + enableScripting = false, + hasJSActionsPromise = null, + fieldObjectsPromise = null, + annotationCanvasMap = null, + accessibilityManager = null, + annotationEditorUIManager = null, + onAppend = null + }) { + this.pdfPage = pdfPage; + this.linkService = linkService; + this.downloadManager = downloadManager; + this.imageResourcesPath = imageResourcesPath; + this.renderForms = renderForms; + this.annotationStorage = annotationStorage; + this.enableScripting = enableScripting; + this._hasJSActionsPromise = hasJSActionsPromise || Promise.resolve(false); + this._fieldObjectsPromise = fieldObjectsPromise || Promise.resolve(null); + this._annotationCanvasMap = annotationCanvasMap; + this._accessibilityManager = accessibilityManager; + this._annotationEditorUIManager = annotationEditorUIManager; + this.#onAppend = onAppend; + this.annotationLayer = null; + this.div = null; + this._cancelled = false; + this._eventBus = linkService.eventBus; + } + async render(viewport, options, intent = "display") { + if (this.div) { + if (this._cancelled || !this.annotationLayer) { + return; + } + this.annotationLayer.update({ + viewport: viewport.clone({ + dontFlip: true + }) + }); + return; + } + const [annotations, hasJSActions, fieldObjects] = await Promise.all([this.pdfPage.getAnnotations({ + intent + }), this._hasJSActionsPromise, this._fieldObjectsPromise]); + if (this._cancelled) { + return; + } + const div = this.div = document.createElement("div"); + div.className = "annotationLayer"; + this.#onAppend?.(div); + if (annotations.length === 0) { + this.hide(); + return; + } + this.annotationLayer = new AnnotationLayer({ + div, + accessibilityManager: this._accessibilityManager, + annotationCanvasMap: this._annotationCanvasMap, + annotationEditorUIManager: this._annotationEditorUIManager, + page: this.pdfPage, + viewport: viewport.clone({ + dontFlip: true + }), + structTreeLayer: options?.structTreeLayer || null + }); + await this.annotationLayer.render({ + annotations, + imageResourcesPath: this.imageResourcesPath, + renderForms: this.renderForms, + linkService: this.linkService, + downloadManager: this.downloadManager, + annotationStorage: this.annotationStorage, + enableScripting: this.enableScripting, + hasJSActions, + fieldObjects + }); + if (this.linkService.isInPresentationMode) { + this.#updatePresentationModeState(PresentationModeState.FULLSCREEN); + } + if (!this.#eventAbortController) { + this.#eventAbortController = new AbortController(); + this._eventBus?._on("presentationmodechanged", evt => { + this.#updatePresentationModeState(evt.state); + }, { + signal: this.#eventAbortController.signal + }); + } + } + cancel() { + this._cancelled = true; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + } + hide() { + if (!this.div) { + return; + } + this.div.hidden = true; + } + hasEditableAnnotations() { + return !!this.annotationLayer?.hasEditableAnnotations(); + } + #updatePresentationModeState(state) { + if (!this.div) { + return; + } + let disableFormElements = false; + switch (state) { + case PresentationModeState.FULLSCREEN: + disableFormElements = true; + break; + case PresentationModeState.NORMAL: + break; + default: + return; + } + for (const section of this.div.childNodes) { + if (section.hasAttribute("data-internal-link")) { + continue; + } + section.inert = disableFormElements; + } + } +} + +;// ./web/download_manager.js + +function download(blobUrl, filename) { + const a = document.createElement("a"); + if (!a.click) { + throw new Error('DownloadManager: "a.click()" is not supported.'); + } + a.href = blobUrl; + a.target = "_parent"; + if ("download" in a) { + a.download = filename; + } + (document.body || document.documentElement).append(a); + a.click(); + a.remove(); +} +class DownloadManager { + #openBlobUrls = new WeakMap(); + downloadData(data, filename, contentType) { + const blobUrl = URL.createObjectURL(new Blob([data], { + type: contentType + })); + download(blobUrl, filename); + } + openOrDownloadData(data, filename, dest = null) { + const isPdfData = isPdfFile(filename); + const contentType = isPdfData ? "application/pdf" : ""; + this.downloadData(data, filename, contentType); + return false; + } + download(data, url, filename) { + let blobUrl; + if (data) { + blobUrl = URL.createObjectURL(new Blob([data], { + type: "application/pdf" + })); + } else { + if (!createValidAbsoluteUrl(url, "http://example.com")) { + console.error(`download - not a valid URL: ${url}`); + return; + } + blobUrl = url + "#pdfjs.action=download"; + } + download(blobUrl, filename); + } +} + +;// ./web/event_utils.js +const WaitOnType = { + EVENT: "event", + TIMEOUT: "timeout" +}; +async function waitOnEventOrTimeout({ + target, + name, + delay = 0 +}) { + if (typeof target !== "object" || !(name && typeof name === "string") || !(Number.isInteger(delay) && delay >= 0)) { + throw new Error("waitOnEventOrTimeout - invalid parameters."); + } + const { + promise, + resolve + } = Promise.withResolvers(); + const ac = new AbortController(); + function handler(type) { + ac.abort(); + clearTimeout(timeout); + resolve(type); + } + const evtMethod = target instanceof EventBus ? "_on" : "addEventListener"; + target[evtMethod](name, handler.bind(null, WaitOnType.EVENT), { + signal: ac.signal + }); + const timeout = setTimeout(handler.bind(null, WaitOnType.TIMEOUT), delay); + return promise; +} +class EventBus { + #listeners = Object.create(null); + on(eventName, listener, options = null) { + this._on(eventName, listener, { + external: true, + once: options?.once, + signal: options?.signal + }); + } + off(eventName, listener, options = null) { + this._off(eventName, listener); + } + dispatch(eventName, data) { + const eventListeners = this.#listeners[eventName]; + if (!eventListeners || eventListeners.length === 0) { + return; + } + let externalListeners; + for (const { + listener, + external, + once + } of eventListeners.slice(0)) { + if (once) { + this._off(eventName, listener); + } + if (external) { + (externalListeners ||= []).push(listener); + continue; + } + listener(data); + } + if (externalListeners) { + for (const listener of externalListeners) { + listener(data); + } + externalListeners = null; + } + } + _on(eventName, listener, options = null) { + let rmAbort = null; + if (options?.signal instanceof AbortSignal) { + const { + signal + } = options; + if (signal.aborted) { + console.error("Cannot use an `aborted` signal."); + return; + } + const onAbort = () => this._off(eventName, listener); + rmAbort = () => signal.removeEventListener("abort", onAbort); + signal.addEventListener("abort", onAbort); + } + const eventListeners = this.#listeners[eventName] ||= []; + eventListeners.push({ + listener, + external: options?.external === true, + once: options?.once === true, + rmAbort + }); + } + _off(eventName, listener, options = null) { + const eventListeners = this.#listeners[eventName]; + if (!eventListeners) { + return; + } + for (let i = 0, ii = eventListeners.length; i < ii; i++) { + const evt = eventListeners[i]; + if (evt.listener === listener) { + evt.rmAbort?.(); + eventListeners.splice(i, 1); + return; + } + } + } +} +class FirefoxEventBus extends EventBus { + #externalServices; + #globalEventNames; + #isInAutomation; + constructor(globalEventNames, externalServices, isInAutomation) { + super(); + this.#globalEventNames = globalEventNames; + this.#externalServices = externalServices; + this.#isInAutomation = isInAutomation; + } + dispatch(eventName, data) { + throw new Error("Not implemented: FirefoxEventBus.dispatch"); + } +} + +;// ./node_modules/@fluent/bundle/esm/types.js +class FluentType { + constructor(value) { + this.value = value; + } + valueOf() { + return this.value; + } +} +class FluentNone extends FluentType { + constructor(value = "???") { + super(value); + } + toString(scope) { + return `{${this.value}}`; + } +} +class FluentNumber extends FluentType { + constructor(value, opts = {}) { + super(value); + this.opts = opts; + } + toString(scope) { + try { + const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts); + return nf.format(this.value); + } catch (err) { + scope.reportError(err); + return this.value.toString(10); + } + } +} +class FluentDateTime extends FluentType { + constructor(value, opts = {}) { + super(value); + this.opts = opts; + } + toString(scope) { + try { + const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts); + return dtf.format(this.value); + } catch (err) { + scope.reportError(err); + return new Date(this.value).toISOString(); + } + } +} +;// ./node_modules/@fluent/bundle/esm/resolver.js + +const MAX_PLACEABLES = 100; +const FSI = "\u2068"; +const PDI = "\u2069"; +function match(scope, selector, key) { + if (key === selector) { + return true; + } + if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) { + return true; + } + if (selector instanceof FluentNumber && typeof key === "string") { + let category = scope.memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value); + if (key === category) { + return true; + } + } + return false; +} +function getDefault(scope, variants, star) { + if (variants[star]) { + return resolvePattern(scope, variants[star].value); + } + scope.reportError(new RangeError("No default")); + return new FluentNone(); +} +function getArguments(scope, args) { + const positional = []; + const named = Object.create(null); + for (const arg of args) { + if (arg.type === "narg") { + named[arg.name] = resolveExpression(scope, arg.value); + } else { + positional.push(resolveExpression(scope, arg)); + } + } + return { + positional, + named + }; +} +function resolveExpression(scope, expr) { + switch (expr.type) { + case "str": + return expr.value; + case "num": + return new FluentNumber(expr.value, { + minimumFractionDigits: expr.precision + }); + case "var": + return resolveVariableReference(scope, expr); + case "mesg": + return resolveMessageReference(scope, expr); + case "term": + return resolveTermReference(scope, expr); + case "func": + return resolveFunctionReference(scope, expr); + case "select": + return resolveSelectExpression(scope, expr); + default: + return new FluentNone(); + } +} +function resolveVariableReference(scope, { + name +}) { + let arg; + if (scope.params) { + if (Object.prototype.hasOwnProperty.call(scope.params, name)) { + arg = scope.params[name]; + } else { + return new FluentNone(`$${name}`); + } + } else if (scope.args && Object.prototype.hasOwnProperty.call(scope.args, name)) { + arg = scope.args[name]; + } else { + scope.reportError(new ReferenceError(`Unknown variable: $${name}`)); + return new FluentNone(`$${name}`); + } + if (arg instanceof FluentType) { + return arg; + } + switch (typeof arg) { + case "string": + return arg; + case "number": + return new FluentNumber(arg); + case "object": + if (arg instanceof Date) { + return new FluentDateTime(arg.getTime()); + } + default: + scope.reportError(new TypeError(`Variable type not supported: $${name}, ${typeof arg}`)); + return new FluentNone(`$${name}`); + } +} +function resolveMessageReference(scope, { + name, + attr +}) { + const message = scope.bundle._messages.get(name); + if (!message) { + scope.reportError(new ReferenceError(`Unknown message: ${name}`)); + return new FluentNone(name); + } + if (attr) { + const attribute = message.attributes[attr]; + if (attribute) { + return resolvePattern(scope, attribute); + } + scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); + return new FluentNone(`${name}.${attr}`); + } + if (message.value) { + return resolvePattern(scope, message.value); + } + scope.reportError(new ReferenceError(`No value: ${name}`)); + return new FluentNone(name); +} +function resolveTermReference(scope, { + name, + attr, + args +}) { + const id = `-${name}`; + const term = scope.bundle._terms.get(id); + if (!term) { + scope.reportError(new ReferenceError(`Unknown term: ${id}`)); + return new FluentNone(id); + } + if (attr) { + const attribute = term.attributes[attr]; + if (attribute) { + scope.params = getArguments(scope, args).named; + const resolved = resolvePattern(scope, attribute); + scope.params = null; + return resolved; + } + scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); + return new FluentNone(`${id}.${attr}`); + } + scope.params = getArguments(scope, args).named; + const resolved = resolvePattern(scope, term.value); + scope.params = null; + return resolved; +} +function resolveFunctionReference(scope, { + name, + args +}) { + let func = scope.bundle._functions[name]; + if (!func) { + scope.reportError(new ReferenceError(`Unknown function: ${name}()`)); + return new FluentNone(`${name}()`); + } + if (typeof func !== "function") { + scope.reportError(new TypeError(`Function ${name}() is not callable`)); + return new FluentNone(`${name}()`); + } + try { + let resolved = getArguments(scope, args); + return func(resolved.positional, resolved.named); + } catch (err) { + scope.reportError(err); + return new FluentNone(`${name}()`); + } +} +function resolveSelectExpression(scope, { + selector, + variants, + star +}) { + let sel = resolveExpression(scope, selector); + if (sel instanceof FluentNone) { + return getDefault(scope, variants, star); + } + for (const variant of variants) { + const key = resolveExpression(scope, variant.key); + if (match(scope, sel, key)) { + return resolvePattern(scope, variant.value); + } + } + return getDefault(scope, variants, star); +} +function resolveComplexPattern(scope, ptn) { + if (scope.dirty.has(ptn)) { + scope.reportError(new RangeError("Cyclic reference")); + return new FluentNone(); + } + scope.dirty.add(ptn); + const result = []; + const useIsolating = scope.bundle._useIsolating && ptn.length > 1; + for (const elem of ptn) { + if (typeof elem === "string") { + result.push(scope.bundle._transform(elem)); + continue; + } + scope.placeables++; + if (scope.placeables > MAX_PLACEABLES) { + scope.dirty.delete(ptn); + throw new RangeError(`Too many placeables expanded: ${scope.placeables}, ` + `max allowed is ${MAX_PLACEABLES}`); + } + if (useIsolating) { + result.push(FSI); + } + result.push(resolveExpression(scope, elem).toString(scope)); + if (useIsolating) { + result.push(PDI); + } + } + scope.dirty.delete(ptn); + return result.join(""); +} +function resolvePattern(scope, value) { + if (typeof value === "string") { + return scope.bundle._transform(value); + } + return resolveComplexPattern(scope, value); +} +;// ./node_modules/@fluent/bundle/esm/scope.js +class Scope { + constructor(bundle, errors, args) { + this.dirty = new WeakSet(); + this.params = null; + this.placeables = 0; + this.bundle = bundle; + this.errors = errors; + this.args = args; + } + reportError(error) { + if (!this.errors || !(error instanceof Error)) { + throw error; + } + this.errors.push(error); + } + memoizeIntlObject(ctor, opts) { + let cache = this.bundle._intls.get(ctor); + if (!cache) { + cache = {}; + this.bundle._intls.set(ctor, cache); + } + let id = JSON.stringify(opts); + if (!cache[id]) { + cache[id] = new ctor(this.bundle.locales, opts); + } + return cache[id]; + } +} +;// ./node_modules/@fluent/bundle/esm/builtins.js + +function values(opts, allowed) { + const unwrapped = Object.create(null); + for (const [name, opt] of Object.entries(opts)) { + if (allowed.includes(name)) { + unwrapped[name] = opt.valueOf(); + } + } + return unwrapped; +} +const NUMBER_ALLOWED = ["unitDisplay", "currencyDisplay", "useGrouping", "minimumIntegerDigits", "minimumFractionDigits", "maximumFractionDigits", "minimumSignificantDigits", "maximumSignificantDigits"]; +function NUMBER(args, opts) { + let arg = args[0]; + if (arg instanceof FluentNone) { + return new FluentNone(`NUMBER(${arg.valueOf()})`); + } + if (arg instanceof FluentNumber) { + return new FluentNumber(arg.valueOf(), { + ...arg.opts, + ...values(opts, NUMBER_ALLOWED) + }); + } + if (arg instanceof FluentDateTime) { + return new FluentNumber(arg.valueOf(), { + ...values(opts, NUMBER_ALLOWED) + }); + } + throw new TypeError("Invalid argument to NUMBER"); +} +const DATETIME_ALLOWED = ["dateStyle", "timeStyle", "fractionalSecondDigits", "dayPeriod", "hour12", "weekday", "era", "year", "month", "day", "hour", "minute", "second", "timeZoneName"]; +function DATETIME(args, opts) { + let arg = args[0]; + if (arg instanceof FluentNone) { + return new FluentNone(`DATETIME(${arg.valueOf()})`); + } + if (arg instanceof FluentDateTime) { + return new FluentDateTime(arg.valueOf(), { + ...arg.opts, + ...values(opts, DATETIME_ALLOWED) + }); + } + if (arg instanceof FluentNumber) { + return new FluentDateTime(arg.valueOf(), { + ...values(opts, DATETIME_ALLOWED) + }); + } + throw new TypeError("Invalid argument to DATETIME"); +} +;// ./node_modules/@fluent/bundle/esm/memoizer.js +const cache = new Map(); +function getMemoizerForLocale(locales) { + const stringLocale = Array.isArray(locales) ? locales.join(" ") : locales; + let memoizer = cache.get(stringLocale); + if (memoizer === undefined) { + memoizer = new Map(); + cache.set(stringLocale, memoizer); + } + return memoizer; +} +;// ./node_modules/@fluent/bundle/esm/bundle.js + + + + + +class FluentBundle { + constructor(locales, { + functions, + useIsolating = true, + transform = v => v + } = {}) { + this._terms = new Map(); + this._messages = new Map(); + this.locales = Array.isArray(locales) ? locales : [locales]; + this._functions = { + NUMBER: NUMBER, + DATETIME: DATETIME, + ...functions + }; + this._useIsolating = useIsolating; + this._transform = transform; + this._intls = getMemoizerForLocale(locales); + } + hasMessage(id) { + return this._messages.has(id); + } + getMessage(id) { + return this._messages.get(id); + } + addResource(res, { + allowOverrides = false + } = {}) { + const errors = []; + for (let i = 0; i < res.body.length; i++) { + let entry = res.body[i]; + if (entry.id.startsWith("-")) { + if (allowOverrides === false && this._terms.has(entry.id)) { + errors.push(new Error(`Attempt to override an existing term: "${entry.id}"`)); + continue; + } + this._terms.set(entry.id, entry); + } else { + if (allowOverrides === false && this._messages.has(entry.id)) { + errors.push(new Error(`Attempt to override an existing message: "${entry.id}"`)); + continue; + } + this._messages.set(entry.id, entry); + } + } + return errors; + } + formatPattern(pattern, args = null, errors = null) { + if (typeof pattern === "string") { + return this._transform(pattern); + } + let scope = new Scope(this, errors, args); + try { + let value = resolveComplexPattern(scope, pattern); + return value.toString(scope); + } catch (err) { + if (scope.errors && err instanceof Error) { + scope.errors.push(err); + return new FluentNone().toString(scope); + } + throw err; + } + } +} +;// ./node_modules/@fluent/bundle/esm/resource.js +const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */gm; +const RE_ATTRIBUTE_START = /\.([a-zA-Z][\w-]*) *= */y; +const RE_VARIANT_START = /\*?\[/y; +const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y; +const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y; +const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y; +const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/; +const RE_TEXT_RUN = /([^{}\n\r]+)/y; +const RE_STRING_RUN = /([^\\"\n\r]*)/y; +const RE_STRING_ESCAPE = /\\([\\"])/y; +const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y; +const RE_LEADING_NEWLINES = /^\n+/; +const RE_TRAILING_SPACES = / +$/; +const RE_BLANK_LINES = / *\r?\n/g; +const RE_INDENT = /( *)$/; +const TOKEN_BRACE_OPEN = /{\s*/y; +const TOKEN_BRACE_CLOSE = /\s*}/y; +const TOKEN_BRACKET_OPEN = /\[\s*/y; +const TOKEN_BRACKET_CLOSE = /\s*] */y; +const TOKEN_PAREN_OPEN = /\s*\(\s*/y; +const TOKEN_ARROW = /\s*->\s*/y; +const TOKEN_COLON = /\s*:\s*/y; +const TOKEN_COMMA = /\s*,?\s*/y; +const TOKEN_BLANK = /\s+/y; +class FluentResource { + constructor(source) { + this.body = []; + RE_MESSAGE_START.lastIndex = 0; + let cursor = 0; + while (true) { + let next = RE_MESSAGE_START.exec(source); + if (next === null) { + break; + } + cursor = RE_MESSAGE_START.lastIndex; + try { + this.body.push(parseMessage(next[1])); + } catch (err) { + if (err instanceof SyntaxError) { + continue; + } + throw err; + } + } + function test(re) { + re.lastIndex = cursor; + return re.test(source); + } + function consumeChar(char, errorClass) { + if (source[cursor] === char) { + cursor++; + return true; + } + if (errorClass) { + throw new errorClass(`Expected ${char}`); + } + return false; + } + function consumeToken(re, errorClass) { + if (test(re)) { + cursor = re.lastIndex; + return true; + } + if (errorClass) { + throw new errorClass(`Expected ${re.toString()}`); + } + return false; + } + function match(re) { + re.lastIndex = cursor; + let result = re.exec(source); + if (result === null) { + throw new SyntaxError(`Expected ${re.toString()}`); + } + cursor = re.lastIndex; + return result; + } + function match1(re) { + return match(re)[1]; + } + function parseMessage(id) { + let value = parsePattern(); + let attributes = parseAttributes(); + if (value === null && Object.keys(attributes).length === 0) { + throw new SyntaxError("Expected message value or attributes"); + } + return { + id, + value, + attributes + }; + } + function parseAttributes() { + let attrs = Object.create(null); + while (test(RE_ATTRIBUTE_START)) { + let name = match1(RE_ATTRIBUTE_START); + let value = parsePattern(); + if (value === null) { + throw new SyntaxError("Expected attribute value"); + } + attrs[name] = value; + } + return attrs; + } + function parsePattern() { + let first; + if (test(RE_TEXT_RUN)) { + first = match1(RE_TEXT_RUN); + } + if (source[cursor] === "{" || source[cursor] === "}") { + return parsePatternElements(first ? [first] : [], Infinity); + } + let indent = parseIndent(); + if (indent) { + if (first) { + return parsePatternElements([first, indent], indent.length); + } + indent.value = trim(indent.value, RE_LEADING_NEWLINES); + return parsePatternElements([indent], indent.length); + } + if (first) { + return trim(first, RE_TRAILING_SPACES); + } + return null; + } + function parsePatternElements(elements = [], commonIndent) { + while (true) { + if (test(RE_TEXT_RUN)) { + elements.push(match1(RE_TEXT_RUN)); + continue; + } + if (source[cursor] === "{") { + elements.push(parsePlaceable()); + continue; + } + if (source[cursor] === "}") { + throw new SyntaxError("Unbalanced closing brace"); + } + let indent = parseIndent(); + if (indent) { + elements.push(indent); + commonIndent = Math.min(commonIndent, indent.length); + continue; + } + break; + } + let lastIndex = elements.length - 1; + let lastElement = elements[lastIndex]; + if (typeof lastElement === "string") { + elements[lastIndex] = trim(lastElement, RE_TRAILING_SPACES); + } + let baked = []; + for (let element of elements) { + if (element instanceof Indent) { + element = element.value.slice(0, element.value.length - commonIndent); + } + if (element) { + baked.push(element); + } + } + return baked; + } + function parsePlaceable() { + consumeToken(TOKEN_BRACE_OPEN, SyntaxError); + let selector = parseInlineExpression(); + if (consumeToken(TOKEN_BRACE_CLOSE)) { + return selector; + } + if (consumeToken(TOKEN_ARROW)) { + let variants = parseVariants(); + consumeToken(TOKEN_BRACE_CLOSE, SyntaxError); + return { + type: "select", + selector, + ...variants + }; + } + throw new SyntaxError("Unclosed placeable"); + } + function parseInlineExpression() { + if (source[cursor] === "{") { + return parsePlaceable(); + } + if (test(RE_REFERENCE)) { + let [, sigil, name, attr = null] = match(RE_REFERENCE); + if (sigil === "$") { + return { + type: "var", + name + }; + } + if (consumeToken(TOKEN_PAREN_OPEN)) { + let args = parseArguments(); + if (sigil === "-") { + return { + type: "term", + name, + attr, + args + }; + } + if (RE_FUNCTION_NAME.test(name)) { + return { + type: "func", + name, + args + }; + } + throw new SyntaxError("Function names must be all upper-case"); + } + if (sigil === "-") { + return { + type: "term", + name, + attr, + args: [] + }; + } + return { + type: "mesg", + name, + attr + }; + } + return parseLiteral(); + } + function parseArguments() { + let args = []; + while (true) { + switch (source[cursor]) { + case ")": + cursor++; + return args; + case undefined: + throw new SyntaxError("Unclosed argument list"); + } + args.push(parseArgument()); + consumeToken(TOKEN_COMMA); + } + } + function parseArgument() { + let expr = parseInlineExpression(); + if (expr.type !== "mesg") { + return expr; + } + if (consumeToken(TOKEN_COLON)) { + return { + type: "narg", + name: expr.name, + value: parseLiteral() + }; + } + return expr; + } + function parseVariants() { + let variants = []; + let count = 0; + let star; + while (test(RE_VARIANT_START)) { + if (consumeChar("*")) { + star = count; + } + let key = parseVariantKey(); + let value = parsePattern(); + if (value === null) { + throw new SyntaxError("Expected variant value"); + } + variants[count++] = { + key, + value + }; + } + if (count === 0) { + return null; + } + if (star === undefined) { + throw new SyntaxError("Expected default variant"); + } + return { + variants, + star + }; + } + function parseVariantKey() { + consumeToken(TOKEN_BRACKET_OPEN, SyntaxError); + let key; + if (test(RE_NUMBER_LITERAL)) { + key = parseNumberLiteral(); + } else { + key = { + type: "str", + value: match1(RE_IDENTIFIER) + }; + } + consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError); + return key; + } + function parseLiteral() { + if (test(RE_NUMBER_LITERAL)) { + return parseNumberLiteral(); + } + if (source[cursor] === '"') { + return parseStringLiteral(); + } + throw new SyntaxError("Invalid expression"); + } + function parseNumberLiteral() { + let [, value, fraction = ""] = match(RE_NUMBER_LITERAL); + let precision = fraction.length; + return { + type: "num", + value: parseFloat(value), + precision + }; + } + function parseStringLiteral() { + consumeChar('"', SyntaxError); + let value = ""; + while (true) { + value += match1(RE_STRING_RUN); + if (source[cursor] === "\\") { + value += parseEscapeSequence(); + continue; + } + if (consumeChar('"')) { + return { + type: "str", + value + }; + } + throw new SyntaxError("Unclosed string literal"); + } + } + function parseEscapeSequence() { + if (test(RE_STRING_ESCAPE)) { + return match1(RE_STRING_ESCAPE); + } + if (test(RE_UNICODE_ESCAPE)) { + let [, codepoint4, codepoint6] = match(RE_UNICODE_ESCAPE); + let codepoint = parseInt(codepoint4 || codepoint6, 16); + return codepoint <= 0xd7ff || 0xe000 <= codepoint ? String.fromCodePoint(codepoint) : "�"; + } + throw new SyntaxError("Unknown escape sequence"); + } + function parseIndent() { + let start = cursor; + consumeToken(TOKEN_BLANK); + switch (source[cursor]) { + case ".": + case "[": + case "*": + case "}": + case undefined: + return false; + case "{": + return makeIndent(source.slice(start, cursor)); + } + if (source[cursor - 1] === " ") { + return makeIndent(source.slice(start, cursor)); + } + return false; + } + function trim(text, re) { + return text.replace(re, ""); + } + function makeIndent(blank) { + let value = blank.replace(RE_BLANK_LINES, "\n"); + let length = RE_INDENT.exec(blank)[1].length; + return new Indent(value, length); + } + } +} +class Indent { + constructor(value, length) { + this.value = value; + this.length = length; + } +} +;// ./node_modules/@fluent/bundle/esm/index.js + + + +;// ./node_modules/@fluent/dom/esm/overlay.js +const reOverlay = /<|&#?\w+;/; +const TEXT_LEVEL_ELEMENTS = { + "http://www.w3.org/1999/xhtml": ["em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data", "time", "code", "var", "samp", "kbd", "sub", "sup", "i", "b", "u", "mark", "bdi", "bdo", "span", "br", "wbr"] +}; +const LOCALIZABLE_ATTRIBUTES = { + "http://www.w3.org/1999/xhtml": { + global: ["title", "aria-label", "aria-valuetext"], + a: ["download"], + area: ["download", "alt"], + input: ["alt", "placeholder"], + menuitem: ["label"], + menu: ["label"], + optgroup: ["label"], + option: ["label"], + track: ["label"], + img: ["alt"], + textarea: ["placeholder"], + th: ["abbr"] + }, + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": { + global: ["accesskey", "aria-label", "aria-valuetext", "label", "title", "tooltiptext"], + description: ["value"], + key: ["key", "keycode"], + label: ["value"], + textbox: ["placeholder", "value"] + } +}; +function translateElement(element, translation) { + const { + value + } = translation; + if (typeof value === "string") { + if (element.localName === "title" && element.namespaceURI === "http://www.w3.org/1999/xhtml") { + element.textContent = value; + } else if (!reOverlay.test(value)) { + element.textContent = value; + } else { + const templateElement = element.ownerDocument.createElementNS("http://www.w3.org/1999/xhtml", "template"); + templateElement.innerHTML = value; + overlayChildNodes(templateElement.content, element); + } + } + overlayAttributes(translation, element); +} +function overlayChildNodes(fromFragment, toElement) { + for (const childNode of fromFragment.childNodes) { + if (childNode.nodeType === childNode.TEXT_NODE) { + continue; + } + if (childNode.hasAttribute("data-l10n-name")) { + const sanitized = getNodeForNamedElement(toElement, childNode); + fromFragment.replaceChild(sanitized, childNode); + continue; + } + if (isElementAllowed(childNode)) { + const sanitized = createSanitizedElement(childNode); + fromFragment.replaceChild(sanitized, childNode); + continue; + } + console.warn(`An element of forbidden type "${childNode.localName}" was found in ` + "the translation. Only safe text-level elements and elements with " + "data-l10n-name are allowed."); + fromFragment.replaceChild(createTextNodeFromTextContent(childNode), childNode); + } + toElement.textContent = ""; + toElement.appendChild(fromFragment); +} +function hasAttribute(attributes, name) { + if (!attributes) { + return false; + } + for (let attr of attributes) { + if (attr.name === name) { + return true; + } + } + return false; +} +function overlayAttributes(fromElement, toElement) { + const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs") ? toElement.getAttribute("data-l10n-attrs").split(",").map(i => i.trim()) : null; + for (const attr of Array.from(toElement.attributes)) { + if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed) && !hasAttribute(fromElement.attributes, attr.name)) { + toElement.removeAttribute(attr.name); + } + } + if (!fromElement.attributes) { + return; + } + for (const attr of Array.from(fromElement.attributes)) { + if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed) && toElement.getAttribute(attr.name) !== attr.value) { + toElement.setAttribute(attr.name, attr.value); + } + } +} +function getNodeForNamedElement(sourceElement, translatedChild) { + const childName = translatedChild.getAttribute("data-l10n-name"); + const sourceChild = sourceElement.querySelector(`[data-l10n-name="${childName}"]`); + if (!sourceChild) { + console.warn(`An element named "${childName}" wasn't found in the source.`); + return createTextNodeFromTextContent(translatedChild); + } + if (sourceChild.localName !== translatedChild.localName) { + console.warn(`An element named "${childName}" was found in the translation ` + `but its type ${translatedChild.localName} didn't match the ` + `element found in the source (${sourceChild.localName}).`); + return createTextNodeFromTextContent(translatedChild); + } + sourceElement.removeChild(sourceChild); + const clone = sourceChild.cloneNode(false); + return shallowPopulateUsing(translatedChild, clone); +} +function createSanitizedElement(element) { + const clone = element.ownerDocument.createElement(element.localName); + return shallowPopulateUsing(element, clone); +} +function createTextNodeFromTextContent(element) { + return element.ownerDocument.createTextNode(element.textContent); +} +function isElementAllowed(element) { + const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI]; + return allowed && allowed.includes(element.localName); +} +function isAttrNameLocalizable(name, element, explicitlyAllowed = null) { + if (explicitlyAllowed && explicitlyAllowed.includes(name)) { + return true; + } + const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI]; + if (!allowed) { + return false; + } + const attrName = name.toLowerCase(); + const elemName = element.localName; + if (allowed.global.includes(attrName)) { + return true; + } + if (!allowed[elemName]) { + return false; + } + if (allowed[elemName].includes(attrName)) { + return true; + } + if (element.namespaceURI === "http://www.w3.org/1999/xhtml" && elemName === "input" && attrName === "value") { + const type = element.type.toLowerCase(); + if (type === "submit" || type === "button" || type === "reset") { + return true; + } + } + return false; +} +function shallowPopulateUsing(fromElement, toElement) { + toElement.textContent = fromElement.textContent; + overlayAttributes(fromElement, toElement); + return toElement; +} +;// ./node_modules/cached-iterable/src/cached_iterable.mjs +class CachedIterable extends Array { + static from(iterable) { + if (iterable instanceof this) { + return iterable; + } + return new this(iterable); + } +} +;// ./node_modules/cached-iterable/src/cached_sync_iterable.mjs + +class CachedSyncIterable extends CachedIterable { + constructor(iterable) { + super(); + if (Symbol.iterator in Object(iterable)) { + this.iterator = iterable[Symbol.iterator](); + } else { + throw new TypeError("Argument must implement the iteration protocol."); + } + } + [Symbol.iterator]() { + const cached = this; + let cur = 0; + return { + next() { + if (cached.length <= cur) { + cached.push(cached.iterator.next()); + } + return cached[cur++]; + } + }; + } + touchNext(count = 1) { + let idx = 0; + while (idx++ < count) { + const last = this[this.length - 1]; + if (last && last.done) { + break; + } + this.push(this.iterator.next()); + } + return this[this.length - 1]; + } +} +;// ./node_modules/cached-iterable/src/cached_async_iterable.mjs + +class CachedAsyncIterable extends CachedIterable { + constructor(iterable) { + super(); + if (Symbol.asyncIterator in Object(iterable)) { + this.iterator = iterable[Symbol.asyncIterator](); + } else if (Symbol.iterator in Object(iterable)) { + this.iterator = iterable[Symbol.iterator](); + } else { + throw new TypeError("Argument must implement the iteration protocol."); + } + } + [Symbol.asyncIterator]() { + const cached = this; + let cur = 0; + return { + async next() { + if (cached.length <= cur) { + cached.push(cached.iterator.next()); + } + return cached[cur++]; + } + }; + } + async touchNext(count = 1) { + let idx = 0; + while (idx++ < count) { + const last = this[this.length - 1]; + if (last && (await last).done) { + break; + } + this.push(this.iterator.next()); + } + return this[this.length - 1]; + } +} +;// ./node_modules/cached-iterable/src/index.mjs + + +;// ./node_modules/@fluent/dom/esm/localization.js + +class Localization { + constructor(resourceIds = [], generateBundles) { + this.resourceIds = resourceIds; + this.generateBundles = generateBundles; + this.onChange(true); + } + addResourceIds(resourceIds, eager = false) { + this.resourceIds.push(...resourceIds); + this.onChange(eager); + return this.resourceIds.length; + } + removeResourceIds(resourceIds) { + this.resourceIds = this.resourceIds.filter(r => !resourceIds.includes(r)); + this.onChange(); + return this.resourceIds.length; + } + async formatWithFallback(keys, method) { + const translations = []; + let hasAtLeastOneBundle = false; + for await (const bundle of this.bundles) { + hasAtLeastOneBundle = true; + const missingIds = keysFromBundle(method, bundle, keys, translations); + if (missingIds.size === 0) { + break; + } + if (typeof console !== "undefined") { + const locale = bundle.locales[0]; + const ids = Array.from(missingIds).join(", "); + console.warn(`[fluent] Missing translations in ${locale}: ${ids}`); + } + } + if (!hasAtLeastOneBundle && typeof console !== "undefined") { + console.warn(`[fluent] Request for keys failed because no resource bundles got generated. + keys: ${JSON.stringify(keys)}. + resourceIds: ${JSON.stringify(this.resourceIds)}.`); + } + return translations; + } + formatMessages(keys) { + return this.formatWithFallback(keys, messageFromBundle); + } + formatValues(keys) { + return this.formatWithFallback(keys, valueFromBundle); + } + async formatValue(id, args) { + const [val] = await this.formatValues([{ + id, + args + }]); + return val; + } + handleEvent() { + this.onChange(); + } + onChange(eager = false) { + this.bundles = CachedAsyncIterable.from(this.generateBundles(this.resourceIds)); + if (eager) { + this.bundles.touchNext(2); + } + } +} +function valueFromBundle(bundle, errors, message, args) { + if (message.value) { + return bundle.formatPattern(message.value, args, errors); + } + return null; +} +function messageFromBundle(bundle, errors, message, args) { + const formatted = { + value: null, + attributes: null + }; + if (message.value) { + formatted.value = bundle.formatPattern(message.value, args, errors); + } + let attrNames = Object.keys(message.attributes); + if (attrNames.length > 0) { + formatted.attributes = new Array(attrNames.length); + for (let [i, name] of attrNames.entries()) { + let value = bundle.formatPattern(message.attributes[name], args, errors); + formatted.attributes[i] = { + name, + value + }; + } + } + return formatted; +} +function keysFromBundle(method, bundle, keys, translations) { + const messageErrors = []; + const missingIds = new Set(); + keys.forEach(({ + id, + args + }, i) => { + if (translations[i] !== undefined) { + return; + } + let message = bundle.getMessage(id); + if (message) { + messageErrors.length = 0; + translations[i] = method(bundle, messageErrors, message, args); + if (messageErrors.length > 0 && typeof console !== "undefined") { + const locale = bundle.locales[0]; + const errors = messageErrors.join(", "); + console.warn(`[fluent][resolver] errors in ${locale}/${id}: ${errors}.`); + } + } else { + missingIds.add(id); + } + }); + return missingIds; +} +;// ./node_modules/@fluent/dom/esm/dom_localization.js + + +const L10NID_ATTR_NAME = "data-l10n-id"; +const L10NARGS_ATTR_NAME = "data-l10n-args"; +const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`; +class DOMLocalization extends Localization { + constructor(resourceIds, generateBundles) { + super(resourceIds, generateBundles); + this.roots = new Set(); + this.pendingrAF = null; + this.pendingElements = new Set(); + this.windowElement = null; + this.mutationObserver = null; + this.observerConfig = { + attributes: true, + characterData: false, + childList: true, + subtree: true, + attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME] + }; + } + onChange(eager = false) { + super.onChange(eager); + if (this.roots) { + this.translateRoots(); + } + } + setAttributes(element, id, args) { + element.setAttribute(L10NID_ATTR_NAME, id); + if (args) { + element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args)); + } else { + element.removeAttribute(L10NARGS_ATTR_NAME); + } + return element; + } + getAttributes(element) { + return { + id: element.getAttribute(L10NID_ATTR_NAME), + args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null) + }; + } + connectRoot(newRoot) { + for (const root of this.roots) { + if (root === newRoot || root.contains(newRoot) || newRoot.contains(root)) { + throw new Error("Cannot add a root that overlaps with existing root."); + } + } + if (this.windowElement) { + if (this.windowElement !== newRoot.ownerDocument.defaultView) { + throw new Error(`Cannot connect a root: + DOMLocalization already has a root from a different window.`); + } + } else { + this.windowElement = newRoot.ownerDocument.defaultView; + this.mutationObserver = new this.windowElement.MutationObserver(mutations => this.translateMutations(mutations)); + } + this.roots.add(newRoot); + this.mutationObserver.observe(newRoot, this.observerConfig); + } + disconnectRoot(root) { + this.roots.delete(root); + this.pauseObserving(); + if (this.roots.size === 0) { + this.mutationObserver = null; + if (this.windowElement && this.pendingrAF) { + this.windowElement.cancelAnimationFrame(this.pendingrAF); + } + this.windowElement = null; + this.pendingrAF = null; + this.pendingElements.clear(); + return true; + } + this.resumeObserving(); + return false; + } + translateRoots() { + const roots = Array.from(this.roots); + return Promise.all(roots.map(root => this.translateFragment(root))); + } + pauseObserving() { + if (!this.mutationObserver) { + return; + } + this.translateMutations(this.mutationObserver.takeRecords()); + this.mutationObserver.disconnect(); + } + resumeObserving() { + if (!this.mutationObserver) { + return; + } + for (const root of this.roots) { + this.mutationObserver.observe(root, this.observerConfig); + } + } + translateMutations(mutations) { + for (const mutation of mutations) { + switch (mutation.type) { + case "attributes": + if (mutation.target.hasAttribute("data-l10n-id")) { + this.pendingElements.add(mutation.target); + } + break; + case "childList": + for (const addedNode of mutation.addedNodes) { + if (addedNode.nodeType === addedNode.ELEMENT_NODE) { + if (addedNode.childElementCount) { + for (const element of this.getTranslatables(addedNode)) { + this.pendingElements.add(element); + } + } else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) { + this.pendingElements.add(addedNode); + } + } + } + break; + } + } + if (this.pendingElements.size > 0) { + if (this.pendingrAF === null) { + this.pendingrAF = this.windowElement.requestAnimationFrame(() => { + this.translateElements(Array.from(this.pendingElements)); + this.pendingElements.clear(); + this.pendingrAF = null; + }); + } + } + } + translateFragment(frag) { + return this.translateElements(this.getTranslatables(frag)); + } + async translateElements(elements) { + if (!elements.length) { + return undefined; + } + const keys = elements.map(this.getKeysForElement); + const translations = await this.formatMessages(keys); + return this.applyTranslations(elements, translations); + } + applyTranslations(elements, translations) { + this.pauseObserving(); + for (let i = 0; i < elements.length; i++) { + if (translations[i] !== undefined) { + translateElement(elements[i], translations[i]); + } + } + this.resumeObserving(); + } + getTranslatables(element) { + const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY)); + if (typeof element.hasAttribute === "function" && element.hasAttribute(L10NID_ATTR_NAME)) { + nodes.push(element); + } + return nodes; + } + getKeysForElement(element) { + return { + id: element.getAttribute(L10NID_ATTR_NAME), + args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null) + }; + } +} +;// ./node_modules/@fluent/dom/esm/index.js + + +;// ./web/l10n.js +class L10n { + #dir; + #elements; + #lang; + #l10n; + constructor({ + lang, + isRTL + }, l10n = null) { + this.#lang = L10n.#fixupLangCode(lang); + this.#l10n = l10n; + this.#dir = isRTL ?? L10n.#isRTL(this.#lang) ? "rtl" : "ltr"; + } + _setL10n(l10n) { + this.#l10n = l10n; + } + getLanguage() { + return this.#lang; + } + getDirection() { + return this.#dir; + } + async get(ids, args = null, fallback) { + if (Array.isArray(ids)) { + ids = ids.map(id => ({ + id + })); + const messages = await this.#l10n.formatMessages(ids); + return messages.map(message => message.value); + } + const messages = await this.#l10n.formatMessages([{ + id: ids, + args + }]); + return messages[0]?.value || fallback; + } + async translate(element) { + (this.#elements ||= new Set()).add(element); + try { + this.#l10n.connectRoot(element); + await this.#l10n.translateRoots(); + } catch {} + } + async translateOnce(element) { + try { + await this.#l10n.translateElements([element]); + } catch (ex) { + console.error("translateOnce:", ex); + } + } + async destroy() { + if (this.#elements) { + for (const element of this.#elements) { + this.#l10n.disconnectRoot(element); + } + this.#elements.clear(); + this.#elements = null; + } + this.#l10n.pauseObserving(); + } + pause() { + this.#l10n.pauseObserving(); + } + resume() { + this.#l10n.resumeObserving(); + } + static #fixupLangCode(langCode) { + langCode = langCode?.toLowerCase() || "en-us"; + const PARTIAL_LANG_CODES = { + en: "en-us", + es: "es-es", + fy: "fy-nl", + ga: "ga-ie", + gu: "gu-in", + hi: "hi-in", + hy: "hy-am", + nb: "nb-no", + ne: "ne-np", + nn: "nn-no", + pa: "pa-in", + pt: "pt-pt", + sv: "sv-se", + zh: "zh-cn" + }; + return PARTIAL_LANG_CODES[langCode] || langCode; + } + static #isRTL(lang) { + const shortCode = lang.split("-", 1)[0]; + return ["ar", "he", "fa", "ps", "ur"].includes(shortCode); + } +} +const GenericL10n = null; + +;// ./web/genericl10n.js + + + + +function createBundle(lang, text) { + const resource = new FluentResource(text); + const bundle = new FluentBundle(lang); + const errors = bundle.addResource(resource); + if (errors.length) { + console.error("L10n errors", errors); + } + return bundle; +} +class genericl10n_GenericL10n extends L10n { + constructor(lang) { + super({ + lang + }); + const generateBundles = !lang ? genericl10n_GenericL10n.#generateBundlesFallback.bind(genericl10n_GenericL10n, this.getLanguage()) : genericl10n_GenericL10n.#generateBundles.bind(genericl10n_GenericL10n, "en-us", this.getLanguage()); + this._setL10n(new DOMLocalization([], generateBundles)); + } + static async *#generateBundles(defaultLang, baseLang) { + const { + baseURL, + paths + } = await this.#getPaths(); + const langs = [baseLang]; + if (defaultLang !== baseLang) { + const shortLang = baseLang.split("-", 1)[0]; + if (shortLang !== baseLang) { + langs.push(shortLang); + } + langs.push(defaultLang); + } + for (const lang of langs) { + const bundle = await this.#createBundle(lang, baseURL, paths); + if (bundle) { + yield bundle; + } else if (lang === "en-us") { + yield this.#createBundleFallback(lang); + } + } + } + static async #createBundle(lang, baseURL, paths) { + const path = paths[lang]; + if (!path) { + return null; + } + const url = new URL(path, baseURL); + const text = await fetchData(url, "text"); + return createBundle(lang, text); + } + static async #getPaths() { + try { + const { + href + } = document.querySelector(`link[type="application/l10n"]`); + const paths = await fetchData(href, "json"); + return { + baseURL: href.replace(/[^/]*$/, "") || "./", + paths + }; + } catch {} + return { + baseURL: "./", + paths: Object.create(null) + }; + } + static async *#generateBundlesFallback(lang) { + yield this.#createBundleFallback(lang); + } + static async #createBundleFallback(lang) { + const text = "pdfjs-previous-button =\n .title = Previous Page\npdfjs-previous-button-label = Previous\npdfjs-next-button =\n .title = Next Page\npdfjs-next-button-label = Next\npdfjs-page-input =\n .title = Page\npdfjs-of-pages = of { $pagesCount }\npdfjs-page-of-pages = ({ $pageNumber } of { $pagesCount })\npdfjs-zoom-out-button =\n .title = Zoom Out\npdfjs-zoom-out-button-label = Zoom Out\npdfjs-zoom-in-button =\n .title = Zoom In\npdfjs-zoom-in-button-label = Zoom In\npdfjs-zoom-select =\n .title = Zoom\npdfjs-presentation-mode-button =\n .title = Switch to Presentation Mode\npdfjs-presentation-mode-button-label = Presentation Mode\npdfjs-open-file-button =\n .title = Open File\npdfjs-open-file-button-label = Open\npdfjs-print-button =\n .title = Print\npdfjs-print-button-label = Print\npdfjs-save-button =\n .title = Save\npdfjs-save-button-label = Save\npdfjs-download-button =\n .title = Download\npdfjs-download-button-label = Download\npdfjs-bookmark-button =\n .title = Current Page (View URL from Current Page)\npdfjs-bookmark-button-label = Current Page\npdfjs-tools-button =\n .title = Tools\npdfjs-tools-button-label = Tools\npdfjs-first-page-button =\n .title = Go to First Page\npdfjs-first-page-button-label = Go to First Page\npdfjs-last-page-button =\n .title = Go to Last Page\npdfjs-last-page-button-label = Go to Last Page\npdfjs-page-rotate-cw-button =\n .title = Rotate Clockwise\npdfjs-page-rotate-cw-button-label = Rotate Clockwise\npdfjs-page-rotate-ccw-button =\n .title = Rotate Counterclockwise\npdfjs-page-rotate-ccw-button-label = Rotate Counterclockwise\npdfjs-cursor-text-select-tool-button =\n .title = Enable Text Selection Tool\npdfjs-cursor-text-select-tool-button-label = Text Selection Tool\npdfjs-cursor-hand-tool-button =\n .title = Enable Hand Tool\npdfjs-cursor-hand-tool-button-label = Hand Tool\npdfjs-scroll-page-button =\n .title = Use Page Scrolling\npdfjs-scroll-page-button-label = Page Scrolling\npdfjs-scroll-vertical-button =\n .title = Use Vertical Scrolling\npdfjs-scroll-vertical-button-label = Vertical Scrolling\npdfjs-scroll-horizontal-button =\n .title = Use Horizontal Scrolling\npdfjs-scroll-horizontal-button-label = Horizontal Scrolling\npdfjs-scroll-wrapped-button =\n .title = Use Wrapped Scrolling\npdfjs-scroll-wrapped-button-label = Wrapped Scrolling\npdfjs-spread-none-button =\n .title = Do not join page spreads\npdfjs-spread-none-button-label = No Spreads\npdfjs-spread-odd-button =\n .title = Join page spreads starting with odd-numbered pages\npdfjs-spread-odd-button-label = Odd Spreads\npdfjs-spread-even-button =\n .title = Join page spreads starting with even-numbered pages\npdfjs-spread-even-button-label = Even Spreads\npdfjs-document-properties-button =\n .title = Document Properties\u2026\npdfjs-document-properties-button-label = Document Properties\u2026\npdfjs-document-properties-file-name = File name:\npdfjs-document-properties-file-size = File size:\npdfjs-document-properties-size-kb = { NUMBER($kb, maximumSignificantDigits: 3) } KB ({ $b } bytes)\npdfjs-document-properties-size-mb = { NUMBER($mb, maximumSignificantDigits: 3) } MB ({ $b } bytes)\npdfjs-document-properties-title = Title:\npdfjs-document-properties-author = Author:\npdfjs-document-properties-subject = Subject:\npdfjs-document-properties-keywords = Keywords:\npdfjs-document-properties-creation-date = Creation Date:\npdfjs-document-properties-modification-date = Modification Date:\npdfjs-document-properties-date-time-string = { DATETIME($dateObj, dateStyle: \"short\", timeStyle: \"medium\") }\npdfjs-document-properties-creator = Creator:\npdfjs-document-properties-producer = PDF Producer:\npdfjs-document-properties-version = PDF Version:\npdfjs-document-properties-page-count = Page Count:\npdfjs-document-properties-page-size = Page Size:\npdfjs-document-properties-page-size-unit-inches = in\npdfjs-document-properties-page-size-unit-millimeters = mm\npdfjs-document-properties-page-size-orientation-portrait = portrait\npdfjs-document-properties-page-size-orientation-landscape = landscape\npdfjs-document-properties-page-size-name-a-three = A3\npdfjs-document-properties-page-size-name-a-four = A4\npdfjs-document-properties-page-size-name-letter = Letter\npdfjs-document-properties-page-size-name-legal = Legal\npdfjs-document-properties-page-size-dimension-string = { $width } \xD7 { $height } { $unit } ({ $orientation })\npdfjs-document-properties-page-size-dimension-name-string = { $width } \xD7 { $height } { $unit } ({ $name }, { $orientation })\npdfjs-document-properties-linearized = Fast Web View:\npdfjs-document-properties-linearized-yes = Yes\npdfjs-document-properties-linearized-no = No\npdfjs-document-properties-close-button = Close\npdfjs-print-progress-message = Preparing document for printing\u2026\npdfjs-print-progress-percent = { $progress }%\npdfjs-print-progress-close-button = Cancel\npdfjs-printing-not-supported = Warning: Printing is not fully supported by this browser.\npdfjs-printing-not-ready = Warning: The PDF is not fully loaded for printing.\npdfjs-toggle-sidebar-button =\n .title = Toggle Sidebar\npdfjs-toggle-sidebar-notification-button =\n .title = Toggle Sidebar (document contains outline/attachments/layers)\npdfjs-toggle-sidebar-button-label = Toggle Sidebar\npdfjs-document-outline-button =\n .title = Show Document Outline (double-click to expand/collapse all items)\npdfjs-document-outline-button-label = Document Outline\npdfjs-attachments-button =\n .title = Show Attachments\npdfjs-attachments-button-label = Attachments\npdfjs-layers-button =\n .title = Show Layers (double-click to reset all layers to the default state)\npdfjs-layers-button-label = Layers\npdfjs-thumbs-button =\n .title = Show Thumbnails\npdfjs-thumbs-button-label = Thumbnails\npdfjs-current-outline-item-button =\n .title = Find Current Outline Item\npdfjs-current-outline-item-button-label = Current Outline Item\npdfjs-findbar-button =\n .title = Find in Document\npdfjs-findbar-button-label = Find\npdfjs-additional-layers = Additional Layers\npdfjs-thumb-page-title =\n .title = Page { $page }\npdfjs-thumb-page-canvas =\n .aria-label = Thumbnail of Page { $page }\npdfjs-find-input =\n .title = Find\n .placeholder = Find in document\u2026\npdfjs-find-previous-button =\n .title = Find the previous occurrence of the phrase\npdfjs-find-previous-button-label = Previous\npdfjs-find-next-button =\n .title = Find the next occurrence of the phrase\npdfjs-find-next-button-label = Next\npdfjs-find-highlight-checkbox = Highlight All\npdfjs-find-match-case-checkbox-label = Match Case\npdfjs-find-match-diacritics-checkbox-label = Match Diacritics\npdfjs-find-entire-word-checkbox-label = Whole Words\npdfjs-find-reached-top = Reached top of document, continued from bottom\npdfjs-find-reached-bottom = Reached end of document, continued from top\npdfjs-find-match-count =\n { $total ->\n [one] { $current } of { $total } match\n *[other] { $current } of { $total } matches\n }\npdfjs-find-match-count-limit =\n { $limit ->\n [one] More than { $limit } match\n *[other] More than { $limit } matches\n }\npdfjs-find-not-found = Phrase not found\npdfjs-page-scale-width = Page Width\npdfjs-page-scale-fit = Page Fit\npdfjs-page-scale-auto = Automatic Zoom\npdfjs-page-scale-actual = Actual Size\npdfjs-page-scale-percent = { $scale }%\npdfjs-page-landmark =\n .aria-label = Page { $page }\npdfjs-loading-error = An error occurred while loading the PDF.\npdfjs-invalid-file-error = Invalid or corrupted PDF file.\npdfjs-missing-file-error = Missing PDF file.\npdfjs-unexpected-response-error = Unexpected server response.\npdfjs-rendering-error = An error occurred while rendering the page.\npdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: \"short\", timeStyle: \"medium\") }\npdfjs-text-annotation-type =\n .alt = [{ $type } Annotation]\npdfjs-password-label = Enter the password to open this PDF file.\npdfjs-password-invalid = Invalid password. Please try again.\npdfjs-password-ok-button = OK\npdfjs-password-cancel-button = Cancel\npdfjs-web-fonts-disabled = Web fonts are disabled: unable to use embedded PDF fonts.\npdfjs-editor-free-text-button =\n .title = Text\npdfjs-editor-free-text-button-label = Text\npdfjs-editor-ink-button =\n .title = Draw\npdfjs-editor-ink-button-label = Draw\npdfjs-editor-stamp-button =\n .title = Add or edit images\npdfjs-editor-stamp-button-label = Add or edit images\npdfjs-editor-highlight-button =\n .title = Highlight\npdfjs-editor-highlight-button-label = Highlight\npdfjs-highlight-floating-button1 =\n .title = Highlight\n .aria-label = Highlight\npdfjs-highlight-floating-button-label = Highlight\npdfjs-editor-remove-ink-button =\n .title = Remove drawing\npdfjs-editor-remove-freetext-button =\n .title = Remove text\npdfjs-editor-remove-stamp-button =\n .title = Remove image\npdfjs-editor-remove-highlight-button =\n .title = Remove highlight\npdfjs-editor-free-text-color-input = Color\npdfjs-editor-free-text-size-input = Size\npdfjs-editor-ink-color-input = Color\npdfjs-editor-ink-thickness-input = Thickness\npdfjs-editor-ink-opacity-input = Opacity\npdfjs-editor-stamp-add-image-button =\n .title = Add image\npdfjs-editor-stamp-add-image-button-label = Add image\npdfjs-editor-free-highlight-thickness-input = Thickness\npdfjs-editor-free-highlight-thickness-title =\n .title = Change thickness when highlighting items other than text\npdfjs-free-text2 =\n .aria-label = Text Editor\n .default-content = Start typing\u2026\npdfjs-ink =\n .aria-label = Draw Editor\npdfjs-ink-canvas =\n .aria-label = User-created image\npdfjs-editor-alt-text-button =\n .aria-label = Alt text\npdfjs-editor-alt-text-button-label = Alt text\npdfjs-editor-alt-text-edit-button =\n .aria-label = Edit alt text\npdfjs-editor-alt-text-dialog-label = Choose an option\npdfjs-editor-alt-text-dialog-description = Alt text (alternative text) helps when people can\u2019t see the image or when it doesn\u2019t load.\npdfjs-editor-alt-text-add-description-label = Add a description\npdfjs-editor-alt-text-add-description-description = Aim for 1-2 sentences that describe the subject, setting, or actions.\npdfjs-editor-alt-text-mark-decorative-label = Mark as decorative\npdfjs-editor-alt-text-mark-decorative-description = This is used for ornamental images, like borders or watermarks.\npdfjs-editor-alt-text-cancel-button = Cancel\npdfjs-editor-alt-text-save-button = Save\npdfjs-editor-alt-text-decorative-tooltip = Marked as decorative\npdfjs-editor-alt-text-textarea =\n .placeholder = For example, \u201CA young man sits down at a table to eat a meal\u201D\npdfjs-editor-resizer-top-left =\n .aria-label = Top left corner \u2014 resize\npdfjs-editor-resizer-top-middle =\n .aria-label = Top middle \u2014 resize\npdfjs-editor-resizer-top-right =\n .aria-label = Top right corner \u2014 resize\npdfjs-editor-resizer-middle-right =\n .aria-label = Middle right \u2014 resize\npdfjs-editor-resizer-bottom-right =\n .aria-label = Bottom right corner \u2014 resize\npdfjs-editor-resizer-bottom-middle =\n .aria-label = Bottom middle \u2014 resize\npdfjs-editor-resizer-bottom-left =\n .aria-label = Bottom left corner \u2014 resize\npdfjs-editor-resizer-middle-left =\n .aria-label = Middle left \u2014 resize\npdfjs-editor-highlight-colorpicker-label = Highlight color\npdfjs-editor-colorpicker-button =\n .title = Change color\npdfjs-editor-colorpicker-dropdown =\n .aria-label = Color choices\npdfjs-editor-colorpicker-yellow =\n .title = Yellow\npdfjs-editor-colorpicker-green =\n .title = Green\npdfjs-editor-colorpicker-blue =\n .title = Blue\npdfjs-editor-colorpicker-pink =\n .title = Pink\npdfjs-editor-colorpicker-red =\n .title = Red\npdfjs-editor-highlight-show-all-button-label = Show all\npdfjs-editor-highlight-show-all-button =\n .title = Show all\npdfjs-editor-new-alt-text-dialog-edit-label = Edit alt text (image description)\npdfjs-editor-new-alt-text-dialog-add-label = Add alt text (image description)\npdfjs-editor-new-alt-text-textarea =\n .placeholder = Write your description here\u2026\npdfjs-editor-new-alt-text-description = Short description for people who can\u2019t see the image or when the image doesn\u2019t load.\npdfjs-editor-new-alt-text-disclaimer1 = This alt text was created automatically and may be inaccurate.\npdfjs-editor-new-alt-text-disclaimer-learn-more-url = Learn more\npdfjs-editor-new-alt-text-create-automatically-button-label = Create alt text automatically\npdfjs-editor-new-alt-text-not-now-button = Not now\npdfjs-editor-new-alt-text-error-title = Couldn\u2019t create alt text automatically\npdfjs-editor-new-alt-text-error-description = Please write your own alt text or try again later.\npdfjs-editor-new-alt-text-error-close-button = Close\npdfjs-editor-new-alt-text-ai-model-downloading-progress = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB)\n .aria-valuetext = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB)\npdfjs-editor-new-alt-text-added-button =\n .aria-label = Alt text added\npdfjs-editor-new-alt-text-added-button-label = Alt text added\npdfjs-editor-new-alt-text-missing-button =\n .aria-label = Missing alt text\npdfjs-editor-new-alt-text-missing-button-label = Missing alt text\npdfjs-editor-new-alt-text-to-review-button =\n .aria-label = Review alt text\npdfjs-editor-new-alt-text-to-review-button-label = Review alt text\npdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = Created automatically: { $generatedAltText }\npdfjs-image-alt-text-settings-button =\n .title = Image alt text settings\npdfjs-image-alt-text-settings-button-label = Image alt text settings\npdfjs-editor-alt-text-settings-dialog-label = Image alt text settings\npdfjs-editor-alt-text-settings-automatic-title = Automatic alt text\npdfjs-editor-alt-text-settings-create-model-button-label = Create alt text automatically\npdfjs-editor-alt-text-settings-create-model-description = Suggests descriptions to help people who can\u2019t see the image or when the image doesn\u2019t load.\npdfjs-editor-alt-text-settings-download-model-label = Alt text AI model ({ $totalSize } MB)\npdfjs-editor-alt-text-settings-ai-model-description = Runs locally on your device so your data stays private. Required for automatic alt text.\npdfjs-editor-alt-text-settings-delete-model-button = Delete\npdfjs-editor-alt-text-settings-download-model-button = Download\npdfjs-editor-alt-text-settings-downloading-model-button = Downloading\u2026\npdfjs-editor-alt-text-settings-editor-title = Alt text editor\npdfjs-editor-alt-text-settings-show-dialog-button-label = Show alt text editor right away when adding an image\npdfjs-editor-alt-text-settings-show-dialog-description = Helps you make sure all your images have alt text.\npdfjs-editor-alt-text-settings-close-button = Close\npdfjs-editor-undo-bar-message-highlight = Highlight removed\npdfjs-editor-undo-bar-message-freetext = Text removed\npdfjs-editor-undo-bar-message-ink = Drawing removed\npdfjs-editor-undo-bar-message-stamp = Image removed\npdfjs-editor-undo-bar-message-multiple =\n { $count ->\n [one] { $count } annotation removed\n *[other] { $count } annotations removed\n }\npdfjs-editor-undo-bar-undo-button =\n .title = Undo\npdfjs-editor-undo-bar-undo-button-label = Undo\npdfjs-editor-undo-bar-close-button =\n .title = Close\npdfjs-editor-undo-bar-close-button-label = Close"; + return createBundle(lang, text); + } +} + +;// ./web/pdf_history.js + + +const HASH_CHANGE_TIMEOUT = 1000; +const POSITION_UPDATED_THRESHOLD = 50; +const UPDATE_VIEWAREA_TIMEOUT = 1000; +function getCurrentHash() { + return document.location.hash; +} +class PDFHistory { + #eventAbortController = null; + constructor({ + linkService, + eventBus + }) { + this.linkService = linkService; + this.eventBus = eventBus; + this._initialized = false; + this._fingerprint = ""; + this.reset(); + this.eventBus._on("pagesinit", () => { + this._isPagesLoaded = false; + this.eventBus._on("pagesloaded", evt => { + this._isPagesLoaded = !!evt.pagesCount; + }, { + once: true + }); + }); + } + initialize({ + fingerprint, + resetHistory = false, + updateUrl = false + }) { + if (!fingerprint || typeof fingerprint !== "string") { + console.error('PDFHistory.initialize: The "fingerprint" must be a non-empty string.'); + return; + } + if (this._initialized) { + this.reset(); + } + const reInitialized = this._fingerprint !== "" && this._fingerprint !== fingerprint; + this._fingerprint = fingerprint; + this._updateUrl = updateUrl === true; + this._initialized = true; + this.#bindEvents(); + const state = window.history.state; + this._popStateInProgress = false; + this._blockHashChange = 0; + this._currentHash = getCurrentHash(); + this._numPositionUpdates = 0; + this._uid = this._maxUid = 0; + this._destination = null; + this._position = null; + if (!this.#isValidState(state, true) || resetHistory) { + const { + hash, + page, + rotation + } = this.#parseCurrentHash(true); + if (!hash || reInitialized || resetHistory) { + this.#pushOrReplaceState(null, true); + return; + } + this.#pushOrReplaceState({ + hash, + page, + rotation + }, true); + return; + } + const destination = state.destination; + this.#updateInternalState(destination, state.uid, true); + if (destination.rotation !== undefined) { + this._initialRotation = destination.rotation; + } + if (destination.dest) { + this._initialBookmark = JSON.stringify(destination.dest); + this._destination.page = null; + } else if (destination.hash) { + this._initialBookmark = destination.hash; + } else if (destination.page) { + this._initialBookmark = `page=${destination.page}`; + } + } + reset() { + if (this._initialized) { + this.#pageHide(); + this._initialized = false; + this.#unbindEvents(); + } + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + this._initialBookmark = null; + this._initialRotation = null; + } + push({ + namedDest = null, + explicitDest, + pageNumber + }) { + if (!this._initialized) { + return; + } + if (namedDest && typeof namedDest !== "string") { + console.error("PDFHistory.push: " + `"${namedDest}" is not a valid namedDest parameter.`); + return; + } else if (!Array.isArray(explicitDest)) { + console.error("PDFHistory.push: " + `"${explicitDest}" is not a valid explicitDest parameter.`); + return; + } else if (!this.#isValidPage(pageNumber)) { + if (pageNumber !== null || this._destination) { + console.error("PDFHistory.push: " + `"${pageNumber}" is not a valid pageNumber parameter.`); + return; + } + } + const hash = namedDest || JSON.stringify(explicitDest); + if (!hash) { + return; + } + let forceReplace = false; + if (this._destination && (isDestHashesEqual(this._destination.hash, hash) || isDestArraysEqual(this._destination.dest, explicitDest))) { + if (this._destination.page) { + return; + } + forceReplace = true; + } + if (this._popStateInProgress && !forceReplace) { + return; + } + this.#pushOrReplaceState({ + dest: explicitDest, + hash, + page: pageNumber, + rotation: this.linkService.rotation + }, forceReplace); + if (!this._popStateInProgress) { + this._popStateInProgress = true; + Promise.resolve().then(() => { + this._popStateInProgress = false; + }); + } + } + pushPage(pageNumber) { + if (!this._initialized) { + return; + } + if (!this.#isValidPage(pageNumber)) { + console.error(`PDFHistory.pushPage: "${pageNumber}" is not a valid page number.`); + return; + } + if (this._destination?.page === pageNumber) { + return; + } + if (this._popStateInProgress) { + return; + } + this.#pushOrReplaceState({ + dest: null, + hash: `page=${pageNumber}`, + page: pageNumber, + rotation: this.linkService.rotation + }); + if (!this._popStateInProgress) { + this._popStateInProgress = true; + Promise.resolve().then(() => { + this._popStateInProgress = false; + }); + } + } + pushCurrentPosition() { + if (!this._initialized || this._popStateInProgress) { + return; + } + this.#tryPushCurrentPosition(); + } + back() { + if (!this._initialized || this._popStateInProgress) { + return; + } + const state = window.history.state; + if (this.#isValidState(state) && state.uid > 0) { + window.history.back(); + } + } + forward() { + if (!this._initialized || this._popStateInProgress) { + return; + } + const state = window.history.state; + if (this.#isValidState(state) && state.uid < this._maxUid) { + window.history.forward(); + } + } + get popStateInProgress() { + return this._initialized && (this._popStateInProgress || this._blockHashChange > 0); + } + get initialBookmark() { + return this._initialized ? this._initialBookmark : null; + } + get initialRotation() { + return this._initialized ? this._initialRotation : null; + } + #pushOrReplaceState(destination, forceReplace = false) { + const shouldReplace = forceReplace || !this._destination; + const newState = { + fingerprint: this._fingerprint, + uid: shouldReplace ? this._uid : this._uid + 1, + destination + }; + this.#updateInternalState(destination, newState.uid); + let newUrl; + if (this._updateUrl && destination?.hash) { + const baseUrl = document.location.href.split("#", 1)[0]; + if (!baseUrl.startsWith("file://")) { + newUrl = `${baseUrl}#${destination.hash}`; + } + } + if (shouldReplace) { + window.history.replaceState(newState, "", newUrl); + } else { + window.history.pushState(newState, "", newUrl); + } + } + #tryPushCurrentPosition(temporary = false) { + if (!this._position) { + return; + } + let position = this._position; + if (temporary) { + position = Object.assign(Object.create(null), this._position); + position.temporary = true; + } + if (!this._destination) { + this.#pushOrReplaceState(position); + return; + } + if (this._destination.temporary) { + this.#pushOrReplaceState(position, true); + return; + } + if (this._destination.hash === position.hash) { + return; + } + if (!this._destination.page && (POSITION_UPDATED_THRESHOLD <= 0 || this._numPositionUpdates <= POSITION_UPDATED_THRESHOLD)) { + return; + } + let forceReplace = false; + if (this._destination.page >= position.first && this._destination.page <= position.page) { + if (this._destination.dest !== undefined || !this._destination.first) { + return; + } + forceReplace = true; + } + this.#pushOrReplaceState(position, forceReplace); + } + #isValidPage(val) { + return Number.isInteger(val) && val > 0 && val <= this.linkService.pagesCount; + } + #isValidState(state, checkReload = false) { + if (!state) { + return false; + } + if (state.fingerprint !== this._fingerprint) { + if (checkReload) { + if (typeof state.fingerprint !== "string" || state.fingerprint.length !== this._fingerprint.length) { + return false; + } + const [perfEntry] = performance.getEntriesByType("navigation"); + if (perfEntry?.type !== "reload") { + return false; + } + } else { + return false; + } + } + if (!Number.isInteger(state.uid) || state.uid < 0) { + return false; + } + if (state.destination === null || typeof state.destination !== "object") { + return false; + } + return true; + } + #updateInternalState(destination, uid, removeTemporary = false) { + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + if (removeTemporary && destination?.temporary) { + delete destination.temporary; + } + this._destination = destination; + this._uid = uid; + this._maxUid = Math.max(this._maxUid, uid); + this._numPositionUpdates = 0; + } + #parseCurrentHash(checkNameddest = false) { + const hash = unescape(getCurrentHash()).substring(1); + const params = parseQueryString(hash); + const nameddest = params.get("nameddest") || ""; + let page = params.get("page") | 0; + if (!this.#isValidPage(page) || checkNameddest && nameddest.length > 0) { + page = null; + } + return { + hash, + page, + rotation: this.linkService.rotation + }; + } + #updateViewarea({ + location + }) { + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + this._position = { + hash: location.pdfOpenParams.substring(1), + page: this.linkService.page, + first: location.pageNumber, + rotation: location.rotation + }; + if (this._popStateInProgress) { + return; + } + if (POSITION_UPDATED_THRESHOLD > 0 && this._isPagesLoaded && this._destination && !this._destination.page) { + this._numPositionUpdates++; + } + if (UPDATE_VIEWAREA_TIMEOUT > 0) { + this._updateViewareaTimeout = setTimeout(() => { + if (!this._popStateInProgress) { + this.#tryPushCurrentPosition(true); + } + this._updateViewareaTimeout = null; + }, UPDATE_VIEWAREA_TIMEOUT); + } + } + #popState({ + state + }) { + const newHash = getCurrentHash(), + hashChanged = this._currentHash !== newHash; + this._currentHash = newHash; + if (!state) { + this._uid++; + const { + hash, + page, + rotation + } = this.#parseCurrentHash(); + this.#pushOrReplaceState({ + hash, + page, + rotation + }, true); + return; + } + if (!this.#isValidState(state)) { + return; + } + this._popStateInProgress = true; + if (hashChanged) { + this._blockHashChange++; + waitOnEventOrTimeout({ + target: window, + name: "hashchange", + delay: HASH_CHANGE_TIMEOUT + }).then(() => { + this._blockHashChange--; + }); + } + const destination = state.destination; + this.#updateInternalState(destination, state.uid, true); + if (isValidRotation(destination.rotation)) { + this.linkService.rotation = destination.rotation; + } + if (destination.dest) { + this.linkService.goToDestination(destination.dest); + } else if (destination.hash) { + this.linkService.setHash(destination.hash); + } else if (destination.page) { + this.linkService.page = destination.page; + } + Promise.resolve().then(() => { + this._popStateInProgress = false; + }); + } + #pageHide() { + if (!this._destination || this._destination.temporary) { + this.#tryPushCurrentPosition(); + } + } + #bindEvents() { + if (this.#eventAbortController) { + return; + } + this.#eventAbortController = new AbortController(); + const { + signal + } = this.#eventAbortController; + this.eventBus._on("updateviewarea", this.#updateViewarea.bind(this), { + signal + }); + window.addEventListener("popstate", this.#popState.bind(this), { + signal + }); + window.addEventListener("pagehide", this.#pageHide.bind(this), { + signal + }); + } + #unbindEvents() { + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + } +} +function isDestHashesEqual(destHash, pushHash) { + if (typeof destHash !== "string" || typeof pushHash !== "string") { + return false; + } + if (destHash === pushHash) { + return true; + } + const nameddest = parseQueryString(destHash).get("nameddest"); + if (nameddest === pushHash) { + return true; + } + return false; +} +function isDestArraysEqual(firstDest, secondDest) { + function isEntryEqual(first, second) { + if (typeof first !== typeof second) { + return false; + } + if (Array.isArray(first) || Array.isArray(second)) { + return false; + } + if (first !== null && typeof first === "object" && second !== null) { + if (Object.keys(first).length !== Object.keys(second).length) { + return false; + } + for (const key in first) { + if (!isEntryEqual(first[key], second[key])) { + return false; + } + } + return true; + } + return first === second || Number.isNaN(first) && Number.isNaN(second); + } + if (!(Array.isArray(firstDest) && Array.isArray(secondDest))) { + return false; + } + if (firstDest.length !== secondDest.length) { + return false; + } + for (let i = 0, ii = firstDest.length; i < ii; i++) { + if (!isEntryEqual(firstDest[i], secondDest[i])) { + return false; + } + } + return true; +} + +;// ./web/annotation_editor_layer_builder.js + + +class AnnotationEditorLayerBuilder { + #annotationLayer = null; + #drawLayer = null; + #onAppend = null; + #structTreeLayer = null; + #textLayer = null; + #uiManager; + constructor(options) { + this.pdfPage = options.pdfPage; + this.accessibilityManager = options.accessibilityManager; + this.l10n = options.l10n; + this.l10n ||= new genericl10n_GenericL10n(); + this.annotationEditorLayer = null; + this.div = null; + this._cancelled = false; + this.#uiManager = options.uiManager; + this.#annotationLayer = options.annotationLayer || null; + this.#textLayer = options.textLayer || null; + this.#drawLayer = options.drawLayer || null; + this.#onAppend = options.onAppend || null; + this.#structTreeLayer = options.structTreeLayer || null; + } + async render(viewport, intent = "display") { + if (intent !== "display") { + return; + } + if (this._cancelled) { + return; + } + const clonedViewport = viewport.clone({ + dontFlip: true + }); + if (this.div) { + this.annotationEditorLayer.update({ + viewport: clonedViewport + }); + this.show(); + return; + } + const div = this.div = document.createElement("div"); + div.className = "annotationEditorLayer"; + div.hidden = true; + div.dir = this.#uiManager.direction; + this.#onAppend?.(div); + this.annotationEditorLayer = new AnnotationEditorLayer({ + uiManager: this.#uiManager, + div, + structTreeLayer: this.#structTreeLayer, + accessibilityManager: this.accessibilityManager, + pageIndex: this.pdfPage.pageNumber - 1, + l10n: this.l10n, + viewport: clonedViewport, + annotationLayer: this.#annotationLayer, + textLayer: this.#textLayer, + drawLayer: this.#drawLayer + }); + const parameters = { + viewport: clonedViewport, + div, + annotations: null, + intent + }; + this.annotationEditorLayer.render(parameters); + this.show(); + } + cancel() { + this._cancelled = true; + if (!this.div) { + return; + } + this.annotationEditorLayer.destroy(); + } + hide() { + if (!this.div) { + return; + } + this.annotationEditorLayer.pause(true); + this.div.hidden = true; + } + show() { + if (!this.div || this.annotationEditorLayer.isInvisible) { + return; + } + this.div.hidden = false; + this.annotationEditorLayer.pause(false); + } +} + +;// ./web/app_options.js +{ + var compatParams = new Map(); + const userAgent = navigator.userAgent || ""; + const platform = navigator.platform || ""; + const maxTouchPoints = navigator.maxTouchPoints || 1; + const isAndroid = /Android/.test(userAgent); + const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent) || platform === "MacIntel" && maxTouchPoints > 1; + (function () { + if (isIOS || isAndroid) { + compatParams.set("maxCanvasPixels", 5242880); + } + })(); + (function () { + if (isAndroid) { + compatParams.set("useSystemFonts", false); + } + })(); +} +const OptionKind = { + BROWSER: 0x01, + VIEWER: 0x02, + API: 0x04, + WORKER: 0x08, + EVENT_DISPATCH: 0x10, + PREFERENCE: 0x80 +}; +const Type = { + BOOLEAN: 0x01, + NUMBER: 0x02, + OBJECT: 0x04, + STRING: 0x08, + UNDEFINED: 0x10 +}; +const defaultOptions = { + allowedGlobalEvents: { + value: null, + kind: OptionKind.BROWSER + }, + canvasMaxAreaInBytes: { + value: -1, + kind: OptionKind.BROWSER + OptionKind.API + }, + isInAutomation: { + value: false, + kind: OptionKind.BROWSER + }, + localeProperties: { + value: { + lang: navigator.language || "en-US" + }, + kind: OptionKind.BROWSER + }, + nimbusDataStr: { + value: "", + kind: OptionKind.BROWSER + }, + supportsCaretBrowsingMode: { + value: false, + kind: OptionKind.BROWSER + }, + supportsDocumentFonts: { + value: true, + kind: OptionKind.BROWSER + }, + supportsIntegratedFind: { + value: false, + kind: OptionKind.BROWSER + }, + supportsMouseWheelZoomCtrlKey: { + value: true, + kind: OptionKind.BROWSER + }, + supportsMouseWheelZoomMetaKey: { + value: true, + kind: OptionKind.BROWSER + }, + supportsPinchToZoom: { + value: true, + kind: OptionKind.BROWSER + }, + toolbarDensity: { + value: 0, + kind: OptionKind.BROWSER + OptionKind.EVENT_DISPATCH + }, + altTextLearnMoreUrl: { + value: "", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + annotationEditorMode: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + annotationMode: { + value: 2, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + cursorToolOnLoad: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + debuggerSrc: { + value: "./debugger.mjs", + kind: OptionKind.VIEWER + }, + defaultZoomDelay: { + value: 400, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + defaultZoomValue: { + value: "", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + disableHistory: { + value: false, + kind: OptionKind.VIEWER + }, + disablePageLabels: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableAltText: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableAltTextModelDownload: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + OptionKind.EVENT_DISPATCH + }, + enableGuessAltText: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + OptionKind.EVENT_DISPATCH + }, + enableHighlightFloatingButton: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableNewAltTextWhenAddingImage: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enablePermissions: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enablePrintAutoRotate: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableScripting: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableUpdatedAddImage: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + externalLinkRel: { + value: "noopener noreferrer nofollow", + kind: OptionKind.VIEWER + }, + externalLinkTarget: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + highlightEditorColors: { + value: "yellow=#FFFF98,green=#53FFBC,blue=#80EBFF,pink=#FFCBE6,red=#FF4F5F", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + historyUpdateUrl: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + ignoreDestinationZoom: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + imageResourcesPath: { + value: "./images/", + kind: OptionKind.VIEWER + }, + maxCanvasPixels: { + value: 2 ** 25, + kind: OptionKind.VIEWER + }, + forcePageColors: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + pageColorsBackground: { + value: "Canvas", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + pageColorsForeground: { + value: "CanvasText", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + pdfBugEnabled: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + printResolution: { + value: 150, + kind: OptionKind.VIEWER + }, + sidebarViewOnLoad: { + value: -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + scrollModeOnLoad: { + value: -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + spreadModeOnLoad: { + value: -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + textLayerMode: { + value: 1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + viewOnLoad: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + cMapPacked: { + value: true, + kind: OptionKind.API + }, + cMapUrl: { + value: "../web/cmaps/", + kind: OptionKind.API + }, + disableAutoFetch: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + disableFontFace: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + disableRange: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + disableStream: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + docBaseUrl: { + value: "", + kind: OptionKind.API + }, + enableHWA: { + value: true, + kind: OptionKind.API + OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableXfa: { + value: true, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + fontExtraProperties: { + value: false, + kind: OptionKind.API + }, + isEvalSupported: { + value: true, + kind: OptionKind.API + }, + isOffscreenCanvasSupported: { + value: true, + kind: OptionKind.API + }, + maxImageSize: { + value: -1, + kind: OptionKind.API + }, + pdfBug: { + value: false, + kind: OptionKind.API + }, + standardFontDataUrl: { + value: "../web/standard_fonts/", + kind: OptionKind.API + }, + useSystemFonts: { + value: undefined, + kind: OptionKind.API, + type: Type.BOOLEAN + Type.UNDEFINED + }, + verbosity: { + value: 1, + kind: OptionKind.API + }, + workerPort: { + value: null, + kind: OptionKind.WORKER + }, + workerSrc: { + value: "../build/pdf.worker.mjs", + kind: OptionKind.WORKER + } +}; +{ + defaultOptions.defaultUrl = { + value: "compressed.tracemonkey-pldi-09.pdf", + kind: OptionKind.VIEWER + }; + defaultOptions.sandboxBundleSrc = { + value: "../build/pdf.sandbox.mjs", + kind: OptionKind.VIEWER + }; + defaultOptions.viewerCssTheme = { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }; + defaultOptions.enableFakeMLManager = { + value: true, + kind: OptionKind.VIEWER + }; +} +{ + defaultOptions.disablePreferences = { + value: false, + kind: OptionKind.VIEWER + }; +} +class AppOptions { + static eventBus; + static #opts = new Map(); + static { + for (const name in defaultOptions) { + this.#opts.set(name, defaultOptions[name].value); + } + for (const [name, value] of compatParams) { + this.#opts.set(name, value); + } + this._hasInvokedSet = false; + this._checkDisablePreferences = () => { + if (this.get("disablePreferences")) { + return true; + } + if (this._hasInvokedSet) { + console.warn("The Preferences may override manually set AppOptions; " + 'please use the "disablePreferences"-option to prevent that.'); + } + return false; + }; + } + static get(name) { + return this.#opts.get(name); + } + static getAll(kind = null, defaultOnly = false) { + const options = Object.create(null); + for (const name in defaultOptions) { + const defaultOpt = defaultOptions[name]; + if (kind && !(kind & defaultOpt.kind)) { + continue; + } + options[name] = !defaultOnly ? this.#opts.get(name) : defaultOpt.value; + } + return options; + } + static set(name, value) { + this.setAll({ + [name]: value + }); + } + static setAll(options, prefs = false) { + this._hasInvokedSet ||= true; + let events; + for (const name in options) { + const defaultOpt = defaultOptions[name], + userOpt = options[name]; + if (!defaultOpt || !(typeof userOpt === typeof defaultOpt.value || Type[(typeof userOpt).toUpperCase()] & defaultOpt.type)) { + continue; + } + const { + kind + } = defaultOpt; + if (prefs && !(kind & OptionKind.BROWSER || kind & OptionKind.PREFERENCE)) { + continue; + } + if (this.eventBus && kind & OptionKind.EVENT_DISPATCH) { + (events ||= new Map()).set(name, userOpt); + } + this.#opts.set(name, userOpt); + } + if (events) { + for (const [name, value] of events) { + this.eventBus.dispatch(name.toLowerCase(), { + source: this, + value + }); + } + } + } +} + +;// ./web/draw_layer_builder.js + +class DrawLayerBuilder { + #drawLayer = null; + constructor(options) { + this.pageIndex = options.pageIndex; + } + async render(intent = "display") { + if (intent !== "display" || this.#drawLayer || this._cancelled) { + return; + } + this.#drawLayer = new DrawLayer({ + pageIndex: this.pageIndex + }); + } + cancel() { + this._cancelled = true; + if (!this.#drawLayer) { + return; + } + this.#drawLayer.destroy(); + this.#drawLayer = null; + } + setParent(parent) { + this.#drawLayer?.setParent(parent); + } + getDrawLayer() { + return this.#drawLayer; + } +} + +;// ./web/struct_tree_layer_builder.js + +const PDF_ROLE_TO_HTML_ROLE = { + Document: null, + DocumentFragment: null, + Part: "group", + Sect: "group", + Div: "group", + Aside: "note", + NonStruct: "none", + P: null, + H: "heading", + Title: null, + FENote: "note", + Sub: "group", + Lbl: null, + Span: null, + Em: null, + Strong: null, + Link: "link", + Annot: "note", + Form: "form", + Ruby: null, + RB: null, + RT: null, + RP: null, + Warichu: null, + WT: null, + WP: null, + L: "list", + LI: "listitem", + LBody: null, + Table: "table", + TR: "row", + TH: "columnheader", + TD: "cell", + THead: "columnheader", + TBody: null, + TFoot: null, + Caption: null, + Figure: "figure", + Formula: null, + Artifact: null +}; +const HEADING_PATTERN = /^H(\d+)$/; +class StructTreeLayerBuilder { + #promise; + #treeDom = null; + #treePromise; + #elementAttributes = new Map(); + #rawDims; + #elementsToAddToTextLayer = null; + constructor(pdfPage, rawDims) { + this.#promise = pdfPage.getStructTree(); + this.#rawDims = rawDims; + } + async render() { + if (this.#treePromise) { + return this.#treePromise; + } + const { + promise, + resolve, + reject + } = Promise.withResolvers(); + this.#treePromise = promise; + try { + this.#treeDom = this.#walk(await this.#promise); + } catch (ex) { + reject(ex); + } + this.#promise = null; + this.#treeDom?.classList.add("structTree"); + resolve(this.#treeDom); + return promise; + } + async getAriaAttributes(annotationId) { + try { + await this.render(); + return this.#elementAttributes.get(annotationId); + } catch {} + return null; + } + hide() { + if (this.#treeDom && !this.#treeDom.hidden) { + this.#treeDom.hidden = true; + } + } + show() { + if (this.#treeDom?.hidden) { + this.#treeDom.hidden = false; + } + } + #setAttributes(structElement, htmlElement) { + const { + alt, + id, + lang + } = structElement; + if (alt !== undefined) { + let added = false; + const label = removeNullCharacters(alt); + for (const child of structElement.children) { + if (child.type === "annotation") { + let attrs = this.#elementAttributes.get(child.id); + if (!attrs) { + attrs = new Map(); + this.#elementAttributes.set(child.id, attrs); + } + attrs.set("aria-label", label); + added = true; + } + } + if (!added) { + htmlElement.setAttribute("aria-label", label); + } + } + if (id !== undefined) { + htmlElement.setAttribute("aria-owns", id); + } + if (lang !== undefined) { + htmlElement.setAttribute("lang", removeNullCharacters(lang, true)); + } + } + #addImageInTextLayer(node, element) { + const { + alt, + bbox, + children + } = node; + const child = children?.[0]; + if (!this.#rawDims || !alt || !bbox || child?.type !== "content") { + return false; + } + const { + id + } = child; + if (!id) { + return false; + } + element.setAttribute("aria-owns", id); + const img = document.createElement("span"); + (this.#elementsToAddToTextLayer ||= new Map()).set(id, img); + img.setAttribute("role", "img"); + img.setAttribute("aria-label", removeNullCharacters(alt)); + const { + pageHeight, + pageX, + pageY + } = this.#rawDims; + const calc = "calc(var(--scale-factor)*"; + const { + style + } = img; + style.width = `${calc}${bbox[2] - bbox[0]}px)`; + style.height = `${calc}${bbox[3] - bbox[1]}px)`; + style.left = `${calc}${bbox[0] - pageX}px)`; + style.top = `${calc}${pageHeight - bbox[3] + pageY}px)`; + return true; + } + addElementsToTextLayer() { + if (!this.#elementsToAddToTextLayer) { + return; + } + for (const [id, img] of this.#elementsToAddToTextLayer) { + document.getElementById(id)?.append(img); + } + this.#elementsToAddToTextLayer.clear(); + this.#elementsToAddToTextLayer = null; + } + #walk(node) { + if (!node) { + return null; + } + const element = document.createElement("span"); + if ("role" in node) { + const { + role + } = node; + const match = role.match(HEADING_PATTERN); + if (match) { + element.setAttribute("role", "heading"); + element.setAttribute("aria-level", match[1]); + } else if (PDF_ROLE_TO_HTML_ROLE[role]) { + element.setAttribute("role", PDF_ROLE_TO_HTML_ROLE[role]); + } + if (role === "Figure" && this.#addImageInTextLayer(node, element)) { + return element; + } + } + this.#setAttributes(node, element); + if (node.children) { + if (node.children.length === 1 && "id" in node.children[0]) { + this.#setAttributes(node.children[0], element); + } else { + for (const kid of node.children) { + element.append(this.#walk(kid)); + } + } + } + return element; + } +} + +;// ./web/text_accessibility.js + +class TextAccessibilityManager { + #enabled = false; + #textChildren = null; + #textNodes = new Map(); + #waitingElements = new Map(); + setTextMapping(textDivs) { + this.#textChildren = textDivs; + } + static #compareElementPositions(e1, e2) { + const rect1 = e1.getBoundingClientRect(); + const rect2 = e2.getBoundingClientRect(); + if (rect1.width === 0 && rect1.height === 0) { + return +1; + } + if (rect2.width === 0 && rect2.height === 0) { + return -1; + } + const top1 = rect1.y; + const bot1 = rect1.y + rect1.height; + const mid1 = rect1.y + rect1.height / 2; + const top2 = rect2.y; + const bot2 = rect2.y + rect2.height; + const mid2 = rect2.y + rect2.height / 2; + if (mid1 <= top2 && mid2 >= bot1) { + return -1; + } + if (mid2 <= top1 && mid1 >= bot2) { + return +1; + } + const centerX1 = rect1.x + rect1.width / 2; + const centerX2 = rect2.x + rect2.width / 2; + return centerX1 - centerX2; + } + enable() { + if (this.#enabled) { + throw new Error("TextAccessibilityManager is already enabled."); + } + if (!this.#textChildren) { + throw new Error("Text divs and strings have not been set."); + } + this.#enabled = true; + this.#textChildren = this.#textChildren.slice(); + this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions); + if (this.#textNodes.size > 0) { + const textChildren = this.#textChildren; + for (const [id, nodeIndex] of this.#textNodes) { + const element = document.getElementById(id); + if (!element) { + this.#textNodes.delete(id); + continue; + } + this.#addIdToAriaOwns(id, textChildren[nodeIndex]); + } + } + for (const [element, isRemovable] of this.#waitingElements) { + this.addPointerInTextLayer(element, isRemovable); + } + this.#waitingElements.clear(); + } + disable() { + if (!this.#enabled) { + return; + } + this.#waitingElements.clear(); + this.#textChildren = null; + this.#enabled = false; + } + removePointerInTextLayer(element) { + if (!this.#enabled) { + this.#waitingElements.delete(element); + return; + } + const children = this.#textChildren; + if (!children || children.length === 0) { + return; + } + const { + id + } = element; + const nodeIndex = this.#textNodes.get(id); + if (nodeIndex === undefined) { + return; + } + const node = children[nodeIndex]; + this.#textNodes.delete(id); + let owns = node.getAttribute("aria-owns"); + if (owns?.includes(id)) { + owns = owns.split(" ").filter(x => x !== id).join(" "); + if (owns) { + node.setAttribute("aria-owns", owns); + } else { + node.removeAttribute("aria-owns"); + node.setAttribute("role", "presentation"); + } + } + } + #addIdToAriaOwns(id, node) { + const owns = node.getAttribute("aria-owns"); + if (!owns?.includes(id)) { + node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id); + } + node.removeAttribute("role"); + } + addPointerInTextLayer(element, isRemovable) { + const { + id + } = element; + if (!id) { + return null; + } + if (!this.#enabled) { + this.#waitingElements.set(element, isRemovable); + return null; + } + if (isRemovable) { + this.removePointerInTextLayer(element); + } + const children = this.#textChildren; + if (!children || children.length === 0) { + return null; + } + const index = binarySearchFirstItem(children, node => TextAccessibilityManager.#compareElementPositions(element, node) < 0); + const nodeIndex = Math.max(0, index - 1); + const child = children[nodeIndex]; + this.#addIdToAriaOwns(id, child); + this.#textNodes.set(id, nodeIndex); + const parent = child.parentNode; + return parent?.classList.contains("markedContent") ? parent.id : null; + } + moveElementInDOM(container, element, contentElement, isRemovable) { + const id = this.addPointerInTextLayer(contentElement, isRemovable); + if (!container.hasChildNodes()) { + container.append(element); + return id; + } + const children = Array.from(container.childNodes).filter(node => node !== element); + if (children.length === 0) { + return id; + } + const elementToCompare = contentElement || element; + const index = binarySearchFirstItem(children, node => TextAccessibilityManager.#compareElementPositions(elementToCompare, node) < 0); + if (index === 0) { + children[0].before(element); + } else { + children[index - 1].after(element); + } + return id; + } +} + +;// ./web/text_highlighter.js +class TextHighlighter { + #eventAbortController = null; + constructor({ + findController, + eventBus, + pageIndex + }) { + this.findController = findController; + this.matches = []; + this.eventBus = eventBus; + this.pageIdx = pageIndex; + this.textDivs = null; + this.textContentItemsStr = null; + this.enabled = false; + } + setTextMapping(divs, texts) { + this.textDivs = divs; + this.textContentItemsStr = texts; + } + enable() { + if (!this.textDivs || !this.textContentItemsStr) { + throw new Error("Text divs and strings have not been set."); + } + if (this.enabled) { + throw new Error("TextHighlighter is already enabled."); + } + this.enabled = true; + if (!this.#eventAbortController) { + this.#eventAbortController = new AbortController(); + this.eventBus._on("updatetextlayermatches", evt => { + if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) { + this._updateMatches(); + } + }, { + signal: this.#eventAbortController.signal + }); + } + this._updateMatches(); + } + disable() { + if (!this.enabled) { + return; + } + this.enabled = false; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this._updateMatches(true); + } + _convertMatches(matches, matchesLength) { + if (!matches) { + return []; + } + const { + textContentItemsStr + } = this; + let i = 0, + iIndex = 0; + const end = textContentItemsStr.length - 1; + const result = []; + for (let m = 0, mm = matches.length; m < mm; m++) { + let matchIdx = matches[m]; + while (i !== end && matchIdx >= iIndex + textContentItemsStr[i].length) { + iIndex += textContentItemsStr[i].length; + i++; + } + if (i === textContentItemsStr.length) { + console.error("Could not find a matching mapping"); + } + const match = { + begin: { + divIdx: i, + offset: matchIdx - iIndex + } + }; + matchIdx += matchesLength[m]; + while (i !== end && matchIdx > iIndex + textContentItemsStr[i].length) { + iIndex += textContentItemsStr[i].length; + i++; + } + match.end = { + divIdx: i, + offset: matchIdx - iIndex + }; + result.push(match); + } + return result; + } + _renderMatches(matches) { + if (matches.length === 0) { + return; + } + const { + findController, + pageIdx + } = this; + const { + textContentItemsStr, + textDivs + } = this; + const isSelectedPage = pageIdx === findController.selected.pageIdx; + const selectedMatchIdx = findController.selected.matchIdx; + const highlightAll = findController.state.highlightAll; + let prevEnd = null; + const infinity = { + divIdx: -1, + offset: undefined + }; + function beginText(begin, className) { + const divIdx = begin.divIdx; + textDivs[divIdx].textContent = ""; + return appendTextToDiv(divIdx, 0, begin.offset, className); + } + function appendTextToDiv(divIdx, fromOffset, toOffset, className) { + let div = textDivs[divIdx]; + if (div.nodeType === Node.TEXT_NODE) { + const span = document.createElement("span"); + div.before(span); + span.append(div); + textDivs[divIdx] = span; + div = span; + } + const content = textContentItemsStr[divIdx].substring(fromOffset, toOffset); + const node = document.createTextNode(content); + if (className) { + const span = document.createElement("span"); + span.className = `${className} appended`; + span.append(node); + div.append(span); + if (className.includes("selected")) { + const { + left + } = span.getClientRects()[0]; + const parentLeft = div.getBoundingClientRect().left; + return left - parentLeft; + } + return 0; + } + div.append(node); + return 0; + } + let i0 = selectedMatchIdx, + i1 = i0 + 1; + if (highlightAll) { + i0 = 0; + i1 = matches.length; + } else if (!isSelectedPage) { + return; + } + let lastDivIdx = -1; + let lastOffset = -1; + for (let i = i0; i < i1; i++) { + const match = matches[i]; + const begin = match.begin; + if (begin.divIdx === lastDivIdx && begin.offset === lastOffset) { + continue; + } + lastDivIdx = begin.divIdx; + lastOffset = begin.offset; + const end = match.end; + const isSelected = isSelectedPage && i === selectedMatchIdx; + const highlightSuffix = isSelected ? " selected" : ""; + let selectedLeft = 0; + if (!prevEnd || begin.divIdx !== prevEnd.divIdx) { + if (prevEnd !== null) { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset); + } + beginText(begin); + } else { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset); + } + if (begin.divIdx === end.divIdx) { + selectedLeft = appendTextToDiv(begin.divIdx, begin.offset, end.offset, "highlight" + highlightSuffix); + } else { + selectedLeft = appendTextToDiv(begin.divIdx, begin.offset, infinity.offset, "highlight begin" + highlightSuffix); + for (let n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) { + textDivs[n0].className = "highlight middle" + highlightSuffix; + } + beginText(end, "highlight end" + highlightSuffix); + } + prevEnd = end; + if (isSelected) { + findController.scrollMatchIntoView({ + element: textDivs[begin.divIdx], + selectedLeft, + pageIndex: pageIdx, + matchIndex: selectedMatchIdx + }); + } + } + if (prevEnd) { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset); + } + } + _updateMatches(reset = false) { + if (!this.enabled && !reset) { + return; + } + const { + findController, + matches, + pageIdx + } = this; + const { + textContentItemsStr, + textDivs + } = this; + let clearedUntilDivIdx = -1; + for (const match of matches) { + const begin = Math.max(clearedUntilDivIdx, match.begin.divIdx); + for (let n = begin, end = match.end.divIdx; n <= end; n++) { + const div = textDivs[n]; + div.textContent = textContentItemsStr[n]; + div.className = ""; + } + clearedUntilDivIdx = match.end.divIdx + 1; + } + if (!findController?.highlightMatches || reset) { + return; + } + const pageMatches = findController.pageMatches[pageIdx] || null; + const pageMatchesLength = findController.pageMatchesLength[pageIdx] || null; + this.matches = this._convertMatches(pageMatches, pageMatchesLength); + this._renderMatches(this.matches); + } +} + +;// ./web/text_layer_builder.js + + +class TextLayerBuilder { + #enablePermissions = false; + #onAppend = null; + #renderingDone = false; + #textLayer = null; + static #textLayers = new Map(); + static #selectionChangeAbortController = null; + constructor({ + pdfPage, + highlighter = null, + accessibilityManager = null, + enablePermissions = false, + onAppend = null + }) { + this.pdfPage = pdfPage; + this.highlighter = highlighter; + this.accessibilityManager = accessibilityManager; + this.#enablePermissions = enablePermissions === true; + this.#onAppend = onAppend; + this.div = document.createElement("div"); + this.div.tabIndex = 0; + this.div.className = "textLayer"; + } + async render(viewport, textContentParams = null) { + if (this.#renderingDone && this.#textLayer) { + this.#textLayer.update({ + viewport, + onBefore: this.hide.bind(this) + }); + this.show(); + return; + } + this.cancel(); + this.#textLayer = new TextLayer({ + textContentSource: this.pdfPage.streamTextContent(textContentParams || { + includeMarkedContent: true, + disableNormalization: true + }), + container: this.div, + viewport + }); + const { + textDivs, + textContentItemsStr + } = this.#textLayer; + this.highlighter?.setTextMapping(textDivs, textContentItemsStr); + this.accessibilityManager?.setTextMapping(textDivs); + await this.#textLayer.render(); + this.#renderingDone = true; + const endOfContent = document.createElement("div"); + endOfContent.className = "endOfContent"; + this.div.append(endOfContent); + this.#bindMouse(endOfContent); + this.#onAppend?.(this.div); + this.highlighter?.enable(); + this.accessibilityManager?.enable(); + } + hide() { + if (!this.div.hidden && this.#renderingDone) { + this.highlighter?.disable(); + this.div.hidden = true; + } + } + show() { + if (this.div.hidden && this.#renderingDone) { + this.div.hidden = false; + this.highlighter?.enable(); + } + } + cancel() { + this.#textLayer?.cancel(); + this.#textLayer = null; + this.highlighter?.disable(); + this.accessibilityManager?.disable(); + TextLayerBuilder.#removeGlobalSelectionListener(this.div); + } + #bindMouse(end) { + const { + div + } = this; + div.addEventListener("mousedown", () => { + div.classList.add("selecting"); + }); + div.addEventListener("copy", event => { + if (!this.#enablePermissions) { + const selection = document.getSelection(); + event.clipboardData.setData("text/plain", removeNullCharacters(normalizeUnicode(selection.toString()))); + } + stopEvent(event); + }); + TextLayerBuilder.#textLayers.set(div, end); + TextLayerBuilder.#enableGlobalSelectionListener(); + } + static #removeGlobalSelectionListener(textLayerDiv) { + this.#textLayers.delete(textLayerDiv); + if (this.#textLayers.size === 0) { + this.#selectionChangeAbortController?.abort(); + this.#selectionChangeAbortController = null; + } + } + static #enableGlobalSelectionListener() { + if (this.#selectionChangeAbortController) { + return; + } + this.#selectionChangeAbortController = new AbortController(); + const { + signal + } = this.#selectionChangeAbortController; + const reset = (end, textLayer) => { + textLayer.append(end); + end.style.width = ""; + end.style.height = ""; + textLayer.classList.remove("selecting"); + }; + let isPointerDown = false; + document.addEventListener("pointerdown", () => { + isPointerDown = true; + }, { + signal + }); + document.addEventListener("pointerup", () => { + isPointerDown = false; + this.#textLayers.forEach(reset); + }, { + signal + }); + window.addEventListener("blur", () => { + isPointerDown = false; + this.#textLayers.forEach(reset); + }, { + signal + }); + document.addEventListener("keyup", () => { + if (!isPointerDown) { + this.#textLayers.forEach(reset); + } + }, { + signal + }); + var isFirefox, prevRange; + document.addEventListener("selectionchange", () => { + const selection = document.getSelection(); + if (selection.rangeCount === 0) { + this.#textLayers.forEach(reset); + return; + } + const activeTextLayers = new Set(); + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + for (const textLayerDiv of this.#textLayers.keys()) { + if (!activeTextLayers.has(textLayerDiv) && range.intersectsNode(textLayerDiv)) { + activeTextLayers.add(textLayerDiv); + } + } + } + for (const [textLayerDiv, endDiv] of this.#textLayers) { + if (activeTextLayers.has(textLayerDiv)) { + textLayerDiv.classList.add("selecting"); + } else { + reset(endDiv, textLayerDiv); + } + } + isFirefox ??= getComputedStyle(this.#textLayers.values().next().value).getPropertyValue("-moz-user-select") === "none"; + if (isFirefox) { + return; + } + const range = selection.getRangeAt(0); + const modifyStart = prevRange && (range.compareBoundaryPoints(Range.END_TO_END, prevRange) === 0 || range.compareBoundaryPoints(Range.START_TO_END, prevRange) === 0); + let anchor = modifyStart ? range.startContainer : range.endContainer; + if (anchor.nodeType === Node.TEXT_NODE) { + anchor = anchor.parentNode; + } + const parentTextLayer = anchor.parentElement?.closest(".textLayer"); + const endDiv = this.#textLayers.get(parentTextLayer); + if (endDiv) { + endDiv.style.width = parentTextLayer.style.width; + endDiv.style.height = parentTextLayer.style.height; + anchor.parentElement.insertBefore(endDiv, modifyStart ? anchor : anchor.nextSibling); + } + prevRange = range.cloneRange(); + }, { + signal + }); + } +} + +;// ./web/xfa_layer_builder.js + +class XfaLayerBuilder { + constructor({ + pdfPage, + annotationStorage = null, + linkService, + xfaHtml = null + }) { + this.pdfPage = pdfPage; + this.annotationStorage = annotationStorage; + this.linkService = linkService; + this.xfaHtml = xfaHtml; + this.div = null; + this._cancelled = false; + } + async render(viewport, intent = "display") { + if (intent === "print") { + const parameters = { + viewport: viewport.clone({ + dontFlip: true + }), + div: this.div, + xfaHtml: this.xfaHtml, + annotationStorage: this.annotationStorage, + linkService: this.linkService, + intent + }; + this.div = document.createElement("div"); + parameters.div = this.div; + return XfaLayer.render(parameters); + } + const xfaHtml = await this.pdfPage.getXfa(); + if (this._cancelled || !xfaHtml) { + return { + textDivs: [] + }; + } + const parameters = { + viewport: viewport.clone({ + dontFlip: true + }), + div: this.div, + xfaHtml, + annotationStorage: this.annotationStorage, + linkService: this.linkService, + intent + }; + if (this.div) { + return XfaLayer.update(parameters); + } + this.div = document.createElement("div"); + parameters.div = this.div; + return XfaLayer.render(parameters); + } + cancel() { + this._cancelled = true; + } + hide() { + if (!this.div) { + return; + } + this.div.hidden = true; + } +} + +;// ./web/pdf_page_view.js + + + + + + + + + + + + + +const DEFAULT_LAYER_PROPERTIES = { + annotationEditorUIManager: null, + annotationStorage: null, + downloadManager: null, + enableScripting: false, + fieldObjectsPromise: null, + findController: null, + hasJSActionsPromise: null, + get linkService() { + return new SimpleLinkService(); + } +}; +const LAYERS_ORDER = new Map([["canvasWrapper", 0], ["textLayer", 1], ["annotationLayer", 2], ["annotationEditorLayer", 3], ["xfaLayer", 3]]); +class PDFPageView { + #annotationMode = AnnotationMode.ENABLE_FORMS; + #canvasWrapper = null; + #enableHWA = false; + #hasRestrictedScaling = false; + #isEditing = false; + #layerProperties = null; + #loadingId = null; + #originalViewport = null; + #previousRotation = null; + #scaleRoundX = 1; + #scaleRoundY = 1; + #renderError = null; + #renderingState = RenderingStates.INITIAL; + #textLayerMode = TextLayerMode.ENABLE; + #useThumbnailCanvas = { + directDrawing: true, + initialOptionalContent: true, + regularAnnotations: true + }; + #layers = [null, null, null, null]; + constructor(options) { + const container = options.container; + const defaultViewport = options.defaultViewport; + this.id = options.id; + this.renderingId = "page" + this.id; + this.#layerProperties = options.layerProperties || DEFAULT_LAYER_PROPERTIES; + this.pdfPage = null; + this.pageLabel = null; + this.rotation = 0; + this.scale = options.scale || DEFAULT_SCALE; + this.viewport = defaultViewport; + this.pdfPageRotate = defaultViewport.rotation; + this._optionalContentConfigPromise = options.optionalContentConfigPromise || null; + this.#textLayerMode = options.textLayerMode ?? TextLayerMode.ENABLE; + this.#annotationMode = options.annotationMode ?? AnnotationMode.ENABLE_FORMS; + this.imageResourcesPath = options.imageResourcesPath || ""; + this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); + this.pageColors = options.pageColors || null; + this.#enableHWA = options.enableHWA || false; + this.eventBus = options.eventBus; + this.renderingQueue = options.renderingQueue; + this.l10n = options.l10n; + this.l10n ||= new genericl10n_GenericL10n(); + this.renderTask = null; + this.resume = null; + this._isStandalone = !this.renderingQueue?.hasViewer(); + this._container = container; + this._annotationCanvasMap = null; + this.annotationLayer = null; + this.annotationEditorLayer = null; + this.textLayer = null; + this.xfaLayer = null; + this.structTreeLayer = null; + this.drawLayer = null; + const div = document.createElement("div"); + div.className = "page"; + div.setAttribute("data-page-number", this.id); + div.setAttribute("role", "region"); + div.setAttribute("data-l10n-id", "pdfjs-page-landmark"); + div.setAttribute("data-l10n-args", JSON.stringify({ + page: this.id + })); + this.div = div; + this.#setDimensions(); + container?.append(div); + if (this._isStandalone) { + container?.style.setProperty("--scale-factor", this.scale * PixelsPerInch.PDF_TO_CSS_UNITS); + if (this.pageColors?.background) { + container?.style.setProperty("--page-bg-color", this.pageColors.background); + } + const { + optionalContentConfigPromise + } = options; + if (optionalContentConfigPromise) { + optionalContentConfigPromise.then(optionalContentConfig => { + if (optionalContentConfigPromise !== this._optionalContentConfigPromise) { + return; + } + this.#useThumbnailCanvas.initialOptionalContent = optionalContentConfig.hasInitialVisibility; + }); + } + if (!options.l10n) { + this.l10n.translate(this.div); + } + } + } + #addLayer(div, name) { + const pos = LAYERS_ORDER.get(name); + const oldDiv = this.#layers[pos]; + this.#layers[pos] = div; + if (oldDiv) { + oldDiv.replaceWith(div); + return; + } + for (let i = pos - 1; i >= 0; i--) { + const layer = this.#layers[i]; + if (layer) { + layer.after(div); + return; + } + } + this.div.prepend(div); + } + get renderingState() { + return this.#renderingState; + } + set renderingState(state) { + if (state === this.#renderingState) { + return; + } + this.#renderingState = state; + if (this.#loadingId) { + clearTimeout(this.#loadingId); + this.#loadingId = null; + } + switch (state) { + case RenderingStates.PAUSED: + this.div.classList.remove("loading"); + break; + case RenderingStates.RUNNING: + this.div.classList.add("loadingIcon"); + this.#loadingId = setTimeout(() => { + this.div.classList.add("loading"); + this.#loadingId = null; + }, 0); + break; + case RenderingStates.INITIAL: + case RenderingStates.FINISHED: + this.div.classList.remove("loadingIcon", "loading"); + break; + } + } + #setDimensions() { + const { + viewport + } = this; + if (this.pdfPage) { + if (this.#previousRotation === viewport.rotation) { + return; + } + this.#previousRotation = viewport.rotation; + } + setLayerDimensions(this.div, viewport, true, false); + } + setPdfPage(pdfPage) { + if (this._isStandalone && (this.pageColors?.foreground === "CanvasText" || this.pageColors?.background === "Canvas")) { + this._container?.style.setProperty("--hcm-highlight-filter", pdfPage.filterFactory.addHighlightHCMFilter("highlight", "CanvasText", "Canvas", "HighlightText", "Highlight")); + this._container?.style.setProperty("--hcm-highlight-selected-filter", pdfPage.filterFactory.addHighlightHCMFilter("highlight_selected", "CanvasText", "Canvas", "HighlightText", "Highlight")); + } + this.pdfPage = pdfPage; + this.pdfPageRotate = pdfPage.rotate; + const totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = pdfPage.getViewport({ + scale: this.scale * PixelsPerInch.PDF_TO_CSS_UNITS, + rotation: totalRotation + }); + this.#setDimensions(); + this.reset(); + } + destroy() { + this.reset(); + this.pdfPage?.cleanup(); + } + hasEditableAnnotations() { + return !!this.annotationLayer?.hasEditableAnnotations(); + } + get _textHighlighter() { + return shadow(this, "_textHighlighter", new TextHighlighter({ + pageIndex: this.id - 1, + eventBus: this.eventBus, + findController: this.#layerProperties.findController + })); + } + #dispatchLayerRendered(name, error) { + this.eventBus.dispatch(name, { + source: this, + pageNumber: this.id, + error + }); + } + async #renderAnnotationLayer() { + let error = null; + try { + await this.annotationLayer.render(this.viewport, { + structTreeLayer: this.structTreeLayer + }, "display"); + } catch (ex) { + console.error("#renderAnnotationLayer:", ex); + error = ex; + } finally { + this.#dispatchLayerRendered("annotationlayerrendered", error); + } + } + async #renderAnnotationEditorLayer() { + let error = null; + try { + await this.annotationEditorLayer.render(this.viewport, "display"); + } catch (ex) { + console.error("#renderAnnotationEditorLayer:", ex); + error = ex; + } finally { + this.#dispatchLayerRendered("annotationeditorlayerrendered", error); + } + } + async #renderDrawLayer() { + try { + await this.drawLayer.render("display"); + } catch (ex) { + console.error("#renderDrawLayer:", ex); + } + } + async #renderXfaLayer() { + let error = null; + try { + const result = await this.xfaLayer.render(this.viewport, "display"); + if (result?.textDivs && this._textHighlighter) { + this.#buildXfaTextContentItems(result.textDivs); + } + } catch (ex) { + console.error("#renderXfaLayer:", ex); + error = ex; + } finally { + if (this.xfaLayer?.div) { + this.l10n.pause(); + this.#addLayer(this.xfaLayer.div, "xfaLayer"); + this.l10n.resume(); + } + this.#dispatchLayerRendered("xfalayerrendered", error); + } + } + async #renderTextLayer() { + if (!this.textLayer) { + return; + } + let error = null; + try { + await this.textLayer.render(this.viewport); + } catch (ex) { + if (ex instanceof AbortException) { + return; + } + console.error("#renderTextLayer:", ex); + error = ex; + } + this.#dispatchLayerRendered("textlayerrendered", error); + this.#renderStructTreeLayer(); + } + async #renderStructTreeLayer() { + if (!this.textLayer) { + return; + } + const treeDom = await this.structTreeLayer?.render(); + if (treeDom) { + this.l10n.pause(); + this.structTreeLayer?.addElementsToTextLayer(); + if (this.canvas && treeDom.parentNode !== this.canvas) { + this.canvas.append(treeDom); + } + this.l10n.resume(); + } + this.structTreeLayer?.show(); + } + async #buildXfaTextContentItems(textDivs) { + const text = await this.pdfPage.getTextContent(); + const items = []; + for (const item of text.items) { + items.push(item.str); + } + this._textHighlighter.setTextMapping(textDivs, items); + this._textHighlighter.enable(); + } + #resetCanvas() { + const { + canvas + } = this; + if (!canvas) { + return; + } + canvas.remove(); + canvas.width = canvas.height = 0; + this.canvas = null; + this.#originalViewport = null; + } + reset({ + keepAnnotationLayer = false, + keepAnnotationEditorLayer = false, + keepXfaLayer = false, + keepTextLayer = false, + keepCanvasWrapper = false + } = {}) { + this.cancelRendering({ + keepAnnotationLayer, + keepAnnotationEditorLayer, + keepXfaLayer, + keepTextLayer + }); + this.renderingState = RenderingStates.INITIAL; + const div = this.div; + const childNodes = div.childNodes, + annotationLayerNode = keepAnnotationLayer && this.annotationLayer?.div || null, + annotationEditorLayerNode = keepAnnotationEditorLayer && this.annotationEditorLayer?.div || null, + xfaLayerNode = keepXfaLayer && this.xfaLayer?.div || null, + textLayerNode = keepTextLayer && this.textLayer?.div || null, + canvasWrapperNode = keepCanvasWrapper && this.#canvasWrapper || null; + for (let i = childNodes.length - 1; i >= 0; i--) { + const node = childNodes[i]; + switch (node) { + case annotationLayerNode: + case annotationEditorLayerNode: + case xfaLayerNode: + case textLayerNode: + case canvasWrapperNode: + continue; + } + node.remove(); + const layerIndex = this.#layers.indexOf(node); + if (layerIndex >= 0) { + this.#layers[layerIndex] = null; + } + } + div.removeAttribute("data-loaded"); + if (annotationLayerNode) { + this.annotationLayer.hide(); + } + if (annotationEditorLayerNode) { + this.annotationEditorLayer.hide(); + } + if (xfaLayerNode) { + this.xfaLayer.hide(); + } + if (textLayerNode) { + this.textLayer.hide(); + } + this.structTreeLayer?.hide(); + if (!keepCanvasWrapper && this.#canvasWrapper) { + this.#canvasWrapper = null; + this.#resetCanvas(); + } + } + toggleEditingMode(isEditing) { + if (!this.hasEditableAnnotations()) { + return; + } + this.#isEditing = isEditing; + this.reset({ + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + keepCanvasWrapper: true + }); + } + update({ + scale = 0, + rotation = null, + optionalContentConfigPromise = null, + drawingDelay = -1 + }) { + this.scale = scale || this.scale; + if (typeof rotation === "number") { + this.rotation = rotation; + } + if (optionalContentConfigPromise instanceof Promise) { + this._optionalContentConfigPromise = optionalContentConfigPromise; + optionalContentConfigPromise.then(optionalContentConfig => { + if (optionalContentConfigPromise !== this._optionalContentConfigPromise) { + return; + } + this.#useThumbnailCanvas.initialOptionalContent = optionalContentConfig.hasInitialVisibility; + }); + } + this.#useThumbnailCanvas.directDrawing = true; + const totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = this.viewport.clone({ + scale: this.scale * PixelsPerInch.PDF_TO_CSS_UNITS, + rotation: totalRotation + }); + this.#setDimensions(); + if (this._isStandalone) { + this._container?.style.setProperty("--scale-factor", this.viewport.scale); + } + if (this.canvas) { + let onlyCssZoom = false; + if (this.#hasRestrictedScaling) { + if (this.maxCanvasPixels === 0) { + onlyCssZoom = true; + } else if (this.maxCanvasPixels > 0) { + const { + width, + height + } = this.viewport; + const { + sx, + sy + } = this.outputScale; + onlyCssZoom = (Math.floor(width) * sx | 0) * (Math.floor(height) * sy | 0) > this.maxCanvasPixels; + } + } + const postponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; + if (postponeDrawing || onlyCssZoom) { + if (postponeDrawing && !onlyCssZoom && this.renderingState !== RenderingStates.FINISHED) { + this.cancelRendering({ + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + cancelExtraDelay: drawingDelay + }); + this.renderingState = RenderingStates.FINISHED; + this.#useThumbnailCanvas.directDrawing = false; + } + this.cssTransform({ + redrawAnnotationLayer: true, + redrawAnnotationEditorLayer: true, + redrawXfaLayer: true, + redrawTextLayer: !postponeDrawing, + hideTextLayer: postponeDrawing + }); + if (postponeDrawing) { + return; + } + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform: true, + timestamp: performance.now(), + error: this.#renderError + }); + return; + } + } + this.cssTransform({}); + this.reset({ + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + keepCanvasWrapper: true + }); + } + cancelRendering({ + keepAnnotationLayer = false, + keepAnnotationEditorLayer = false, + keepXfaLayer = false, + keepTextLayer = false, + cancelExtraDelay = 0 + } = {}) { + if (this.renderTask) { + this.renderTask.cancel(cancelExtraDelay); + this.renderTask = null; + } + this.resume = null; + if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) { + this.textLayer.cancel(); + this.textLayer = null; + } + if (this.annotationLayer && (!keepAnnotationLayer || !this.annotationLayer.div)) { + this.annotationLayer.cancel(); + this.annotationLayer = null; + this._annotationCanvasMap = null; + } + if (this.structTreeLayer && !this.textLayer) { + this.structTreeLayer = null; + } + if (this.annotationEditorLayer && (!keepAnnotationEditorLayer || !this.annotationEditorLayer.div)) { + if (this.drawLayer) { + this.drawLayer.cancel(); + this.drawLayer = null; + } + this.annotationEditorLayer.cancel(); + this.annotationEditorLayer = null; + } + if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) { + this.xfaLayer.cancel(); + this.xfaLayer = null; + this._textHighlighter?.disable(); + } + } + cssTransform({ + redrawAnnotationLayer = false, + redrawAnnotationEditorLayer = false, + redrawXfaLayer = false, + redrawTextLayer = false, + hideTextLayer = false + }) { + const { + canvas + } = this; + if (!canvas) { + return; + } + const originalViewport = this.#originalViewport; + if (this.viewport !== originalViewport) { + const relativeRotation = (360 + this.viewport.rotation - originalViewport.rotation) % 360; + if (relativeRotation === 90 || relativeRotation === 270) { + const { + width, + height + } = this.viewport; + const scaleX = height / width; + const scaleY = width / height; + canvas.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX},${scaleY})`; + } else { + canvas.style.transform = relativeRotation === 0 ? "" : `rotate(${relativeRotation}deg)`; + } + } + if (redrawAnnotationLayer && this.annotationLayer) { + this.#renderAnnotationLayer(); + } + if (redrawAnnotationEditorLayer && this.annotationEditorLayer) { + if (this.drawLayer) { + this.#renderDrawLayer(); + } + this.#renderAnnotationEditorLayer(); + } + if (redrawXfaLayer && this.xfaLayer) { + this.#renderXfaLayer(); + } + if (this.textLayer) { + if (hideTextLayer) { + this.textLayer.hide(); + this.structTreeLayer?.hide(); + } else if (redrawTextLayer) { + this.#renderTextLayer(); + } + } + } + get width() { + return this.viewport.width; + } + get height() { + return this.viewport.height; + } + getPagePoint(x, y) { + return this.viewport.convertToPdfPoint(x, y); + } + async #finishRenderTask(renderTask, error = null) { + if (renderTask === this.renderTask) { + this.renderTask = null; + } + if (error instanceof RenderingCancelledException) { + this.#renderError = null; + return; + } + this.#renderError = error; + this.renderingState = RenderingStates.FINISHED; + this.#useThumbnailCanvas.regularAnnotations = !renderTask.separateAnnots; + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform: false, + timestamp: performance.now(), + error: this.#renderError + }); + if (error) { + throw error; + } + } + async draw() { + if (this.renderingState !== RenderingStates.INITIAL) { + console.error("Must be in new state before drawing"); + this.reset(); + } + const { + div, + l10n, + pageColors, + pdfPage, + viewport + } = this; + if (!pdfPage) { + this.renderingState = RenderingStates.FINISHED; + throw new Error("pdfPage is not loaded"); + } + this.renderingState = RenderingStates.RUNNING; + let canvasWrapper = this.#canvasWrapper; + if (!canvasWrapper) { + canvasWrapper = this.#canvasWrapper = document.createElement("div"); + canvasWrapper.classList.add("canvasWrapper"); + this.#addLayer(canvasWrapper, "canvasWrapper"); + } + if (!this.textLayer && this.#textLayerMode !== TextLayerMode.DISABLE && !pdfPage.isPureXfa) { + this._accessibilityManager ||= new TextAccessibilityManager(); + this.textLayer = new TextLayerBuilder({ + pdfPage, + highlighter: this._textHighlighter, + accessibilityManager: this._accessibilityManager, + enablePermissions: this.#textLayerMode === TextLayerMode.ENABLE_PERMISSIONS, + onAppend: textLayerDiv => { + this.l10n.pause(); + this.#addLayer(textLayerDiv, "textLayer"); + this.l10n.resume(); + } + }); + } + if (!this.annotationLayer && this.#annotationMode !== AnnotationMode.DISABLE) { + const { + annotationStorage, + annotationEditorUIManager, + downloadManager, + enableScripting, + fieldObjectsPromise, + hasJSActionsPromise, + linkService + } = this.#layerProperties; + this._annotationCanvasMap ||= new Map(); + this.annotationLayer = new AnnotationLayerBuilder({ + pdfPage, + annotationStorage, + imageResourcesPath: this.imageResourcesPath, + renderForms: this.#annotationMode === AnnotationMode.ENABLE_FORMS, + linkService, + downloadManager, + enableScripting, + hasJSActionsPromise, + fieldObjectsPromise, + annotationCanvasMap: this._annotationCanvasMap, + accessibilityManager: this._accessibilityManager, + annotationEditorUIManager, + onAppend: annotationLayerDiv => { + this.#addLayer(annotationLayerDiv, "annotationLayer"); + } + }); + } + const renderContinueCallback = cont => { + showCanvas?.(false); + if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) { + this.renderingState = RenderingStates.PAUSED; + this.resume = () => { + this.renderingState = RenderingStates.RUNNING; + cont(); + }; + return; + } + cont(); + }; + const { + width, + height + } = viewport; + const canvas = document.createElement("canvas"); + canvas.setAttribute("role", "presentation"); + const hasHCM = !!(pageColors?.background && pageColors?.foreground); + const prevCanvas = this.canvas; + const updateOnFirstShow = !prevCanvas && !hasHCM; + this.canvas = canvas; + this.#originalViewport = viewport; + let showCanvas = isLastShow => { + if (updateOnFirstShow) { + canvasWrapper.prepend(canvas); + showCanvas = null; + return; + } + if (!isLastShow) { + return; + } + if (prevCanvas) { + prevCanvas.replaceWith(canvas); + prevCanvas.width = prevCanvas.height = 0; + } else { + canvasWrapper.prepend(canvas); + } + showCanvas = null; + }; + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !this.#enableHWA + }); + const outputScale = this.outputScale = new OutputScale(); + if (this.maxCanvasPixels === 0) { + const invScale = 1 / this.scale; + outputScale.sx *= invScale; + outputScale.sy *= invScale; + this.#hasRestrictedScaling = true; + } else if (this.maxCanvasPixels > 0) { + const pixelsInViewport = width * height; + const maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); + if (outputScale.sx > maxScale || outputScale.sy > maxScale) { + outputScale.sx = maxScale; + outputScale.sy = maxScale; + this.#hasRestrictedScaling = true; + } else { + this.#hasRestrictedScaling = false; + } + } + const sfx = approximateFraction(outputScale.sx); + const sfy = approximateFraction(outputScale.sy); + const canvasWidth = canvas.width = floorToDivide(calcRound(width * outputScale.sx), sfx[0]); + const canvasHeight = canvas.height = floorToDivide(calcRound(height * outputScale.sy), sfy[0]); + const pageWidth = floorToDivide(calcRound(width), sfx[1]); + const pageHeight = floorToDivide(calcRound(height), sfy[1]); + outputScale.sx = canvasWidth / pageWidth; + outputScale.sy = canvasHeight / pageHeight; + if (this.#scaleRoundX !== sfx[1]) { + div.style.setProperty("--scale-round-x", `${sfx[1]}px`); + this.#scaleRoundX = sfx[1]; + } + if (this.#scaleRoundY !== sfy[1]) { + div.style.setProperty("--scale-round-y", `${sfy[1]}px`); + this.#scaleRoundY = sfy[1]; + } + const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; + const renderContext = { + canvasContext: ctx, + transform, + viewport, + annotationMode: this.#annotationMode, + optionalContentConfigPromise: this._optionalContentConfigPromise, + annotationCanvasMap: this._annotationCanvasMap, + pageColors, + isEditing: this.#isEditing + }; + const renderTask = this.renderTask = pdfPage.render(renderContext); + renderTask.onContinue = renderContinueCallback; + const resultPromise = renderTask.promise.then(async () => { + showCanvas?.(true); + await this.#finishRenderTask(renderTask); + this.structTreeLayer ||= new StructTreeLayerBuilder(pdfPage, viewport.rawDims); + this.#renderTextLayer(); + if (this.annotationLayer) { + await this.#renderAnnotationLayer(); + } + const { + annotationEditorUIManager + } = this.#layerProperties; + if (!annotationEditorUIManager) { + return; + } + this.drawLayer ||= new DrawLayerBuilder({ + pageIndex: this.id + }); + await this.#renderDrawLayer(); + this.drawLayer.setParent(canvasWrapper); + this.annotationEditorLayer ||= new AnnotationEditorLayerBuilder({ + uiManager: annotationEditorUIManager, + pdfPage, + l10n, + structTreeLayer: this.structTreeLayer, + accessibilityManager: this._accessibilityManager, + annotationLayer: this.annotationLayer?.annotationLayer, + textLayer: this.textLayer, + drawLayer: this.drawLayer.getDrawLayer(), + onAppend: annotationEditorLayerDiv => { + this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); + } + }); + this.#renderAnnotationEditorLayer(); + }, error => { + if (!(error instanceof RenderingCancelledException)) { + showCanvas?.(true); + } else { + prevCanvas?.remove(); + this.#resetCanvas(); + } + return this.#finishRenderTask(renderTask, error); + }); + if (pdfPage.isPureXfa) { + if (!this.xfaLayer) { + const { + annotationStorage, + linkService + } = this.#layerProperties; + this.xfaLayer = new XfaLayerBuilder({ + pdfPage, + annotationStorage, + linkService + }); + } + this.#renderXfaLayer(); + } + div.setAttribute("data-loaded", true); + this.eventBus.dispatch("pagerender", { + source: this, + pageNumber: this.id + }); + return resultPromise; + } + setPageLabel(label) { + this.pageLabel = typeof label === "string" ? label : null; + this.div.setAttribute("data-l10n-args", JSON.stringify({ + page: this.pageLabel ?? this.id + })); + if (this.pageLabel !== null) { + this.div.setAttribute("data-page-label", this.pageLabel); + } else { + this.div.removeAttribute("data-page-label"); + } + } + get thumbnailCanvas() { + const { + directDrawing, + initialOptionalContent, + regularAnnotations + } = this.#useThumbnailCanvas; + return directDrawing && initialOptionalContent && regularAnnotations ? this.canvas : null; + } +} + +;// ./web/generic_scripting.js + +async function docProperties(pdfDocument) { + const url = "", + baseUrl = url.split("#", 1)[0]; + let { + info, + metadata, + contentDispositionFilename, + contentLength + } = await pdfDocument.getMetadata(); + if (!contentLength) { + const { + length + } = await pdfDocument.getDownloadInfo(); + contentLength = length; + } + return { + ...info, + baseURL: baseUrl, + filesize: contentLength, + filename: contentDispositionFilename || getPdfFilenameFromUrl(url), + metadata: metadata?.getRaw(), + authors: metadata?.get("dc:creator"), + numPages: pdfDocument.numPages, + URL: url + }; +} +class GenericScripting { + constructor(sandboxBundleSrc) { + this._ready = new Promise((resolve, reject) => { + const sandbox = import(/*webpackIgnore: true*/sandboxBundleSrc); + sandbox.then(pdfjsSandbox => { + resolve(pdfjsSandbox.QuickJSSandbox()); + }).catch(reject); + }); + } + async createSandbox(data) { + const sandbox = await this._ready; + sandbox.create(data); + } + async dispatchEventInSandbox(event) { + const sandbox = await this._ready; + setTimeout(() => sandbox.dispatchEvent(event), 0); + } + async destroySandbox() { + const sandbox = await this._ready; + sandbox.nukeSandbox(); + } +} + +;// ./web/pdf_scripting_manager.js + + +class PDFScriptingManager { + #closeCapability = null; + #destroyCapability = null; + #docProperties = null; + #eventAbortController = null; + #eventBus = null; + #externalServices = null; + #pdfDocument = null; + #pdfViewer = null; + #ready = false; + #scripting = null; + #willPrintCapability = null; + constructor({ + eventBus, + externalServices = null, + docProperties = null + }) { + this.#eventBus = eventBus; + this.#externalServices = externalServices; + this.#docProperties = docProperties; + } + setViewer(pdfViewer) { + this.#pdfViewer = pdfViewer; + } + async setDocument(pdfDocument) { + if (this.#pdfDocument) { + await this.#destroyScripting(); + } + this.#pdfDocument = pdfDocument; + if (!pdfDocument) { + return; + } + const [objects, calculationOrder, docActions] = await Promise.all([pdfDocument.getFieldObjects(), pdfDocument.getCalculationOrderIds(), pdfDocument.getJSActions()]); + if (!objects && !docActions) { + await this.#destroyScripting(); + return; + } + if (pdfDocument !== this.#pdfDocument) { + return; + } + try { + this.#scripting = this.#initScripting(); + } catch (error) { + console.error("setDocument:", error); + await this.#destroyScripting(); + return; + } + const eventBus = this.#eventBus; + this.#eventAbortController = new AbortController(); + const { + signal + } = this.#eventAbortController; + eventBus._on("updatefromsandbox", event => { + if (event?.source === window) { + this.#updateFromSandbox(event.detail); + } + }, { + signal + }); + eventBus._on("dispatcheventinsandbox", event => { + this.#scripting?.dispatchEventInSandbox(event.detail); + }, { + signal + }); + eventBus._on("pagechanging", ({ + pageNumber, + previous + }) => { + if (pageNumber === previous) { + return; + } + this.#dispatchPageClose(previous); + this.#dispatchPageOpen(pageNumber); + }, { + signal + }); + eventBus._on("pagerendered", ({ + pageNumber + }) => { + if (!this._pageOpenPending.has(pageNumber)) { + return; + } + if (pageNumber !== this.#pdfViewer.currentPageNumber) { + return; + } + this.#dispatchPageOpen(pageNumber); + }, { + signal + }); + eventBus._on("pagesdestroy", async () => { + await this.#dispatchPageClose(this.#pdfViewer.currentPageNumber); + await this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "WillClose" + }); + this.#closeCapability?.resolve(); + }, { + signal + }); + try { + const docProperties = await this.#docProperties(pdfDocument); + if (pdfDocument !== this.#pdfDocument) { + return; + } + await this.#scripting.createSandbox({ + objects, + calculationOrder, + appInfo: { + platform: navigator.platform, + language: navigator.language + }, + docInfo: { + ...docProperties, + actions: docActions + } + }); + eventBus.dispatch("sandboxcreated", { + source: this + }); + } catch (error) { + console.error("setDocument:", error); + await this.#destroyScripting(); + return; + } + await this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "Open" + }); + await this.#dispatchPageOpen(this.#pdfViewer.currentPageNumber, true); + Promise.resolve().then(() => { + if (pdfDocument === this.#pdfDocument) { + this.#ready = true; + } + }); + } + async dispatchWillSave() { + return this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "WillSave" + }); + } + async dispatchDidSave() { + return this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "DidSave" + }); + } + async dispatchWillPrint() { + if (!this.#scripting) { + return; + } + await this.#willPrintCapability?.promise; + this.#willPrintCapability = Promise.withResolvers(); + try { + await this.#scripting.dispatchEventInSandbox({ + id: "doc", + name: "WillPrint" + }); + } catch (ex) { + this.#willPrintCapability.resolve(); + this.#willPrintCapability = null; + throw ex; + } + await this.#willPrintCapability.promise; + } + async dispatchDidPrint() { + return this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "DidPrint" + }); + } + get destroyPromise() { + return this.#destroyCapability?.promise || null; + } + get ready() { + return this.#ready; + } + get _pageOpenPending() { + return shadow(this, "_pageOpenPending", new Set()); + } + get _visitedPages() { + return shadow(this, "_visitedPages", new Map()); + } + async #updateFromSandbox(detail) { + const pdfViewer = this.#pdfViewer; + const isInPresentationMode = pdfViewer.isInPresentationMode || pdfViewer.isChangingPresentationMode; + const { + id, + siblings, + command, + value + } = detail; + if (!id) { + switch (command) { + case "clear": + console.clear(); + break; + case "error": + console.error(value); + break; + case "layout": + if (!isInPresentationMode) { + const modes = apiPageLayoutToViewerModes(value); + pdfViewer.spreadMode = modes.spreadMode; + } + break; + case "page-num": + pdfViewer.currentPageNumber = value + 1; + break; + case "print": + await pdfViewer.pagesPromise; + this.#eventBus.dispatch("print", { + source: this + }); + break; + case "println": + console.log(value); + break; + case "zoom": + if (!isInPresentationMode) { + pdfViewer.currentScaleValue = value; + } + break; + case "SaveAs": + this.#eventBus.dispatch("download", { + source: this + }); + break; + case "FirstPage": + pdfViewer.currentPageNumber = 1; + break; + case "LastPage": + pdfViewer.currentPageNumber = pdfViewer.pagesCount; + break; + case "NextPage": + pdfViewer.nextPage(); + break; + case "PrevPage": + pdfViewer.previousPage(); + break; + case "ZoomViewIn": + if (!isInPresentationMode) { + pdfViewer.increaseScale(); + } + break; + case "ZoomViewOut": + if (!isInPresentationMode) { + pdfViewer.decreaseScale(); + } + break; + case "WillPrintFinished": + this.#willPrintCapability?.resolve(); + this.#willPrintCapability = null; + break; + } + return; + } + if (isInPresentationMode && detail.focus) { + return; + } + delete detail.id; + delete detail.siblings; + const ids = siblings ? [id, ...siblings] : [id]; + for (const elementId of ids) { + const element = document.querySelector(`[data-element-id="${elementId}"]`); + if (element) { + element.dispatchEvent(new CustomEvent("updatefromsandbox", { + detail + })); + } else { + this.#pdfDocument?.annotationStorage.setValue(elementId, detail); + } + } + } + async #dispatchPageOpen(pageNumber, initialize = false) { + const pdfDocument = this.#pdfDocument, + visitedPages = this._visitedPages; + if (initialize) { + this.#closeCapability = Promise.withResolvers(); + } + if (!this.#closeCapability) { + return; + } + const pageView = this.#pdfViewer.getPageView(pageNumber - 1); + if (pageView?.renderingState !== RenderingStates.FINISHED) { + this._pageOpenPending.add(pageNumber); + return; + } + this._pageOpenPending.delete(pageNumber); + const actionsPromise = (async () => { + const actions = await (!visitedPages.has(pageNumber) ? pageView.pdfPage?.getJSActions() : null); + if (pdfDocument !== this.#pdfDocument) { + return; + } + await this.#scripting?.dispatchEventInSandbox({ + id: "page", + name: "PageOpen", + pageNumber, + actions + }); + })(); + visitedPages.set(pageNumber, actionsPromise); + } + async #dispatchPageClose(pageNumber) { + const pdfDocument = this.#pdfDocument, + visitedPages = this._visitedPages; + if (!this.#closeCapability) { + return; + } + if (this._pageOpenPending.has(pageNumber)) { + return; + } + const actionsPromise = visitedPages.get(pageNumber); + if (!actionsPromise) { + return; + } + visitedPages.set(pageNumber, null); + await actionsPromise; + if (pdfDocument !== this.#pdfDocument) { + return; + } + await this.#scripting?.dispatchEventInSandbox({ + id: "page", + name: "PageClose", + pageNumber + }); + } + #initScripting() { + this.#destroyCapability = Promise.withResolvers(); + if (this.#scripting) { + throw new Error("#initScripting: Scripting already exists."); + } + return this.#externalServices.createScripting(); + } + async #destroyScripting() { + if (!this.#scripting) { + this.#pdfDocument = null; + this.#destroyCapability?.resolve(); + return; + } + if (this.#closeCapability) { + await Promise.race([this.#closeCapability.promise, new Promise(resolve => { + setTimeout(resolve, 1000); + })]).catch(() => {}); + this.#closeCapability = null; + } + this.#pdfDocument = null; + try { + await this.#scripting.destroySandbox(); + } catch {} + this.#willPrintCapability?.reject(new Error("Scripting destroyed.")); + this.#willPrintCapability = null; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this._pageOpenPending.clear(); + this._visitedPages.clear(); + this.#scripting = null; + this.#ready = false; + this.#destroyCapability?.resolve(); + } +} + +;// ./web/pdf_scripting_manager.component.js + + +class PDFScriptingManagerComponents extends PDFScriptingManager { + constructor(options) { + if (!options.externalServices) { + window.addEventListener("updatefromsandbox", event => { + options.eventBus.dispatch("updatefromsandbox", { + source: window, + detail: event.detail + }); + }); + } + options.externalServices ||= { + createScripting: () => new GenericScripting(options.sandboxBundleSrc) + }; + options.docProperties ||= pdfDocument => docProperties(pdfDocument); + super(options); + } +} + +;// ./web/pdf_rendering_queue.js + + +const CLEANUP_TIMEOUT = 30000; +class PDFRenderingQueue { + constructor() { + this.pdfViewer = null; + this.pdfThumbnailViewer = null; + this.onIdle = null; + this.highestPriorityPage = null; + this.idleTimeout = null; + this.printing = false; + this.isThumbnailViewEnabled = false; + Object.defineProperty(this, "hasViewer", { + value: () => !!this.pdfViewer + }); + } + setViewer(pdfViewer) { + this.pdfViewer = pdfViewer; + } + setThumbnailViewer(pdfThumbnailViewer) { + this.pdfThumbnailViewer = pdfThumbnailViewer; + } + isHighestPriority(view) { + return this.highestPriorityPage === view.renderingId; + } + renderHighestPriority(currentlyVisiblePages) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + if (this.pdfViewer.forceRendering(currentlyVisiblePages)) { + return; + } + if (this.isThumbnailViewEnabled && this.pdfThumbnailViewer?.forceRendering()) { + return; + } + if (this.printing) { + return; + } + if (this.onIdle) { + this.idleTimeout = setTimeout(this.onIdle.bind(this), CLEANUP_TIMEOUT); + } + } + getHighestPriority(visible, views, scrolledDown, preRenderExtra = false) { + const visibleViews = visible.views, + numVisible = visibleViews.length; + if (numVisible === 0) { + return null; + } + for (let i = 0; i < numVisible; i++) { + const view = visibleViews[i].view; + if (!this.isViewFinished(view)) { + return view; + } + } + const firstId = visible.first.id, + lastId = visible.last.id; + if (lastId - firstId + 1 > numVisible) { + const visibleIds = visible.ids; + for (let i = 1, ii = lastId - firstId; i < ii; i++) { + const holeId = scrolledDown ? firstId + i : lastId - i; + if (visibleIds.has(holeId)) { + continue; + } + const holeView = views[holeId - 1]; + if (!this.isViewFinished(holeView)) { + return holeView; + } + } + } + let preRenderIndex = scrolledDown ? lastId : firstId - 2; + let preRenderView = views[preRenderIndex]; + if (preRenderView && !this.isViewFinished(preRenderView)) { + return preRenderView; + } + if (preRenderExtra) { + preRenderIndex += scrolledDown ? 1 : -1; + preRenderView = views[preRenderIndex]; + if (preRenderView && !this.isViewFinished(preRenderView)) { + return preRenderView; + } + } + return null; + } + isViewFinished(view) { + return view.renderingState === RenderingStates.FINISHED; + } + renderView(view) { + switch (view.renderingState) { + case RenderingStates.FINISHED: + return false; + case RenderingStates.PAUSED: + this.highestPriorityPage = view.renderingId; + view.resume(); + break; + case RenderingStates.RUNNING: + this.highestPriorityPage = view.renderingId; + break; + case RenderingStates.INITIAL: + this.highestPriorityPage = view.renderingId; + view.draw().finally(() => { + this.renderHighestPriority(); + }).catch(reason => { + if (reason instanceof RenderingCancelledException) { + return; + } + console.error("renderView:", reason); + }); + break; + } + return true; + } +} + +;// ./web/pdf_viewer.js + + + + + + +const DEFAULT_CACHE_SIZE = 10; +const PagesCountLimit = { + FORCE_SCROLL_MODE_PAGE: 10000, + FORCE_LAZY_PAGE_INIT: 5000, + PAUSE_EAGER_PAGE_INIT: 250 +}; +function isValidAnnotationEditorMode(mode) { + return Object.values(AnnotationEditorType).includes(mode) && mode !== AnnotationEditorType.DISABLE; +} +class PDFPageViewBuffer { + #buf = new Set(); + #size = 0; + constructor(size) { + this.#size = size; + } + push(view) { + const buf = this.#buf; + if (buf.has(view)) { + buf.delete(view); + } + buf.add(view); + if (buf.size > this.#size) { + this.#destroyFirstView(); + } + } + resize(newSize, idsToKeep = null) { + this.#size = newSize; + const buf = this.#buf; + if (idsToKeep) { + const ii = buf.size; + let i = 1; + for (const view of buf) { + if (idsToKeep.has(view.id)) { + buf.delete(view); + buf.add(view); + } + if (++i > ii) { + break; + } + } + } + while (buf.size > this.#size) { + this.#destroyFirstView(); + } + } + has(view) { + return this.#buf.has(view); + } + [Symbol.iterator]() { + return this.#buf.keys(); + } + #destroyFirstView() { + const firstView = this.#buf.keys().next().value; + firstView?.destroy(); + this.#buf.delete(firstView); + } +} +class PDFViewer { + #buffer = null; + #altTextManager = null; + #annotationEditorHighlightColors = null; + #annotationEditorMode = AnnotationEditorType.NONE; + #annotationEditorUIManager = null; + #annotationMode = AnnotationMode.ENABLE_FORMS; + #containerTopLeft = null; + #editorUndoBar = null; + #enableHWA = false; + #enableHighlightFloatingButton = false; + #enablePermissions = false; + #enableUpdatedAddImage = false; + #enableNewAltTextWhenAddingImage = false; + #eventAbortController = null; + #mlManager = null; + #switchAnnotationEditorModeAC = null; + #switchAnnotationEditorModeTimeoutId = null; + #getAllTextInProgress = false; + #hiddenCopyElement = null; + #interruptCopyCondition = false; + #previousContainerHeight = 0; + #resizeObserver = new ResizeObserver(this.#resizeObserverCallback.bind(this)); + #scrollModePageState = null; + #scaleTimeoutId = null; + #supportsPinchToZoom = true; + #textLayerMode = TextLayerMode.ENABLE; + constructor(options) { + const viewerVersion = "4.10.38"; + if (version !== viewerVersion) { + throw new Error(`The API version "${version}" does not match the Viewer version "${viewerVersion}".`); + } + this.container = options.container; + this.viewer = options.viewer || options.container.firstElementChild; + if (this.container?.tagName !== "DIV" || this.viewer?.tagName !== "DIV") { + throw new Error("Invalid `container` and/or `viewer` option."); + } + if (this.container.offsetParent && getComputedStyle(this.container).position !== "absolute") { + throw new Error("The `container` must be absolutely positioned."); + } + this.#resizeObserver.observe(this.container); + this.eventBus = options.eventBus; + this.linkService = options.linkService || new SimpleLinkService(); + this.downloadManager = options.downloadManager || null; + this.findController = options.findController || null; + this.#altTextManager = options.altTextManager || null; + this.#editorUndoBar = options.editorUndoBar || null; + if (this.findController) { + this.findController.onIsPageVisible = pageNumber => this._getVisiblePages().ids.has(pageNumber); + } + this._scriptingManager = options.scriptingManager || null; + this.#textLayerMode = options.textLayerMode ?? TextLayerMode.ENABLE; + this.#annotationMode = options.annotationMode ?? AnnotationMode.ENABLE_FORMS; + this.#annotationEditorMode = options.annotationEditorMode ?? AnnotationEditorType.NONE; + this.#annotationEditorHighlightColors = options.annotationEditorHighlightColors || null; + this.#enableHighlightFloatingButton = options.enableHighlightFloatingButton === true; + this.#enableUpdatedAddImage = options.enableUpdatedAddImage === true; + this.#enableNewAltTextWhenAddingImage = options.enableNewAltTextWhenAddingImage === true; + this.imageResourcesPath = options.imageResourcesPath || ""; + this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; + this.removePageBorders = options.removePageBorders || false; + this.maxCanvasPixels = options.maxCanvasPixels; + this.l10n = options.l10n; + this.l10n ||= new genericl10n_GenericL10n(); + this.#enablePermissions = options.enablePermissions || false; + this.pageColors = options.pageColors || null; + this.#mlManager = options.mlManager || null; + this.#enableHWA = options.enableHWA || false; + this.#supportsPinchToZoom = options.supportsPinchToZoom !== false; + this.defaultRenderingQueue = !options.renderingQueue; + if (this.defaultRenderingQueue) { + this.renderingQueue = new PDFRenderingQueue(); + this.renderingQueue.setViewer(this); + } else { + this.renderingQueue = options.renderingQueue; + } + const { + abortSignal + } = options; + abortSignal?.addEventListener("abort", () => { + this.#resizeObserver.disconnect(); + this.#resizeObserver = null; + }, { + once: true + }); + this.scroll = watchScroll(this.container, this._scrollUpdate.bind(this), abortSignal); + this.presentationModeState = PresentationModeState.UNKNOWN; + this._resetView(); + if (this.removePageBorders) { + this.viewer.classList.add("removePageBorders"); + } + this.#updateContainerHeightCss(); + this.eventBus._on("thumbnailrendered", ({ + pageNumber, + pdfPage + }) => { + const pageView = this._pages[pageNumber - 1]; + if (!this.#buffer.has(pageView)) { + pdfPage?.cleanup(); + } + }); + if (!options.l10n) { + this.l10n.translate(this.container); + } + } + get pagesCount() { + return this._pages.length; + } + getPageView(index) { + return this._pages[index]; + } + getCachedPageViews() { + return new Set(this.#buffer); + } + get pageViewsReady() { + return this._pages.every(pageView => pageView?.pdfPage); + } + get renderForms() { + return this.#annotationMode === AnnotationMode.ENABLE_FORMS; + } + get enableScripting() { + return !!this._scriptingManager; + } + get currentPageNumber() { + return this._currentPageNumber; + } + set currentPageNumber(val) { + if (!Number.isInteger(val)) { + throw new Error("Invalid page number."); + } + if (!this.pdfDocument) { + return; + } + if (!this._setCurrentPageNumber(val, true)) { + console.error(`currentPageNumber: "${val}" is not a valid page.`); + } + } + _setCurrentPageNumber(val, resetCurrentPageView = false) { + if (this._currentPageNumber === val) { + if (resetCurrentPageView) { + this.#resetCurrentPageView(); + } + return true; + } + if (!(0 < val && val <= this.pagesCount)) { + return false; + } + const previous = this._currentPageNumber; + this._currentPageNumber = val; + this.eventBus.dispatch("pagechanging", { + source: this, + pageNumber: val, + pageLabel: this._pageLabels?.[val - 1] ?? null, + previous + }); + if (resetCurrentPageView) { + this.#resetCurrentPageView(); + } + return true; + } + get currentPageLabel() { + return this._pageLabels?.[this._currentPageNumber - 1] ?? null; + } + set currentPageLabel(val) { + if (!this.pdfDocument) { + return; + } + let page = val | 0; + if (this._pageLabels) { + const i = this._pageLabels.indexOf(val); + if (i >= 0) { + page = i + 1; + } + } + if (!this._setCurrentPageNumber(page, true)) { + console.error(`currentPageLabel: "${val}" is not a valid page.`); + } + } + get currentScale() { + return this._currentScale !== UNKNOWN_SCALE ? this._currentScale : DEFAULT_SCALE; + } + set currentScale(val) { + if (isNaN(val)) { + throw new Error("Invalid numeric scale."); + } + if (!this.pdfDocument) { + return; + } + this.#setScale(val, { + noScroll: false + }); + } + get currentScaleValue() { + return this._currentScaleValue; + } + set currentScaleValue(val) { + if (!this.pdfDocument) { + return; + } + this.#setScale(val, { + noScroll: false + }); + } + get pagesRotation() { + return this._pagesRotation; + } + set pagesRotation(rotation) { + if (!isValidRotation(rotation)) { + throw new Error("Invalid pages rotation angle."); + } + if (!this.pdfDocument) { + return; + } + rotation %= 360; + if (rotation < 0) { + rotation += 360; + } + if (this._pagesRotation === rotation) { + return; + } + this._pagesRotation = rotation; + const pageNumber = this._currentPageNumber; + this.refresh(true, { + rotation + }); + if (this._currentScaleValue) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this.eventBus.dispatch("rotationchanging", { + source: this, + pagesRotation: rotation, + pageNumber + }); + if (this.defaultRenderingQueue) { + this.update(); + } + } + get firstPagePromise() { + return this.pdfDocument ? this._firstPageCapability.promise : null; + } + get onePageRendered() { + return this.pdfDocument ? this._onePageRenderedCapability.promise : null; + } + get pagesPromise() { + return this.pdfDocument ? this._pagesCapability.promise : null; + } + get _layerProperties() { + const self = this; + return shadow(this, "_layerProperties", { + get annotationEditorUIManager() { + return self.#annotationEditorUIManager; + }, + get annotationStorage() { + return self.pdfDocument?.annotationStorage; + }, + get downloadManager() { + return self.downloadManager; + }, + get enableScripting() { + return !!self._scriptingManager; + }, + get fieldObjectsPromise() { + return self.pdfDocument?.getFieldObjects(); + }, + get findController() { + return self.findController; + }, + get hasJSActionsPromise() { + return self.pdfDocument?.hasJSActions(); + }, + get linkService() { + return self.linkService; + } + }); + } + #initializePermissions(permissions) { + const params = { + annotationEditorMode: this.#annotationEditorMode, + annotationMode: this.#annotationMode, + textLayerMode: this.#textLayerMode + }; + if (!permissions) { + return params; + } + if (!permissions.includes(PermissionFlag.COPY) && this.#textLayerMode === TextLayerMode.ENABLE) { + params.textLayerMode = TextLayerMode.ENABLE_PERMISSIONS; + } + if (!permissions.includes(PermissionFlag.MODIFY_CONTENTS)) { + params.annotationEditorMode = AnnotationEditorType.DISABLE; + } + if (!permissions.includes(PermissionFlag.MODIFY_ANNOTATIONS) && !permissions.includes(PermissionFlag.FILL_INTERACTIVE_FORMS) && this.#annotationMode === AnnotationMode.ENABLE_FORMS) { + params.annotationMode = AnnotationMode.ENABLE; + } + return params; + } + async #onePageRenderedOrForceFetch(signal) { + if (document.visibilityState === "hidden" || !this.container.offsetParent || this._getVisiblePages().views.length === 0) { + return; + } + const hiddenCapability = Promise.withResolvers(), + ac = new AbortController(); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + hiddenCapability.resolve(); + } + }, { + signal: typeof AbortSignal.any === "function" ? AbortSignal.any([signal, ac.signal]) : signal + }); + await Promise.race([this._onePageRenderedCapability.promise, hiddenCapability.promise]); + ac.abort(); + } + async getAllText() { + const texts = []; + const buffer = []; + for (let pageNum = 1, pagesCount = this.pdfDocument.numPages; pageNum <= pagesCount; ++pageNum) { + if (this.#interruptCopyCondition) { + return null; + } + buffer.length = 0; + const page = await this.pdfDocument.getPage(pageNum); + const { + items + } = await page.getTextContent(); + for (const item of items) { + if (item.str) { + buffer.push(item.str); + } + if (item.hasEOL) { + buffer.push("\n"); + } + } + texts.push(removeNullCharacters(buffer.join(""))); + } + return texts.join("\n"); + } + #copyCallback(textLayerMode, event) { + const selection = document.getSelection(); + const { + focusNode, + anchorNode + } = selection; + if (anchorNode && focusNode && selection.containsNode(this.#hiddenCopyElement)) { + if (this.#getAllTextInProgress || textLayerMode === TextLayerMode.ENABLE_PERMISSIONS) { + stopEvent(event); + return; + } + this.#getAllTextInProgress = true; + const { + classList + } = this.viewer; + classList.add("copyAll"); + const ac = new AbortController(); + window.addEventListener("keydown", ev => this.#interruptCopyCondition = ev.key === "Escape", { + signal: ac.signal + }); + this.getAllText().then(async text => { + if (text !== null) { + await navigator.clipboard.writeText(text); + } + }).catch(reason => { + console.warn(`Something goes wrong when extracting the text: ${reason.message}`); + }).finally(() => { + this.#getAllTextInProgress = false; + this.#interruptCopyCondition = false; + ac.abort(); + classList.remove("copyAll"); + }); + stopEvent(event); + } + } + setDocument(pdfDocument) { + if (this.pdfDocument) { + this.eventBus.dispatch("pagesdestroy", { + source: this + }); + this._cancelRendering(); + this._resetView(); + this.findController?.setDocument(null); + this._scriptingManager?.setDocument(null); + this.#annotationEditorUIManager?.destroy(); + this.#annotationEditorUIManager = null; + } + this.pdfDocument = pdfDocument; + if (!pdfDocument) { + return; + } + const pagesCount = pdfDocument.numPages; + const firstPagePromise = pdfDocument.getPage(1); + const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({ + intent: "display" + }); + const permissionsPromise = this.#enablePermissions ? pdfDocument.getPermissions() : Promise.resolve(); + const { + eventBus, + pageColors, + viewer + } = this; + this.#eventAbortController = new AbortController(); + const { + signal + } = this.#eventAbortController; + if (pagesCount > PagesCountLimit.FORCE_SCROLL_MODE_PAGE) { + console.warn("Forcing PAGE-scrolling for performance reasons, given the length of the document."); + const mode = this._scrollMode = ScrollMode.PAGE; + eventBus.dispatch("scrollmodechanged", { + source: this, + mode + }); + } + this._pagesCapability.promise.then(() => { + eventBus.dispatch("pagesloaded", { + source: this, + pagesCount + }); + }, () => {}); + const onBeforeDraw = evt => { + const pageView = this._pages[evt.pageNumber - 1]; + if (!pageView) { + return; + } + this.#buffer.push(pageView); + }; + eventBus._on("pagerender", onBeforeDraw, { + signal + }); + const onAfterDraw = evt => { + if (evt.cssTransform) { + return; + } + this._onePageRenderedCapability.resolve({ + timestamp: evt.timestamp + }); + eventBus._off("pagerendered", onAfterDraw); + }; + eventBus._on("pagerendered", onAfterDraw, { + signal + }); + Promise.all([firstPagePromise, permissionsPromise]).then(([firstPdfPage, permissions]) => { + if (pdfDocument !== this.pdfDocument) { + return; + } + this._firstPageCapability.resolve(firstPdfPage); + this._optionalContentConfigPromise = optionalContentConfigPromise; + const { + annotationEditorMode, + annotationMode, + textLayerMode + } = this.#initializePermissions(permissions); + if (textLayerMode !== TextLayerMode.DISABLE) { + const element = this.#hiddenCopyElement = document.createElement("div"); + element.id = "hiddenCopyElement"; + viewer.before(element); + } + if (typeof AbortSignal.any === "function" && annotationEditorMode !== AnnotationEditorType.DISABLE) { + const mode = annotationEditorMode; + if (pdfDocument.isPureXfa) { + console.warn("Warning: XFA-editing is not implemented."); + } else if (isValidAnnotationEditorMode(mode)) { + this.#annotationEditorUIManager = new AnnotationEditorUIManager(this.container, viewer, this.#altTextManager, eventBus, pdfDocument, pageColors, this.#annotationEditorHighlightColors, this.#enableHighlightFloatingButton, this.#enableUpdatedAddImage, this.#enableNewAltTextWhenAddingImage, this.#mlManager, this.#editorUndoBar, this.#supportsPinchToZoom); + eventBus.dispatch("annotationeditoruimanager", { + source: this, + uiManager: this.#annotationEditorUIManager + }); + if (mode !== AnnotationEditorType.NONE) { + if (mode === AnnotationEditorType.STAMP) { + this.#mlManager?.loadModel("altText"); + } + this.#annotationEditorUIManager.updateMode(mode); + } + } else { + console.error(`Invalid AnnotationEditor mode: ${mode}`); + } + } + const viewerElement = this._scrollMode === ScrollMode.PAGE ? null : viewer; + const scale = this.currentScale; + const viewport = firstPdfPage.getViewport({ + scale: scale * PixelsPerInch.PDF_TO_CSS_UNITS + }); + viewer.style.setProperty("--scale-factor", viewport.scale); + if (pageColors?.background) { + viewer.style.setProperty("--page-bg-color", pageColors.background); + } + if (pageColors?.foreground === "CanvasText" || pageColors?.background === "Canvas") { + viewer.style.setProperty("--hcm-highlight-filter", pdfDocument.filterFactory.addHighlightHCMFilter("highlight", "CanvasText", "Canvas", "HighlightText", "Highlight")); + viewer.style.setProperty("--hcm-highlight-selected-filter", pdfDocument.filterFactory.addHighlightHCMFilter("highlight_selected", "CanvasText", "Canvas", "HighlightText", "ButtonText")); + } + for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { + const pageView = new PDFPageView({ + container: viewerElement, + eventBus, + id: pageNum, + scale, + defaultViewport: viewport.clone(), + optionalContentConfigPromise, + renderingQueue: this.renderingQueue, + textLayerMode, + annotationMode, + imageResourcesPath: this.imageResourcesPath, + maxCanvasPixels: this.maxCanvasPixels, + pageColors, + l10n: this.l10n, + layerProperties: this._layerProperties, + enableHWA: this.#enableHWA + }); + this._pages.push(pageView); + } + this._pages[0]?.setPdfPage(firstPdfPage); + if (this._scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + } else if (this._spreadMode !== SpreadMode.NONE) { + this._updateSpreadMode(); + } + this.#onePageRenderedOrForceFetch(signal).then(async () => { + if (pdfDocument !== this.pdfDocument) { + return; + } + this.findController?.setDocument(pdfDocument); + this._scriptingManager?.setDocument(pdfDocument); + if (this.#hiddenCopyElement) { + document.addEventListener("copy", this.#copyCallback.bind(this, textLayerMode), { + signal + }); + } + if (this.#annotationEditorUIManager) { + eventBus.dispatch("annotationeditormodechanged", { + source: this, + mode: this.#annotationEditorMode + }); + } + if (pdfDocument.loadingParams.disableAutoFetch || pagesCount > PagesCountLimit.FORCE_LAZY_PAGE_INIT) { + this._pagesCapability.resolve(); + return; + } + let getPagesLeft = pagesCount - 1; + if (getPagesLeft <= 0) { + this._pagesCapability.resolve(); + return; + } + for (let pageNum = 2; pageNum <= pagesCount; ++pageNum) { + const promise = pdfDocument.getPage(pageNum).then(pdfPage => { + const pageView = this._pages[pageNum - 1]; + if (!pageView.pdfPage) { + pageView.setPdfPage(pdfPage); + } + if (--getPagesLeft === 0) { + this._pagesCapability.resolve(); + } + }, reason => { + console.error(`Unable to get page ${pageNum} to initialize viewer`, reason); + if (--getPagesLeft === 0) { + this._pagesCapability.resolve(); + } + }); + if (pageNum % PagesCountLimit.PAUSE_EAGER_PAGE_INIT === 0) { + await promise; + } + } + }); + eventBus.dispatch("pagesinit", { + source: this + }); + pdfDocument.getMetadata().then(({ + info + }) => { + if (pdfDocument !== this.pdfDocument) { + return; + } + if (info.Language) { + viewer.lang = info.Language; + } + }); + if (this.defaultRenderingQueue) { + this.update(); + } + }).catch(reason => { + console.error("Unable to initialize viewer", reason); + this._pagesCapability.reject(reason); + }); + } + setPageLabels(labels) { + if (!this.pdfDocument) { + return; + } + if (!labels) { + this._pageLabels = null; + } else if (!(Array.isArray(labels) && this.pdfDocument.numPages === labels.length)) { + this._pageLabels = null; + console.error(`setPageLabels: Invalid page labels.`); + } else { + this._pageLabels = labels; + } + for (let i = 0, ii = this._pages.length; i < ii; i++) { + this._pages[i].setPageLabel(this._pageLabels?.[i] ?? null); + } + } + _resetView() { + this._pages = []; + this._currentPageNumber = 1; + this._currentScale = UNKNOWN_SCALE; + this._currentScaleValue = null; + this._pageLabels = null; + this.#buffer = new PDFPageViewBuffer(DEFAULT_CACHE_SIZE); + this._location = null; + this._pagesRotation = 0; + this._optionalContentConfigPromise = null; + this._firstPageCapability = Promise.withResolvers(); + this._onePageRenderedCapability = Promise.withResolvers(); + this._pagesCapability = Promise.withResolvers(); + this._scrollMode = ScrollMode.VERTICAL; + this._previousScrollMode = ScrollMode.UNKNOWN; + this._spreadMode = SpreadMode.NONE; + this.#scrollModePageState = { + previousPageNumber: 1, + scrollDown: true, + pages: [] + }; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this.viewer.textContent = ""; + this._updateScrollMode(); + this.viewer.removeAttribute("lang"); + this.#hiddenCopyElement?.remove(); + this.#hiddenCopyElement = null; + this.#cleanupSwitchAnnotationEditorMode(); + } + #ensurePageViewVisible() { + if (this._scrollMode !== ScrollMode.PAGE) { + throw new Error("#ensurePageViewVisible: Invalid scrollMode value."); + } + const pageNumber = this._currentPageNumber, + state = this.#scrollModePageState, + viewer = this.viewer; + viewer.textContent = ""; + state.pages.length = 0; + if (this._spreadMode === SpreadMode.NONE && !this.isInPresentationMode) { + const pageView = this._pages[pageNumber - 1]; + viewer.append(pageView.div); + state.pages.push(pageView); + } else { + const pageIndexSet = new Set(), + parity = this._spreadMode - 1; + if (parity === -1) { + pageIndexSet.add(pageNumber - 1); + } else if (pageNumber % 2 !== parity) { + pageIndexSet.add(pageNumber - 1); + pageIndexSet.add(pageNumber); + } else { + pageIndexSet.add(pageNumber - 2); + pageIndexSet.add(pageNumber - 1); + } + const spread = document.createElement("div"); + spread.className = "spread"; + if (this.isInPresentationMode) { + const dummyPage = document.createElement("div"); + dummyPage.className = "dummyPage"; + spread.append(dummyPage); + } + for (const i of pageIndexSet) { + const pageView = this._pages[i]; + if (!pageView) { + continue; + } + spread.append(pageView.div); + state.pages.push(pageView); + } + viewer.append(spread); + } + state.scrollDown = pageNumber >= state.previousPageNumber; + state.previousPageNumber = pageNumber; + } + _scrollUpdate() { + if (this.pagesCount === 0) { + return; + } + this.update(); + } + #scrollIntoView(pageView, pageSpot = null) { + const { + div, + id + } = pageView; + if (this._currentPageNumber !== id) { + this._setCurrentPageNumber(id); + } + if (this._scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + this.update(); + } + if (!pageSpot && !this.isInPresentationMode) { + const left = div.offsetLeft + div.clientLeft, + right = left + div.clientWidth; + const { + scrollLeft, + clientWidth + } = this.container; + if (this._scrollMode === ScrollMode.HORIZONTAL || left < scrollLeft || right > scrollLeft + clientWidth) { + pageSpot = { + left: 0, + top: 0 + }; + } + } + scrollIntoView(div, pageSpot); + if (!this._currentScaleValue && this._location) { + this._location = null; + } + } + #isSameScale(newScale) { + return newScale === this._currentScale || Math.abs(newScale - this._currentScale) < 1e-15; + } + #setScaleUpdatePages(newScale, newValue, { + noScroll = false, + preset = false, + drawingDelay = -1, + origin = null + }) { + this._currentScaleValue = newValue.toString(); + if (this.#isSameScale(newScale)) { + if (preset) { + this.eventBus.dispatch("scalechanging", { + source: this, + scale: newScale, + presetValue: newValue + }); + } + return; + } + this.viewer.style.setProperty("--scale-factor", newScale * PixelsPerInch.PDF_TO_CSS_UNITS); + const postponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; + this.refresh(true, { + scale: newScale, + drawingDelay: postponeDrawing ? drawingDelay : -1 + }); + if (postponeDrawing) { + this.#scaleTimeoutId = setTimeout(() => { + this.#scaleTimeoutId = null; + this.refresh(); + }, drawingDelay); + } + const previousScale = this._currentScale; + this._currentScale = newScale; + if (!noScroll) { + let page = this._currentPageNumber, + dest; + if (this._location && !(this.isInPresentationMode || this.isChangingPresentationMode)) { + page = this._location.pageNumber; + dest = [null, { + name: "XYZ" + }, this._location.left, this._location.top, null]; + } + this.scrollPageIntoView({ + pageNumber: page, + destArray: dest, + allowNegativeOffset: true + }); + if (Array.isArray(origin)) { + const scaleDiff = newScale / previousScale - 1; + const [top, left] = this.containerTopLeft; + this.container.scrollLeft += (origin[0] - left) * scaleDiff; + this.container.scrollTop += (origin[1] - top) * scaleDiff; + } + } + this.eventBus.dispatch("scalechanging", { + source: this, + scale: newScale, + presetValue: preset ? newValue : undefined + }); + if (this.defaultRenderingQueue) { + this.update(); + } + } + get #pageWidthScaleFactor() { + if (this._spreadMode !== SpreadMode.NONE && this._scrollMode !== ScrollMode.HORIZONTAL) { + return 2; + } + return 1; + } + #setScale(value, options) { + let scale = parseFloat(value); + if (scale > 0) { + options.preset = false; + this.#setScaleUpdatePages(scale, value, options); + } else { + const currentPage = this._pages[this._currentPageNumber - 1]; + if (!currentPage) { + return; + } + let hPadding = SCROLLBAR_PADDING, + vPadding = VERTICAL_PADDING; + if (this.isInPresentationMode) { + hPadding = vPadding = 4; + if (this._spreadMode !== SpreadMode.NONE) { + hPadding *= 2; + } + } else if (this.removePageBorders) { + hPadding = vPadding = 0; + } else if (this._scrollMode === ScrollMode.HORIZONTAL) { + [hPadding, vPadding] = [vPadding, hPadding]; + } + const pageWidthScale = (this.container.clientWidth - hPadding) / currentPage.width * currentPage.scale / this.#pageWidthScaleFactor; + const pageHeightScale = (this.container.clientHeight - vPadding) / currentPage.height * currentPage.scale; + switch (value) { + case "page-actual": + scale = 1; + break; + case "page-width": + scale = pageWidthScale; + break; + case "page-height": + scale = pageHeightScale; + break; + case "page-fit": + scale = Math.min(pageWidthScale, pageHeightScale); + break; + case "auto": + const horizontalScale = isPortraitOrientation(currentPage) ? pageWidthScale : Math.min(pageHeightScale, pageWidthScale); + scale = Math.min(MAX_AUTO_SCALE, horizontalScale); + break; + default: + console.error(`#setScale: "${value}" is an unknown zoom value.`); + return; + } + options.preset = true; + this.#setScaleUpdatePages(scale, value, options); + } + } + #resetCurrentPageView() { + const pageView = this._pages[this._currentPageNumber - 1]; + if (this.isInPresentationMode) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this.#scrollIntoView(pageView); + } + pageLabelToPageNumber(label) { + if (!this._pageLabels) { + return null; + } + const i = this._pageLabels.indexOf(label); + if (i < 0) { + return null; + } + return i + 1; + } + scrollPageIntoView({ + pageNumber, + destArray = null, + allowNegativeOffset = false, + ignoreDestinationZoom = false + }) { + if (!this.pdfDocument) { + return; + } + const pageView = Number.isInteger(pageNumber) && this._pages[pageNumber - 1]; + if (!pageView) { + console.error(`scrollPageIntoView: "${pageNumber}" is not a valid pageNumber parameter.`); + return; + } + if (this.isInPresentationMode || !destArray) { + this._setCurrentPageNumber(pageNumber, true); + return; + } + let x = 0, + y = 0; + let width = 0, + height = 0, + widthScale, + heightScale; + const changeOrientation = pageView.rotation % 180 !== 0; + const pageWidth = (changeOrientation ? pageView.height : pageView.width) / pageView.scale / PixelsPerInch.PDF_TO_CSS_UNITS; + const pageHeight = (changeOrientation ? pageView.width : pageView.height) / pageView.scale / PixelsPerInch.PDF_TO_CSS_UNITS; + let scale = 0; + switch (destArray[1].name) { + case "XYZ": + x = destArray[2]; + y = destArray[3]; + scale = destArray[4]; + x = x !== null ? x : 0; + y = y !== null ? y : pageHeight; + break; + case "Fit": + case "FitB": + scale = "page-fit"; + break; + case "FitH": + case "FitBH": + y = destArray[2]; + scale = "page-width"; + if (y === null && this._location) { + x = this._location.left; + y = this._location.top; + } else if (typeof y !== "number" || y < 0) { + y = pageHeight; + } + break; + case "FitV": + case "FitBV": + x = destArray[2]; + width = pageWidth; + height = pageHeight; + scale = "page-height"; + break; + case "FitR": + x = destArray[2]; + y = destArray[3]; + width = destArray[4] - x; + height = destArray[5] - y; + let hPadding = SCROLLBAR_PADDING, + vPadding = VERTICAL_PADDING; + if (this.removePageBorders) { + hPadding = vPadding = 0; + } + widthScale = (this.container.clientWidth - hPadding) / width / PixelsPerInch.PDF_TO_CSS_UNITS; + heightScale = (this.container.clientHeight - vPadding) / height / PixelsPerInch.PDF_TO_CSS_UNITS; + scale = Math.min(Math.abs(widthScale), Math.abs(heightScale)); + break; + default: + console.error(`scrollPageIntoView: "${destArray[1].name}" is not a valid destination type.`); + return; + } + if (!ignoreDestinationZoom) { + if (scale && scale !== this._currentScale) { + this.currentScaleValue = scale; + } else if (this._currentScale === UNKNOWN_SCALE) { + this.currentScaleValue = DEFAULT_SCALE_VALUE; + } + } + if (scale === "page-fit" && !destArray[4]) { + this.#scrollIntoView(pageView); + return; + } + const boundingRect = [pageView.viewport.convertToViewportPoint(x, y), pageView.viewport.convertToViewportPoint(x + width, y + height)]; + let left = Math.min(boundingRect[0][0], boundingRect[1][0]); + let top = Math.min(boundingRect[0][1], boundingRect[1][1]); + if (!allowNegativeOffset) { + left = Math.max(left, 0); + top = Math.max(top, 0); + } + this.#scrollIntoView(pageView, { + left, + top + }); + } + _updateLocation(firstPage) { + const currentScale = this._currentScale; + const currentScaleValue = this._currentScaleValue; + const normalizedScaleValue = parseFloat(currentScaleValue) === currentScale ? Math.round(currentScale * 10000) / 100 : currentScaleValue; + const pageNumber = firstPage.id; + const currentPageView = this._pages[pageNumber - 1]; + const container = this.container; + const topLeft = currentPageView.getPagePoint(container.scrollLeft - firstPage.x, container.scrollTop - firstPage.y); + const intLeft = Math.round(topLeft[0]); + const intTop = Math.round(topLeft[1]); + let pdfOpenParams = `#page=${pageNumber}`; + if (!this.isInPresentationMode) { + pdfOpenParams += `&zoom=${normalizedScaleValue},${intLeft},${intTop}`; + } + this._location = { + pageNumber, + scale: normalizedScaleValue, + top: intTop, + left: intLeft, + rotation: this._pagesRotation, + pdfOpenParams + }; + } + update() { + const visible = this._getVisiblePages(); + const visiblePages = visible.views, + numVisiblePages = visiblePages.length; + if (numVisiblePages === 0) { + return; + } + const newCacheSize = Math.max(DEFAULT_CACHE_SIZE, 2 * numVisiblePages + 1); + this.#buffer.resize(newCacheSize, visible.ids); + this.renderingQueue.renderHighestPriority(visible); + const isSimpleLayout = this._spreadMode === SpreadMode.NONE && (this._scrollMode === ScrollMode.PAGE || this._scrollMode === ScrollMode.VERTICAL); + const currentId = this._currentPageNumber; + let stillFullyVisible = false; + for (const page of visiblePages) { + if (page.percent < 100) { + break; + } + if (page.id === currentId && isSimpleLayout) { + stillFullyVisible = true; + break; + } + } + this._setCurrentPageNumber(stillFullyVisible ? currentId : visiblePages[0].id); + this._updateLocation(visible.first); + this.eventBus.dispatch("updateviewarea", { + source: this, + location: this._location + }); + } + #switchToEditAnnotationMode() { + const visible = this._getVisiblePages(); + const pagesToRefresh = []; + const { + ids, + views + } = visible; + for (const page of views) { + const { + view + } = page; + if (!view.hasEditableAnnotations()) { + ids.delete(view.id); + continue; + } + pagesToRefresh.push(page); + } + if (pagesToRefresh.length === 0) { + return null; + } + this.renderingQueue.renderHighestPriority({ + first: pagesToRefresh[0], + last: pagesToRefresh.at(-1), + views: pagesToRefresh, + ids + }); + return ids; + } + containsElement(element) { + return this.container.contains(element); + } + focus() { + this.container.focus(); + } + get _isContainerRtl() { + return getComputedStyle(this.container).direction === "rtl"; + } + get isInPresentationMode() { + return this.presentationModeState === PresentationModeState.FULLSCREEN; + } + get isChangingPresentationMode() { + return this.presentationModeState === PresentationModeState.CHANGING; + } + get isHorizontalScrollbarEnabled() { + return this.isInPresentationMode ? false : this.container.scrollWidth > this.container.clientWidth; + } + get isVerticalScrollbarEnabled() { + return this.isInPresentationMode ? false : this.container.scrollHeight > this.container.clientHeight; + } + _getVisiblePages() { + const views = this._scrollMode === ScrollMode.PAGE ? this.#scrollModePageState.pages : this._pages, + horizontal = this._scrollMode === ScrollMode.HORIZONTAL, + rtl = horizontal && this._isContainerRtl; + return getVisibleElements({ + scrollEl: this.container, + views, + sortByVisibility: true, + horizontal, + rtl + }); + } + cleanup() { + for (const pageView of this._pages) { + if (pageView.renderingState !== RenderingStates.FINISHED) { + pageView.reset(); + } + } + } + _cancelRendering() { + for (const pageView of this._pages) { + pageView.cancelRendering(); + } + } + async #ensurePdfPageLoaded(pageView) { + if (pageView.pdfPage) { + return pageView.pdfPage; + } + try { + const pdfPage = await this.pdfDocument.getPage(pageView.id); + if (!pageView.pdfPage) { + pageView.setPdfPage(pdfPage); + } + return pdfPage; + } catch (reason) { + console.error("Unable to get page for page view", reason); + return null; + } + } + #getScrollAhead(visible) { + if (visible.first?.id === 1) { + return true; + } else if (visible.last?.id === this.pagesCount) { + return false; + } + switch (this._scrollMode) { + case ScrollMode.PAGE: + return this.#scrollModePageState.scrollDown; + case ScrollMode.HORIZONTAL: + return this.scroll.right; + } + return this.scroll.down; + } + forceRendering(currentlyVisiblePages) { + const visiblePages = currentlyVisiblePages || this._getVisiblePages(); + const scrollAhead = this.#getScrollAhead(visiblePages); + const preRenderExtra = this._spreadMode !== SpreadMode.NONE && this._scrollMode !== ScrollMode.HORIZONTAL; + const pageView = this.renderingQueue.getHighestPriority(visiblePages, this._pages, scrollAhead, preRenderExtra); + if (pageView) { + this.#ensurePdfPageLoaded(pageView).then(() => { + this.renderingQueue.renderView(pageView); + }); + return true; + } + return false; + } + get hasEqualPageSizes() { + const firstPageView = this._pages[0]; + for (let i = 1, ii = this._pages.length; i < ii; ++i) { + const pageView = this._pages[i]; + if (pageView.width !== firstPageView.width || pageView.height !== firstPageView.height) { + return false; + } + } + return true; + } + getPagesOverview() { + let initialOrientation; + return this._pages.map(pageView => { + const viewport = pageView.pdfPage.getViewport({ + scale: 1 + }); + const orientation = isPortraitOrientation(viewport); + if (initialOrientation === undefined) { + initialOrientation = orientation; + } else if (this.enablePrintAutoRotate && orientation !== initialOrientation) { + return { + width: viewport.height, + height: viewport.width, + rotation: (viewport.rotation - 90) % 360 + }; + } + return { + width: viewport.width, + height: viewport.height, + rotation: viewport.rotation + }; + }); + } + get optionalContentConfigPromise() { + if (!this.pdfDocument) { + return Promise.resolve(null); + } + if (!this._optionalContentConfigPromise) { + console.error("optionalContentConfigPromise: Not initialized yet."); + return this.pdfDocument.getOptionalContentConfig({ + intent: "display" + }); + } + return this._optionalContentConfigPromise; + } + set optionalContentConfigPromise(promise) { + if (!(promise instanceof Promise)) { + throw new Error(`Invalid optionalContentConfigPromise: ${promise}`); + } + if (!this.pdfDocument) { + return; + } + if (!this._optionalContentConfigPromise) { + return; + } + this._optionalContentConfigPromise = promise; + this.refresh(false, { + optionalContentConfigPromise: promise + }); + this.eventBus.dispatch("optionalcontentconfigchanged", { + source: this, + promise + }); + } + get scrollMode() { + return this._scrollMode; + } + set scrollMode(mode) { + if (this._scrollMode === mode) { + return; + } + if (!isValidScrollMode(mode)) { + throw new Error(`Invalid scroll mode: ${mode}`); + } + if (this.pagesCount > PagesCountLimit.FORCE_SCROLL_MODE_PAGE) { + return; + } + this._previousScrollMode = this._scrollMode; + this._scrollMode = mode; + this.eventBus.dispatch("scrollmodechanged", { + source: this, + mode + }); + this._updateScrollMode(this._currentPageNumber); + } + _updateScrollMode(pageNumber = null) { + const scrollMode = this._scrollMode, + viewer = this.viewer; + viewer.classList.toggle("scrollHorizontal", scrollMode === ScrollMode.HORIZONTAL); + viewer.classList.toggle("scrollWrapped", scrollMode === ScrollMode.WRAPPED); + if (!this.pdfDocument || !pageNumber) { + return; + } + if (scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + } else if (this._previousScrollMode === ScrollMode.PAGE) { + this._updateSpreadMode(); + } + if (this._currentScaleValue && isNaN(this._currentScaleValue)) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this._setCurrentPageNumber(pageNumber, true); + this.update(); + } + get spreadMode() { + return this._spreadMode; + } + set spreadMode(mode) { + if (this._spreadMode === mode) { + return; + } + if (!isValidSpreadMode(mode)) { + throw new Error(`Invalid spread mode: ${mode}`); + } + this._spreadMode = mode; + this.eventBus.dispatch("spreadmodechanged", { + source: this, + mode + }); + this._updateSpreadMode(this._currentPageNumber); + } + _updateSpreadMode(pageNumber = null) { + if (!this.pdfDocument) { + return; + } + const viewer = this.viewer, + pages = this._pages; + if (this._scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + } else { + viewer.textContent = ""; + if (this._spreadMode === SpreadMode.NONE) { + for (const pageView of this._pages) { + viewer.append(pageView.div); + } + } else { + const parity = this._spreadMode - 1; + let spread = null; + for (let i = 0, ii = pages.length; i < ii; ++i) { + if (spread === null) { + spread = document.createElement("div"); + spread.className = "spread"; + viewer.append(spread); + } else if (i % 2 === parity) { + spread = spread.cloneNode(false); + viewer.append(spread); + } + spread.append(pages[i].div); + } + } + } + if (!pageNumber) { + return; + } + if (this._currentScaleValue && isNaN(this._currentScaleValue)) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this._setCurrentPageNumber(pageNumber, true); + this.update(); + } + _getPageAdvance(currentPageNumber, previous = false) { + switch (this._scrollMode) { + case ScrollMode.WRAPPED: + { + const { + views + } = this._getVisiblePages(), + pageLayout = new Map(); + for (const { + id, + y, + percent, + widthPercent + } of views) { + if (percent === 0 || widthPercent < 100) { + continue; + } + let yArray = pageLayout.get(y); + if (!yArray) { + pageLayout.set(y, yArray ||= []); + } + yArray.push(id); + } + for (const yArray of pageLayout.values()) { + const currentIndex = yArray.indexOf(currentPageNumber); + if (currentIndex === -1) { + continue; + } + const numPages = yArray.length; + if (numPages === 1) { + break; + } + if (previous) { + for (let i = currentIndex - 1, ii = 0; i >= ii; i--) { + const currentId = yArray[i], + expectedId = yArray[i + 1] - 1; + if (currentId < expectedId) { + return currentPageNumber - expectedId; + } + } + } else { + for (let i = currentIndex + 1, ii = numPages; i < ii; i++) { + const currentId = yArray[i], + expectedId = yArray[i - 1] + 1; + if (currentId > expectedId) { + return expectedId - currentPageNumber; + } + } + } + if (previous) { + const firstId = yArray[0]; + if (firstId < currentPageNumber) { + return currentPageNumber - firstId + 1; + } + } else { + const lastId = yArray[numPages - 1]; + if (lastId > currentPageNumber) { + return lastId - currentPageNumber + 1; + } + } + break; + } + break; + } + case ScrollMode.HORIZONTAL: + { + break; + } + case ScrollMode.PAGE: + case ScrollMode.VERTICAL: + { + if (this._spreadMode === SpreadMode.NONE) { + break; + } + const parity = this._spreadMode - 1; + if (previous && currentPageNumber % 2 !== parity) { + break; + } else if (!previous && currentPageNumber % 2 === parity) { + break; + } + const { + views + } = this._getVisiblePages(), + expectedId = previous ? currentPageNumber - 1 : currentPageNumber + 1; + for (const { + id, + percent, + widthPercent + } of views) { + if (id !== expectedId) { + continue; + } + if (percent > 0 && widthPercent === 100) { + return 2; + } + break; + } + break; + } + } + return 1; + } + nextPage() { + const currentPageNumber = this._currentPageNumber, + pagesCount = this.pagesCount; + if (currentPageNumber >= pagesCount) { + return false; + } + const advance = this._getPageAdvance(currentPageNumber, false) || 1; + this.currentPageNumber = Math.min(currentPageNumber + advance, pagesCount); + return true; + } + previousPage() { + const currentPageNumber = this._currentPageNumber; + if (currentPageNumber <= 1) { + return false; + } + const advance = this._getPageAdvance(currentPageNumber, true) || 1; + this.currentPageNumber = Math.max(currentPageNumber - advance, 1); + return true; + } + updateScale({ + drawingDelay, + scaleFactor = null, + steps = null, + origin + }) { + if (steps === null && scaleFactor === null) { + throw new Error("Invalid updateScale options: either `steps` or `scaleFactor` must be provided."); + } + if (!this.pdfDocument) { + return; + } + let newScale = this._currentScale; + if (scaleFactor > 0 && scaleFactor !== 1) { + newScale = Math.round(newScale * scaleFactor * 100) / 100; + } else if (steps) { + const delta = steps > 0 ? DEFAULT_SCALE_DELTA : 1 / DEFAULT_SCALE_DELTA; + const round = steps > 0 ? Math.ceil : Math.floor; + steps = Math.abs(steps); + do { + newScale = round((newScale * delta).toFixed(2) * 10) / 10; + } while (--steps > 0); + } + newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); + this.#setScale(newScale, { + noScroll: false, + drawingDelay, + origin + }); + } + increaseScale(options = {}) { + this.updateScale({ + ...options, + steps: options.steps ?? 1 + }); + } + decreaseScale(options = {}) { + this.updateScale({ + ...options, + steps: -(options.steps ?? 1) + }); + } + #updateContainerHeightCss(height = this.container.clientHeight) { + if (height !== this.#previousContainerHeight) { + this.#previousContainerHeight = height; + docStyle.setProperty("--viewer-container-height", `${height}px`); + } + } + #resizeObserverCallback(entries) { + for (const entry of entries) { + if (entry.target === this.container) { + this.#updateContainerHeightCss(Math.floor(entry.borderBoxSize[0].blockSize)); + this.#containerTopLeft = null; + break; + } + } + } + get containerTopLeft() { + return this.#containerTopLeft ||= [this.container.offsetTop, this.container.offsetLeft]; + } + #cleanupSwitchAnnotationEditorMode() { + this.#switchAnnotationEditorModeAC?.abort(); + this.#switchAnnotationEditorModeAC = null; + if (this.#switchAnnotationEditorModeTimeoutId !== null) { + clearTimeout(this.#switchAnnotationEditorModeTimeoutId); + this.#switchAnnotationEditorModeTimeoutId = null; + } + } + get annotationEditorMode() { + return this.#annotationEditorUIManager ? this.#annotationEditorMode : AnnotationEditorType.DISABLE; + } + set annotationEditorMode({ + mode, + editId = null, + isFromKeyboard = false + }) { + if (!this.#annotationEditorUIManager) { + throw new Error(`The AnnotationEditor is not enabled.`); + } + if (this.#annotationEditorMode === mode) { + return; + } + if (!isValidAnnotationEditorMode(mode)) { + throw new Error(`Invalid AnnotationEditor mode: ${mode}`); + } + if (!this.pdfDocument) { + return; + } + if (mode === AnnotationEditorType.STAMP) { + this.#mlManager?.loadModel("altText"); + } + const { + eventBus + } = this; + const updater = () => { + this.#cleanupSwitchAnnotationEditorMode(); + this.#annotationEditorMode = mode; + this.#annotationEditorUIManager.updateMode(mode, editId, isFromKeyboard); + eventBus.dispatch("annotationeditormodechanged", { + source: this, + mode + }); + }; + if (mode === AnnotationEditorType.NONE || this.#annotationEditorMode === AnnotationEditorType.NONE) { + const isEditing = mode !== AnnotationEditorType.NONE; + if (!isEditing) { + this.pdfDocument.annotationStorage.resetModifiedIds(); + } + for (const pageView of this._pages) { + pageView.toggleEditingMode(isEditing); + } + const idsToRefresh = this.#switchToEditAnnotationMode(); + if (isEditing && idsToRefresh) { + this.#cleanupSwitchAnnotationEditorMode(); + this.#switchAnnotationEditorModeAC = new AbortController(); + const signal = AbortSignal.any([this.#eventAbortController.signal, this.#switchAnnotationEditorModeAC.signal]); + eventBus._on("pagerendered", ({ + pageNumber + }) => { + idsToRefresh.delete(pageNumber); + if (idsToRefresh.size === 0) { + this.#switchAnnotationEditorModeTimeoutId = setTimeout(updater, 0); + } + }, { + signal + }); + return; + } + } + updater(); + } + refresh(noUpdate = false, updateArgs = Object.create(null)) { + if (!this.pdfDocument) { + return; + } + for (const pageView of this._pages) { + pageView.update(updateArgs); + } + if (this.#scaleTimeoutId !== null) { + clearTimeout(this.#scaleTimeoutId); + this.#scaleTimeoutId = null; + } + if (!noUpdate) { + this.update(); + } + } +} + +;// ./web/pdf_single_page_viewer.js + + +class PDFSinglePageViewer extends PDFViewer { + _resetView() { + super._resetView(); + this._scrollMode = ScrollMode.PAGE; + this._spreadMode = SpreadMode.NONE; + } + set scrollMode(mode) {} + _updateScrollMode() {} + set spreadMode(mode) {} + _updateSpreadMode() {} +} + +;// ./web/pdf_viewer.component.js + + + + + + + + + + + + + + + +const pdfjsVersion = "4.10.38"; +const pdfjsBuild = "f9bea397f"; + +var __webpack_exports__AnnotationLayerBuilder = __webpack_exports__.AnnotationLayerBuilder; +var __webpack_exports__DownloadManager = __webpack_exports__.DownloadManager; +var __webpack_exports__EventBus = __webpack_exports__.EventBus; +var __webpack_exports__FindState = __webpack_exports__.FindState; +var __webpack_exports__GenericL10n = __webpack_exports__.GenericL10n; +var __webpack_exports__LinkTarget = __webpack_exports__.LinkTarget; +var __webpack_exports__PDFFindController = __webpack_exports__.PDFFindController; +var __webpack_exports__PDFHistory = __webpack_exports__.PDFHistory; +var __webpack_exports__PDFLinkService = __webpack_exports__.PDFLinkService; +var __webpack_exports__PDFPageView = __webpack_exports__.PDFPageView; +var __webpack_exports__PDFScriptingManager = __webpack_exports__.PDFScriptingManager; +var __webpack_exports__PDFSinglePageViewer = __webpack_exports__.PDFSinglePageViewer; +var __webpack_exports__PDFViewer = __webpack_exports__.PDFViewer; +var __webpack_exports__ProgressBar = __webpack_exports__.ProgressBar; +var __webpack_exports__RenderingStates = __webpack_exports__.RenderingStates; +var __webpack_exports__ScrollMode = __webpack_exports__.ScrollMode; +var __webpack_exports__SimpleLinkService = __webpack_exports__.SimpleLinkService; +var __webpack_exports__SpreadMode = __webpack_exports__.SpreadMode; +var __webpack_exports__StructTreeLayerBuilder = __webpack_exports__.StructTreeLayerBuilder; +var __webpack_exports__TextLayerBuilder = __webpack_exports__.TextLayerBuilder; +var __webpack_exports__XfaLayerBuilder = __webpack_exports__.XfaLayerBuilder; +var __webpack_exports__parseQueryString = __webpack_exports__.parseQueryString; +export { __webpack_exports__AnnotationLayerBuilder as AnnotationLayerBuilder, __webpack_exports__DownloadManager as DownloadManager, __webpack_exports__EventBus as EventBus, __webpack_exports__FindState as FindState, __webpack_exports__GenericL10n as GenericL10n, __webpack_exports__LinkTarget as LinkTarget, __webpack_exports__PDFFindController as PDFFindController, __webpack_exports__PDFHistory as PDFHistory, __webpack_exports__PDFLinkService as PDFLinkService, __webpack_exports__PDFPageView as PDFPageView, __webpack_exports__PDFScriptingManager as PDFScriptingManager, __webpack_exports__PDFSinglePageViewer as PDFSinglePageViewer, __webpack_exports__PDFViewer as PDFViewer, __webpack_exports__ProgressBar as ProgressBar, __webpack_exports__RenderingStates as RenderingStates, __webpack_exports__ScrollMode as ScrollMode, __webpack_exports__SimpleLinkService as SimpleLinkService, __webpack_exports__SpreadMode as SpreadMode, __webpack_exports__StructTreeLayerBuilder as StructTreeLayerBuilder, __webpack_exports__TextLayerBuilder as TextLayerBuilder, __webpack_exports__XfaLayerBuilder as XfaLayerBuilder, __webpack_exports__parseQueryString as parseQueryString }; + +//# sourceMappingURL=pdf_viewer.mjs.map \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/README.md new file mode 100644 index 00000000000..69fb53bf322 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/README.md @@ -0,0 +1,7 @@ +tailwindcss version 4.0.3 +https://github.com/tailwindlabs/tailwindcss +License: MIT + +This template uses only `preflight.css`, the CSS reset stylesheet from Tailwind. For simplicity, this template doesn't use a complete deployment of Tailwind. If you want to use the full Tailwind library, remove `preflight.css` and reference the full version of Tailwind. + +To update, replace the `preflight.css` file with the one in an updated build from https://www.npmjs.com/package/tailwindcss diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/dist/preflight.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/dist/preflight.css new file mode 100644 index 00000000000..178c881dd23 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/dist/preflight.css @@ -0,0 +1,383 @@ +/* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. +*/ + +*, +::after, +::before, +::backdrop, +::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ +} + +/* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured `sans` font-family by default. + 5. Use the user's configured `sans` font-feature-settings by default. + 6. Use the user's configured `sans` font-variation-settings by default. + 7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: var( + --default-font-family, + ui-sans-serif, + system-ui, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ + font-feature-settings: var(--default-font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--default-font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* + Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + line-height: inherit; +} + +/* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* + Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* + Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* + Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; +} + +/* + Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* + 1. Use the user's configured `mono` font-family by default. + 2. Use the user's configured `mono` font-feature-settings by default. + 3. Use the user's configured `mono` font-variation-settings by default. + 4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: var( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 4 */ + font-feature-settings: var(--default-mono-font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--default-mono-font-variation-settings, normal); /* 6 */ + font-size: 1em; /* 4 */ +} + +/* + Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* + Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* + Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* + Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* + Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* + Make lists unstyled by default. +*/ + +ol, +ul, +menu { + list-style: none; +} + +/* + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* + 1. Inherit font styles in all browsers. + 2. Remove border radius in all browsers. + 3. Remove background color in all browsers. + 4. Ensure consistent opacity for disabled states in all browsers. +*/ + +button, +input, +select, +optgroup, +textarea, +::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + border-radius: 0; /* 2 */ + background-color: transparent; /* 3 */ + opacity: 1; /* 4 */ +} + +/* + Restore default font weight. +*/ + +:where(select:is([multiple], [size])) optgroup { + font-weight: bolder; +} + +/* + Restore indentation. +*/ + +:where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; +} + +/* + Restore space after button. +*/ + +::file-selector-button { + margin-inline-end: 4px; +} + +/* + 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) + 2. Set the default placeholder color to a semi-transparent version of the current text color. +*/ + +::placeholder { + opacity: 1; /* 1 */ + color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */ +} + +/* + Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* + Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* + 1. Ensure date/time inputs have the same height when empty in iOS Safari. + 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. +*/ + +::-webkit-date-and-time-value { + min-height: 1lh; /* 1 */ + text-align: inherit; /* 2 */ +} + +/* + Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. +*/ + +::-webkit-datetime-edit { + display: inline-flex; +} + +/* + Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. +*/ + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-datetime-edit, +::-webkit-datetime-edit-year-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-minute-field, +::-webkit-datetime-edit-second-field, +::-webkit-datetime-edit-millisecond-field, +::-webkit-datetime-edit-meridiem-field { + padding-block: 0; +} + +/* + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* + Correct the inability to style the border radius in iOS Safari. +*/ + +button, +input:where([type='button'], [type='reset'], [type='submit']), +::file-selector-button { + appearance: button; +} + +/* + Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* + Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden]:where(:not([hidden='until-found'])) { + display: none !important; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.sln b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.sln new file mode 100644 index 00000000000..67d2a3cad3c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.AppHost", "aichatweb.AppHost\aichatweb.AppHost.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.ServiceDefaults", "aichatweb.ServiceDefaults\aichatweb.ServiceDefaults.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.Web", "aichatweb.Web\aichatweb.Web.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor index 5e4f8042add..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } @@ -108,7 +114,7 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + $"{result.Text}"); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs index de5a06ed171..1ff3845eb08 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs @@ -1,11 +1,9 @@ -using Microsoft.EntityFrameworkCore; +using System.ClientModel; using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using OpenAI; using aichatweb.Components; using aichatweb.Services; using aichatweb.Services.Ingestion; -using OpenAI; -using System.ClientModel; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -24,19 +22,17 @@ var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); -var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store")); +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteCollection("data-aichatweb-chunks", vectorStoreConnectionString); +builder.Services.AddSqliteCollection("data-aichatweb-documents", vectorStoreConnectionString); -builder.Services.AddSingleton(vectorStore); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); builder.Services.AddEmbeddingGenerator(embeddingGenerator); -builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=ingestioncache.db")); - var app = builder.Build(); -IngestionCacheDbContext.Initialize(app.Services); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/README.md index 035bd4acaa6..cf7deff5878 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/README.md @@ -6,7 +6,7 @@ This project is an AI chat application that demonstrates how to chat with custom > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. # Configure the AI Model Provider -To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). +To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information. From the command line, configure your token for this project using .NET User Secrets by running the following commands: diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/IngestedChunk.cs new file mode 100644 index 00000000000..2c5a38c7912 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/IngestedChunk.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedChunk +{ + private const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + private const string VectorDistanceFunction = DistanceFunction.CosineDistance; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string DocumentId { get; set; } + + [VectorStoreData] + public int PageNumber { get; set; } + + [VectorStoreData] + public required string Text { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/IngestedDocument.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/IngestedDocument.cs new file mode 100644 index 00000000000..f101cfdc96a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/IngestedDocument.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedDocument +{ + private const int VectorDimensions = 2; + private const string VectorDistanceFunction = DistanceFunction.CosineDistance; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string SourceId { get; set; } + + [VectorStoreData] + public required string DocumentId { get; set; } + + [VectorStoreData] + public required string DocumentVersion { get; set; } + + // The vector is not used but required for some vector databases + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public ReadOnlyMemory Vector { get; set; } = new ReadOnlyMemory([0, 0]); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs index d18307ead2b..89fe287ebed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -1,14 +1,12 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace aichatweb.Services.Ingestion; public class DataIngestor( ILogger logger, - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore, - IngestionCacheDbContext ingestionCacheDb) + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) { public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source) { @@ -19,45 +17,42 @@ public static async Task IngestDataAsync(IServiceProvider services, IIngestionSo public async Task IngestDataAsync(IIngestionSource source) { - var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested"); - await vectorCollection.CreateCollectionIfNotExistsAsync(); + await chunksCollection.EnsureCollectionExistsAsync(); + await documentsCollection.EnsureCollectionExistsAsync(); - var documentsForSource = ingestionCacheDb.Documents - .Where(d => d.SourceId == source.SourceId) - .Include(d => d.Records); + var sourceId = source.SourceId; + var documentsForSource = await documentsCollection.GetAsync(doc => doc.SourceId == sourceId, top: int.MaxValue).ToListAsync(); - var deletedFiles = await source.GetDeletedDocumentsAsync(documentsForSource); - foreach (var deletedFile in deletedFiles) + var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); + foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {file}", deletedFile.Id); - await vectorCollection.DeleteBatchAsync(deletedFile.Records.Select(r => r.Id)); - ingestionCacheDb.Documents.Remove(deletedFile); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); + await DeleteChunksForDocumentAsync(deletedDocument); + await documentsCollection.DeleteAsync(deletedDocument.Key); } - await ingestionCacheDb.SaveChangesAsync(); - var modifiedDocs = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); - foreach (var modifiedDoc in modifiedDocs) + var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); + foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {file}", modifiedDoc.Id); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); + await DeleteChunksForDocumentAsync(modifiedDocument); - if (modifiedDoc.Records.Count > 0) - { - await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id)); - } + await documentsCollection.UpsertAsync(modifiedDocument); - var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id); - await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { } + var newRecords = await source.CreateChunksForDocumentAsync(modifiedDocument); + await chunksCollection.UpsertAsync(newRecords); + } - modifiedDoc.Records.Clear(); - modifiedDoc.Records.AddRange(newRecords.Select(r => new IngestedRecord { Id = r.Key, DocumentId = modifiedDoc.Id })); + logger.LogInformation("Ingestion is up-to-date"); - if (ingestionCacheDb.Entry(modifiedDoc).State == EntityState.Detached) + async Task DeleteChunksForDocumentAsync(IngestedDocument document) + { + var documentId = document.DocumentId; + var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); + if (chunksToDelete.Count != 0) { - ingestionCacheDb.Documents.Add(modifiedDoc); + await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } } - - await ingestionCacheDb.SaveChangesAsync(); - logger.LogInformation("Ingestion is up-to-date"); } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IIngestionSource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IIngestionSource.cs index 298f31190a3..540cac117e7 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IIngestionSource.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IIngestionSource.cs @@ -1,14 +1,12 @@ -using Microsoft.Extensions.AI; - -namespace aichatweb.Services.Ingestion; +namespace aichatweb.Services.Ingestion; public interface IIngestionSource { string SourceId { get; } - Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments); + Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments); - Task> GetDeletedDocumentsAsync(IQueryable existingDocuments); + Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments); - Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId); + Task> CreateChunksForDocumentAsync(IngestedDocument document); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IngestionCacheDbContext.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IngestionCacheDbContext.cs deleted file mode 100644 index 1d77efbb58c..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/IngestionCacheDbContext.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace aichatweb.Services.Ingestion; - -// A DbContext that keeps track of which documents have been ingested. -// This makes it possible to avoid re-ingesting documents that have not changed, -// and to delete documents that have been removed from the underlying source. -public class IngestionCacheDbContext : DbContext -{ - public IngestionCacheDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet Documents { get; set; } = default!; - public DbSet Records { get; set; } = default!; - - public static void Initialize(IServiceProvider services) - { - using var scope = services.CreateScope(); - using var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.Entity().HasMany(d => d.Records).WithOne().HasForeignKey(r => r.DocumentId).OnDelete(DeleteBehavior.Cascade); - } -} - -public class IngestedDocument -{ - // TODO: Make Id+SourceId a composite key - public required string Id { get; set; } - public required string SourceId { get; set; } - public required string Version { get; set; } - public List Records { get; set; } = []; -} - -public class IngestedRecord -{ - public required string Id { get; set; } - public required string DocumentId { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs index 9072a9c2b40..0be02a9d008 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs @@ -1,68 +1,58 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.SemanticKernel.Text; -using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; -using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; +using Microsoft.SemanticKernel.Text; using UglyToad.PdfPig; -using Microsoft.Extensions.AI; using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; namespace aichatweb.Services.Ingestion; public class PDFDirectorySource(string sourceDirectory) : IIngestionSource { public static string SourceFileId(string path) => Path.GetFileName(path); + public static string SourceFileVersion(string path) => File.GetLastWriteTimeUtc(path).ToString("o"); public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}"; - public async Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments) + public Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments) { var results = new List(); var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var existingDocumentsById = existingDocuments.ToDictionary(d => d.DocumentId); foreach (var sourceFile in sourceFiles) { var sourceFileId = SourceFileId(sourceFile); - var sourceFileVersion = File.GetLastWriteTimeUtc(sourceFile).ToString("o"); - - var existingDocument = await existingDocuments.Where(d => d.SourceId == SourceId && d.Id == sourceFileId).FirstOrDefaultAsync(); - if (existingDocument is null) + var sourceFileVersion = SourceFileVersion(sourceFile); + var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; + if (existingDocumentVersion != sourceFileVersion) { - results.Add(new() { Id = sourceFileId, Version = sourceFileVersion, SourceId = SourceId }); - } - else if (existingDocument.Version != sourceFileVersion) - { - existingDocument.Version = sourceFileVersion; - results.Add(existingDocument); + results.Add(new() { Key = Guid.CreateVersion7().ToString(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); } } - return results; + return Task.FromResult((IEnumerable)results); } - public async Task> GetDeletedDocumentsAsync(IQueryable existingDocuments) + public Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments) { - var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); - var sourceFileIds = sourceFiles.Select(SourceFileId).ToList(); - return await existingDocuments - .Where(d => !sourceFileIds.Contains(d.Id)) - .ToListAsync(); + var currentFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var currentFileIds = currentFiles.ToLookup(SourceFileId); + var deletedDocuments = existingDocuments.Where(d => !currentFileIds.Contains(d.DocumentId)); + return Task.FromResult(deletedDocuments); } - public async Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId) + public Task> CreateChunksForDocumentAsync(IngestedDocument document) { - using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId)); + using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, document.DocumentId)); var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList(); - var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text)); - - return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord + return Task.FromResult(paragraphs.Select(p => new IngestedChunk { - Key = $"{Path.GetFileNameWithoutExtension(documentId)}_{pair.First.PageNumber}_{pair.First.IndexOnPage}", - FileName = documentId, - PageNumber = pair.First.PageNumber, - Text = pair.First.Text, - Vector = pair.Second.Vector, - }); + Key = Guid.CreateVersion7().ToString(), + DocumentId = document.DocumentId, + PageNumber = p.PageNumber, + Text = p.Text, + })); } private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs deleted file mode 100644 index 8e6d273a27b..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/JsonVectorStore.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Microsoft.Extensions.VectorData; -using System.Numerics.Tensors; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text.Json; - -namespace aichatweb.Services; - -/// -/// This IVectorStore implementation is for prototyping only. Do not use this in production. -/// In production, you must replace this with a real vector store. There are many IVectorStore -/// implementations available, including ones for standalone vector databases like Qdrant or Milvus, -/// or for integrating with relational databases such as SQL Server or PostgreSQL. -/// -/// This implementation stores the vector records in large JSON files on disk. It is very inefficient -/// and is provided only for convenience when prototyping. -/// -public class JsonVectorStore(string basePath) : IVectorStore -{ - public IVectorStoreRecordCollection GetCollection(string name, VectorStoreRecordDefinition? vectorStoreRecordDefinition = null) where TKey : notnull - => new JsonVectorStoreRecordCollection(name, Path.Combine(basePath, name + ".json"), vectorStoreRecordDefinition); - - public IAsyncEnumerable ListCollectionNamesAsync(CancellationToken cancellationToken = default) - => Directory.EnumerateFiles(basePath, "*.json").ToAsyncEnumerable(); - - private class JsonVectorStoreRecordCollection : IVectorStoreRecordCollection - where TKey : notnull - { - private static readonly Func _getKey = CreateKeyReader(); - private static readonly Func> _getVector = CreateVectorReader(); - - private readonly string _name; - private readonly string _filePath; - private Dictionary? _records; - - public JsonVectorStoreRecordCollection(string name, string filePath, VectorStoreRecordDefinition? vectorStoreRecordDefinition) - { - _name = name; - _filePath = filePath; - - if (File.Exists(filePath)) - { - _records = JsonSerializer.Deserialize>(File.ReadAllText(filePath)); - } - } - - public string CollectionName => _name; - - public Task CollectionExistsAsync(CancellationToken cancellationToken = default) - => Task.FromResult(_records is not null); - - public async Task CreateCollectionAsync(CancellationToken cancellationToken = default) - { - _records = []; - await WriteToDiskAsync(cancellationToken); - } - - public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellationToken = default) - { - if (_records is null) - { - await CreateCollectionAsync(cancellationToken); - } - } - - public Task DeleteAsync(TKey key, CancellationToken cancellationToken = default) - { - _records!.Remove(key); - return WriteToDiskAsync(cancellationToken); - } - - public Task DeleteBatchAsync(IEnumerable keys, CancellationToken cancellationToken = default) - { - foreach (var key in keys) - { - _records!.Remove(key); - } - - return WriteToDiskAsync(cancellationToken); - } - - public Task DeleteCollectionAsync(CancellationToken cancellationToken = default) - { - _records = null; - File.Delete(_filePath); - return Task.CompletedTask; - } - - public Task GetAsync(TKey key, GetRecordOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(_records!.GetValueOrDefault(key)); - - public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = null, CancellationToken cancellationToken = default) - => keys.Select(key => _records!.GetValueOrDefault(key)!).Where(r => r is not null).ToAsyncEnumerable(); - - public async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default) - { - var key = _getKey(record); - _records![key] = record; - await WriteToDiskAsync(cancellationToken); - return key; - } - - public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var results = new List(); - foreach (var record in records) - { - var key = _getKey(record); - _records![key] = record; - results.Add(key); - } - - await WriteToDiskAsync(cancellationToken); - - foreach (var key in results) - { - yield return key; - } - } - - public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default) - { - if (vector is not ReadOnlyMemory floatVector) - { - throw new NotSupportedException($"The provided vector type {vector!.GetType().FullName} is not supported."); - } - - IEnumerable filteredRecords = _records!.Values; - if (options?.Filter is { } filter) - { - filteredRecords = filteredRecords.AsQueryable().Where(filter); - } - - var ranked = from record in filteredRecords - let candidateVector = _getVector(record) - let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span) - orderby similarity descending - select (Record: record, Similarity: similarity); - - var results = ranked.Skip(options?.Skip ?? 0).Take(options?.Top ?? int.MaxValue); - return Task.FromResult(new VectorSearchResults( - results.Select(r => new VectorSearchResult(r.Record, r.Similarity)).ToAsyncEnumerable())); - } - - private static Func CreateKeyReader() - { - var propertyInfo = typeof(TRecord).GetProperties() - .Where(p => p.GetCustomAttribute() is not null - && p.PropertyType == typeof(TKey)) - .Single(); - return record => (TKey)propertyInfo.GetValue(record)!; - } - - private static Func> CreateVectorReader() - { - var propertyInfo = typeof(TRecord).GetProperties() - .Where(p => p.GetCustomAttribute() is not null - && p.PropertyType == typeof(ReadOnlyMemory)) - .Single(); - return record => (ReadOnlyMemory)propertyInfo.GetValue(record)!; - } - - private async Task WriteToDiskAsync(CancellationToken cancellationToken = default) - { - var json = JsonSerializer.Serialize(_records); - Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); - await File.WriteAllTextAsync(_filePath, json, cancellationToken); - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs index 1ac3977d014..291c6c4b4a9 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearch.cs @@ -1,28 +1,17 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData; namespace aichatweb.Services; public class SemanticSearch( - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore) + VectorStoreCollection vectorCollection) { - public async Task> SearchAsync(string text, string? filenameFilter, int maxResults) + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) { - var queryEmbedding = await embeddingGenerator.GenerateVectorAsync(text); - var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested"); - - var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions { - Top = maxResults, - Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null, + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, }); - var results = new List(); - await foreach (var item in nearest.Results) - { - results.Add(item.Record); - } - return results; + return await nearest.Select(result => result.Record).ToListAsync(); } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs deleted file mode 100644 index eb37cef61c8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/SemanticSearchRecord.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Services; - -public class SemanticSearchRecord -{ - [VectorStoreRecordKey] - public required string Key { get; set; } - - [VectorStoreRecordData(IsFilterable = true)] - public required string FileName { get; set; } - - [VectorStoreRecordData] - public int PageNumber { get; set; } - - [VectorStoreRecordData] - public required string Text { get; set; } - - [VectorStoreRecordVector(1536, DistanceFunction.CosineSimilarity)] // 1536 is the default vector size for the OpenAI text-embedding-3-small model - public ReadOnlyMemory Vector { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj index 4b1fad034a9..1e694a1d6a6 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md index c05c18281ef..94c542fda7f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md @@ -43,9 +43,9 @@ Learn more about [prototyping with AI models using GitHub Models](https://docs.g ## Trust the localhost certificate -Several .NET Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. -See [Troubleshoot untrusted localhost certificate in .NET Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. # Updating JavaScript dependencies diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs similarity index 80% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Program.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs index 1a7cc375e1a..d41eea07e40 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -6,12 +6,7 @@ // dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" var openai = builder.AddConnectionString("openai"); -var ingestionCache = builder.AddSqlite("ingestionCache"); - var webApp = builder.AddProject("aichatweb-app"); webApp.WithReference(openai); -webApp - .WithReference(ingestionCache) - .WaitFor(ingestionCache); builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json index 4444e808585..681e3bf0d26 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -9,8 +9,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" } }, "http": { @@ -21,8 +21,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" } } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index a74ef7b7f3b..fa6140a8751 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,19 +1,17 @@  - + Exe net9.0 enable enable - true secret - - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs index f56908872e0..b44d60b604b 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -10,11 +10,14 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -64,7 +67,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() @@ -111,10 +119,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index b7748e7c995..474dd445fae 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 5e4f8042add..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } @@ -108,7 +114,7 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + $"{result.Text}"); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs index 729630d235a..6d23308d93a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs @@ -1,9 +1,8 @@ using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using OpenAI; using aichatweb.Web.Components; using aichatweb.Web.Services; using aichatweb.Web.Services.Ingestion; -using OpenAI; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); @@ -16,14 +15,14 @@ c.EnableSensitiveData = builder.Environment.IsDevelopment()); openai.AddEmbeddingGenerator("text-embedding-3-small"); -var vectorStore = new JsonVectorStore(Path.Combine(AppContext.BaseDirectory, "vector-store")); -builder.Services.AddSingleton(vectorStore); +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteCollection("data-aichatweb-chunks", vectorStoreConnectionString); +builder.Services.AddSqliteCollection("data-aichatweb-documents", vectorStoreConnectionString); builder.Services.AddScoped(); builder.Services.AddSingleton(); -builder.AddSqliteDbContext("ingestionCache"); var app = builder.Build(); -IngestionCacheDbContext.Initialize(app.Services); app.MapDefaultEndpoints(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs new file mode 100644 index 00000000000..92e50e61414 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedChunk +{ + private const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + private const string VectorDistanceFunction = DistanceFunction.CosineDistance; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string DocumentId { get; set; } + + [VectorStoreData] + public int PageNumber { get; set; } + + [VectorStoreData] + public required string Text { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs new file mode 100644 index 00000000000..49a8143005e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedDocument +{ + private const int VectorDimensions = 2; + private const string VectorDistanceFunction = DistanceFunction.CosineDistance; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string SourceId { get; set; } + + [VectorStoreData] + public required string DocumentId { get; set; } + + [VectorStoreData] + public required string DocumentVersion { get; set; } + + // The vector is not used but required for some vector databases + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public ReadOnlyMemory Vector { get; set; } = new ReadOnlyMemory([0, 0]); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs index 31b127914dd..2fe43370071 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -1,14 +1,12 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace aichatweb.Web.Services.Ingestion; public class DataIngestor( ILogger logger, - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore, - IngestionCacheDbContext ingestionCacheDb) + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) { public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source) { @@ -19,45 +17,42 @@ public static async Task IngestDataAsync(IServiceProvider services, IIngestionSo public async Task IngestDataAsync(IIngestionSource source) { - var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested"); - await vectorCollection.CreateCollectionIfNotExistsAsync(); + await chunksCollection.EnsureCollectionExistsAsync(); + await documentsCollection.EnsureCollectionExistsAsync(); - var documentsForSource = ingestionCacheDb.Documents - .Where(d => d.SourceId == source.SourceId) - .Include(d => d.Records); + var sourceId = source.SourceId; + var documentsForSource = await documentsCollection.GetAsync(doc => doc.SourceId == sourceId, top: int.MaxValue).ToListAsync(); - var deletedFiles = await source.GetDeletedDocumentsAsync(documentsForSource); - foreach (var deletedFile in deletedFiles) + var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); + foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {file}", deletedFile.Id); - await vectorCollection.DeleteBatchAsync(deletedFile.Records.Select(r => r.Id)); - ingestionCacheDb.Documents.Remove(deletedFile); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); + await DeleteChunksForDocumentAsync(deletedDocument); + await documentsCollection.DeleteAsync(deletedDocument.Key); } - await ingestionCacheDb.SaveChangesAsync(); - var modifiedDocs = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); - foreach (var modifiedDoc in modifiedDocs) + var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); + foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {file}", modifiedDoc.Id); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); + await DeleteChunksForDocumentAsync(modifiedDocument); - if (modifiedDoc.Records.Count > 0) - { - await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id)); - } + await documentsCollection.UpsertAsync(modifiedDocument); - var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id); - await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { } + var newRecords = await source.CreateChunksForDocumentAsync(modifiedDocument); + await chunksCollection.UpsertAsync(newRecords); + } - modifiedDoc.Records.Clear(); - modifiedDoc.Records.AddRange(newRecords.Select(r => new IngestedRecord { Id = r.Key, DocumentId = modifiedDoc.Id })); + logger.LogInformation("Ingestion is up-to-date"); - if (ingestionCacheDb.Entry(modifiedDoc).State == EntityState.Detached) + async Task DeleteChunksForDocumentAsync(IngestedDocument document) + { + var documentId = document.DocumentId; + var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); + if (chunksToDelete.Count != 0) { - ingestionCacheDb.Documents.Add(modifiedDoc); + await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } } - - await ingestionCacheDb.SaveChangesAsync(); - logger.LogInformation("Ingestion is up-to-date"); } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs index a9e92b26779..a1c6b2191d1 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs @@ -1,14 +1,12 @@ -using Microsoft.Extensions.AI; - -namespace aichatweb.Web.Services.Ingestion; +namespace aichatweb.Web.Services.Ingestion; public interface IIngestionSource { string SourceId { get; } - Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments); + Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments); - Task> GetDeletedDocumentsAsync(IQueryable existingDocuments); + Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments); - Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId); + Task> CreateChunksForDocumentAsync(IngestedDocument document); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IngestionCacheDbContext.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IngestionCacheDbContext.cs deleted file mode 100644 index 66a02d3a0f6..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/IngestionCacheDbContext.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace aichatweb.Web.Services.Ingestion; - -// A DbContext that keeps track of which documents have been ingested. -// This makes it possible to avoid re-ingesting documents that have not changed, -// and to delete documents that have been removed from the underlying source. -public class IngestionCacheDbContext : DbContext -{ - public IngestionCacheDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet Documents { get; set; } = default!; - public DbSet Records { get; set; } = default!; - - public static void Initialize(IServiceProvider services) - { - using var scope = services.CreateScope(); - using var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.Entity().HasMany(d => d.Records).WithOne().HasForeignKey(r => r.DocumentId).OnDelete(DeleteBehavior.Cascade); - } -} - -public class IngestedDocument -{ - // TODO: Make Id+SourceId a composite key - public required string Id { get; set; } - public required string SourceId { get; set; } - public required string Version { get; set; } - public List Records { get; set; } = []; -} - -public class IngestedRecord -{ - public required string Id { get; set; } - public required string DocumentId { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs index d6a10a626b0..32e9f225c08 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs @@ -1,68 +1,58 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.SemanticKernel.Text; -using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; -using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; +using Microsoft.SemanticKernel.Text; using UglyToad.PdfPig; -using Microsoft.Extensions.AI; using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; namespace aichatweb.Web.Services.Ingestion; public class PDFDirectorySource(string sourceDirectory) : IIngestionSource { public static string SourceFileId(string path) => Path.GetFileName(path); + public static string SourceFileVersion(string path) => File.GetLastWriteTimeUtc(path).ToString("o"); public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}"; - public async Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments) + public Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments) { var results = new List(); var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var existingDocumentsById = existingDocuments.ToDictionary(d => d.DocumentId); foreach (var sourceFile in sourceFiles) { var sourceFileId = SourceFileId(sourceFile); - var sourceFileVersion = File.GetLastWriteTimeUtc(sourceFile).ToString("o"); - - var existingDocument = await existingDocuments.Where(d => d.SourceId == SourceId && d.Id == sourceFileId).FirstOrDefaultAsync(); - if (existingDocument is null) + var sourceFileVersion = SourceFileVersion(sourceFile); + var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; + if (existingDocumentVersion != sourceFileVersion) { - results.Add(new() { Id = sourceFileId, Version = sourceFileVersion, SourceId = SourceId }); - } - else if (existingDocument.Version != sourceFileVersion) - { - existingDocument.Version = sourceFileVersion; - results.Add(existingDocument); + results.Add(new() { Key = Guid.CreateVersion7().ToString(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); } } - return results; + return Task.FromResult((IEnumerable)results); } - public async Task> GetDeletedDocumentsAsync(IQueryable existingDocuments) + public Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments) { - var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); - var sourceFileIds = sourceFiles.Select(SourceFileId).ToList(); - return await existingDocuments - .Where(d => !sourceFileIds.Contains(d.Id)) - .ToListAsync(); + var currentFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var currentFileIds = currentFiles.ToLookup(SourceFileId); + var deletedDocuments = existingDocuments.Where(d => !currentFileIds.Contains(d.DocumentId)); + return Task.FromResult(deletedDocuments); } - public async Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId) + public Task> CreateChunksForDocumentAsync(IngestedDocument document) { - using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId)); + using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, document.DocumentId)); var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList(); - var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text)); - - return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord + return Task.FromResult(paragraphs.Select(p => new IngestedChunk { - Key = $"{Path.GetFileNameWithoutExtension(documentId)}_{pair.First.PageNumber}_{pair.First.IndexOnPage}", - FileName = documentId, - PageNumber = pair.First.PageNumber, - Text = pair.First.Text, - Vector = pair.Second.Vector, - }); + Key = Guid.CreateVersion7().ToString(), + DocumentId = document.DocumentId, + PageNumber = p.PageNumber, + Text = p.Text, + })); } private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/JsonVectorStore.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/JsonVectorStore.cs deleted file mode 100644 index de36fb68a17..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/JsonVectorStore.cs +++ /dev/null @@ -1,170 +0,0 @@ -using Microsoft.Extensions.VectorData; -using System.Numerics.Tensors; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text.Json; - -namespace aichatweb.Web.Services; - -/// -/// This IVectorStore implementation is for prototyping only. Do not use this in production. -/// In production, you must replace this with a real vector store. There are many IVectorStore -/// implementations available, including ones for standalone vector databases like Qdrant or Milvus, -/// or for integrating with relational databases such as SQL Server or PostgreSQL. -/// -/// This implementation stores the vector records in large JSON files on disk. It is very inefficient -/// and is provided only for convenience when prototyping. -/// -public class JsonVectorStore(string basePath) : IVectorStore -{ - public IVectorStoreRecordCollection GetCollection(string name, VectorStoreRecordDefinition? vectorStoreRecordDefinition = null) where TKey : notnull - => new JsonVectorStoreRecordCollection(name, Path.Combine(basePath, name + ".json"), vectorStoreRecordDefinition); - - public IAsyncEnumerable ListCollectionNamesAsync(CancellationToken cancellationToken = default) - => Directory.EnumerateFiles(basePath, "*.json").ToAsyncEnumerable(); - - private class JsonVectorStoreRecordCollection : IVectorStoreRecordCollection - where TKey : notnull - { - private static readonly Func _getKey = CreateKeyReader(); - private static readonly Func> _getVector = CreateVectorReader(); - - private readonly string _name; - private readonly string _filePath; - private Dictionary? _records; - - public JsonVectorStoreRecordCollection(string name, string filePath, VectorStoreRecordDefinition? vectorStoreRecordDefinition) - { - _name = name; - _filePath = filePath; - - if (File.Exists(filePath)) - { - _records = JsonSerializer.Deserialize>(File.ReadAllText(filePath)); - } - } - - public string CollectionName => _name; - - public Task CollectionExistsAsync(CancellationToken cancellationToken = default) - => Task.FromResult(_records is not null); - - public async Task CreateCollectionAsync(CancellationToken cancellationToken = default) - { - _records = []; - await WriteToDiskAsync(cancellationToken); - } - - public async Task CreateCollectionIfNotExistsAsync(CancellationToken cancellationToken = default) - { - if (_records is null) - { - await CreateCollectionAsync(cancellationToken); - } - } - - public Task DeleteAsync(TKey key, CancellationToken cancellationToken = default) - { - _records!.Remove(key); - return WriteToDiskAsync(cancellationToken); - } - - public Task DeleteBatchAsync(IEnumerable keys, CancellationToken cancellationToken = default) - { - foreach (var key in keys) - { - _records!.Remove(key); - } - - return WriteToDiskAsync(cancellationToken); - } - - public Task DeleteCollectionAsync(CancellationToken cancellationToken = default) - { - _records = null; - File.Delete(_filePath); - return Task.CompletedTask; - } - - public Task GetAsync(TKey key, GetRecordOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(_records!.GetValueOrDefault(key)); - - public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = null, CancellationToken cancellationToken = default) - => keys.Select(key => _records!.GetValueOrDefault(key)!).Where(r => r is not null).ToAsyncEnumerable(); - - public async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default) - { - var key = _getKey(record); - _records![key] = record; - await WriteToDiskAsync(cancellationToken); - return key; - } - - public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var results = new List(); - foreach (var record in records) - { - var key = _getKey(record); - _records![key] = record; - results.Add(key); - } - - await WriteToDiskAsync(cancellationToken); - - foreach (var key in results) - { - yield return key; - } - } - - public Task> VectorizedSearchAsync(TVector vector, VectorSearchOptions? options = null, CancellationToken cancellationToken = default) - { - if (vector is not ReadOnlyMemory floatVector) - { - throw new NotSupportedException($"The provided vector type {vector!.GetType().FullName} is not supported."); - } - - IEnumerable filteredRecords = _records!.Values; - if (options?.Filter is { } filter) - { - filteredRecords = filteredRecords.AsQueryable().Where(filter); - } - - var ranked = from record in filteredRecords - let candidateVector = _getVector(record) - let similarity = TensorPrimitives.CosineSimilarity(candidateVector.Span, floatVector.Span) - orderby similarity descending - select (Record: record, Similarity: similarity); - - var results = ranked.Skip(options?.Skip ?? 0).Take(options?.Top ?? int.MaxValue); - return Task.FromResult(new VectorSearchResults( - results.Select(r => new VectorSearchResult(r.Record, r.Similarity)).ToAsyncEnumerable())); - } - - private static Func CreateKeyReader() - { - var propertyInfo = typeof(TRecord).GetProperties() - .Where(p => p.GetCustomAttribute() is not null - && p.PropertyType == typeof(TKey)) - .Single(); - return record => (TKey)propertyInfo.GetValue(record)!; - } - - private static Func> CreateVectorReader() - { - var propertyInfo = typeof(TRecord).GetProperties() - .Where(p => p.GetCustomAttribute() is not null - && p.PropertyType == typeof(ReadOnlyMemory)) - .Single(); - return record => (ReadOnlyMemory)propertyInfo.GetValue(record)!; - } - - private async Task WriteToDiskAsync(CancellationToken cancellationToken = default) - { - var json = JsonSerializer.Serialize(_records); - Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); - await File.WriteAllTextAsync(_filePath, json, cancellationToken); - } - } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs index 4f775cd1db4..84fb719f6ae 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs @@ -1,28 +1,17 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData; namespace aichatweb.Web.Services; public class SemanticSearch( - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore) + VectorStoreCollection vectorCollection) { - public async Task> SearchAsync(string text, string? filenameFilter, int maxResults) + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) { - var queryEmbedding = await embeddingGenerator.GenerateVectorAsync(text); - var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested"); - - var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions { - Top = maxResults, - Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null, + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, }); - var results = new List(); - await foreach (var item in nearest.Results) - { - results.Add(item.Record); - } - return results; + return await nearest.Select(result => result.Record).ToListAsync(); } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearchRecord.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearchRecord.cs deleted file mode 100644 index 23371589c7e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/SemanticSearchRecord.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Web.Services; - -public class SemanticSearchRecord -{ - [VectorStoreRecordKey] - public required string Key { get; set; } - - [VectorStoreRecordData(IsFilterable = true)] - public required string FileName { get; set; } - - [VectorStoreRecordData] - public int PageNumber { get; set; } - - [VectorStoreRecordData] - public required string Text { get; set; } - - [VectorStoreRecordVector(1536, DistanceFunction.CosineSimilarity)] // 1536 is the default vector size for the OpenAI text-embedding-3-small model - public ReadOnlyMemory Vector { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index c7b40c7b575..22c00c41978 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,13 +8,13 @@ - - - - - - - + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md new file mode 100644 index 00000000000..0ef2c04a907 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md @@ -0,0 +1,60 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + +>[!NOTE] +> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. + +### Known Issues + +#### Errors running Ollama or Docker + +A recent incompatibility was found between Ollama and Docker Desktop. This issue results in runtime errors when connecting to Ollama, and the workaround for that can lead to Docker not working for Aspire projects. + +This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See [ollama/ollama#9509](https://github.com/ollama/ollama/issues/9509#issuecomment-2842461831) for more information and a link to install the version of Docker Desktop with the fix. + +# Configure the AI Model Provider + +## Setting up a local environment for Ollama +This project is configured to run Ollama in a Docker container. Docker Desktop must be installed and running for the project to run successfully. An Ollama container will automatically start when running the application. + +Download, install, and run Docker Desktop from the [official website](https://www.docker.com/). Follow the installation instructions specific to your operating system. + +Note: Ollama and Docker are excellent open source products, but are not maintained by Microsoft. + + +## Setting up a local environment for Qdrant +This project is configured to run Qdrant in a Docker container. Docker Desktop must be installed and running for the project to run successfully. A Qdrant container will automatically start when running the application. + +Download, install, and run Docker Desktop from the [official website](https://www.docker.com/). Follow the installation instructions specific to your operating system. + +Note: Qdrant and Docker are excellent open source products, but are not maintained by Microsoft. + +# Running the application + +## Using Visual Studio + +1. Open the `.sln` file in Visual Studio. +2. Press `Ctrl+F5` or click the "Start" button in the toolbar to run the project. + +## Using Visual Studio Code + +1. Open the project folder in Visual Studio Code. +2. Install the [C# Dev Kit extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) for Visual Studio Code. +3. Once installed, Open the `Program.cs` file in the aichatweb.AppHost project. +4. Run the project by clicking the "Run" button in the Debug view. + +## Trust the localhost certificate + +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. + +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. + +# Updating JavaScript dependencies + +This template leverages JavaScript libraries to provide essential functionality. These libraries are located in the wwwroot/lib folder of the aichatweb.Web project. For instructions on updating each dependency, please refer to the README.md file in each respective folder. + +# Learn More +To learn more about development with .NET and AI, check out the following links: + +* [AI for .NET Developers](https://learn.microsoft.com/dotnet/ai/) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/AppHost.cs new file mode 100644 index 00000000000..9521e1a0297 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -0,0 +1,22 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var ollama = builder.AddOllama("ollama") + .WithDataVolume(); +var chat = ollama.AddModel("chat", "llama3.2"); +var embeddings = ollama.AddModel("embeddings", "all-minilm"); + +var vectorDB = builder.AddQdrant("vectordb") + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent); + +var webApp = builder.AddProject("aichatweb-app"); +webApp + .WithReference(chat) + .WithReference(embeddings) + .WaitFor(chat) + .WaitFor(embeddings); +webApp + .WithReference(vectorDB) + .WaitFor(vectorDB); + +builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..681e3bf0d26 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj new file mode 100644 index 00000000000..70239b97fa8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net9.0 + enable + enable + secret + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..b0bacf42851 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/appsettings.json new file mode 100644 index 00000000000..bfad98588cd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000000..b44d60b604b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("Experimental.Microsoft.Extensions.AI"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddSource("Experimental.Microsoft.Extensions.AI"); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj new file mode 100644 index 00000000000..474dd445fae --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/App.razor new file mode 100644 index 00000000000..262359d5f5a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
    diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
    diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor new file mode 100644 index 00000000000..77557f20173 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor @@ -0,0 +1,13 @@ +
    + + +
    + How well is this template working for you? Please take a + brief survey + and tell us what you think. +
    +
    diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css new file mode 100644 index 00000000000..c939b902afb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css @@ -0,0 +1,20 @@ +.surveyContainer { + display: flex; + justify-content: center; + gap: 0.5rem; + font-size: 0.9em; + margin: 0.5rem auto -0.7rem auto; + max-width: 1024px; + color: #444; +} + + .surveyContainer a { + text-decoration: underline; + } + + .surveyContainer .tool-icon { + margin-top: 0.15rem; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..8aa0ec9fd28 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -0,0 +1,122 @@ +@page "/" +@using System.ComponentModel +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search +@implements IDisposable + +Chat + + + + + +
    To get started, try asking about these example documents. You can replace these with your own data and replace this message.
    + + +
    +
    + +
    + + + @* Remove this line to eliminate the template survey message *@ +
    + +@code { + private const string SystemPrompt = @" + You are an assistant who answers questions about information you retrieve. + Do not answer questions about anything else. + Use only simple markdown to format your responses. + + Use the search tool to find relevant information. When you do this, end your + reply with citations in the special XML format: + + exact quote here + + Always include the citation in your response if there are results. + + The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. + Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + [Description("Searches for information using a phrase or keyword")] + private async Task> SearchAsync( + [Description("The phrase to search for.")] string searchPhrase, + [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) + { + await InvokeAsync(StateHasChanged); + var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); + return results.Select(result => + $"{result.Text}"); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 00000000000..ccb5853cec4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,38 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
    +
    @File
    +
    @Quote
    +
    +
    +} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public int? PageNumber { get; set; } + + [Parameter] + public required string Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#page={PageNumber}&search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..12b1d524e23 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
    +
    + +
    + +

    aichatweb.Web

    +
    + +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..92c20c70667 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,94 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Linq + +@if (Message.Role == ChatRole.User) +{ +
    + @Message.Text +
    +} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
    +
    +
    + + + +
    +
    +
    Assistant
    +
    + + + @foreach (var citation in citations ?? []) + { + + } +
    +
    + } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + + private List<(string File, int? Page, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var matches = CitationRegex.Matches(text); + citations = matches.Any() + ? matches.Select(m => (m.Groups["file"].Value, int.TryParse(m.Groups["page"].Value, out var page) ? page : (int?)null, m.Groups["quote"].Value)).ToList() + : null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,120 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +::deep pre > code { + background-color: white; + display: block; + padding: 0.5rem 1rem; + margin: 1rem 0; + overflow-x: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
    + + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
    @NoMessagesContent
    + } +
    +
    + +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
    + @foreach (var suggestion in suggestions) + { + + } +
    +} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

    Error.

    +

    An error occurred while processing your request.

    + +@if (ShowRequestId) +{ +

    + Request ID: @RequestId +

    +} + +

    Development Mode

    +

    + Swapping to Development environment will display more detailed information about the error that occurred. +

    +

    + The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

    + +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Routes.razor new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/_Imports.razor new file mode 100644 index 00000000000..fa7cadef6ea --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.AI +@using Microsoft.JSInterop +@using aichatweb.Web +@using aichatweb.Web.Components +@using aichatweb.Web.Components.Layout +@using aichatweb.Web.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/OllamaResilienceHandlerExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/OllamaResilienceHandlerExtensions.cs new file mode 100644 index 00000000000..ae82393302c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/OllamaResilienceHandlerExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace aichatweb.Web.Services; + +public static class OllamaResilienceHandlerExtensions +{ + public static IServiceCollection AddOllamaResilienceHandler(this IServiceCollection services) + { + services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(config => + { + // Extend the HTTP Client timeout for Ollama + config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); + + // Must be at least double the AttemptTimeout to pass options validation + config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); + config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + }); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return services; + } +} + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs new file mode 100644 index 00000000000..c67c70db5d6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.AI; +using aichatweb.Web.Components; +using aichatweb.Web.Services; +using aichatweb.Web.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +builder.AddOllamaApiClient("chat") + .AddChatClient() + .UseFunctionInvocation() + .UseOpenTelemetry(configure: c => + c.EnableSensitiveData = builder.Environment.IsDevelopment()); +builder.AddOllamaApiClient("embeddings") + .AddEmbeddingGenerator(); + +builder.AddQdrantClient("vectordb"); +builder.Services.AddQdrantCollection("data-aichatweb-chunks"); +builder.Services.AddQdrantCollection("data-aichatweb-documents"); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +// Applies robust HTTP resilience settings for all HttpClients in the Web project, +// not across the entire solution. It's aimed at supporting Ollama scenarios due +// to its self-hosted nature and potentially slow responses. +// Remove this if you want to use the global or a different HTTP resilience policy instead. +builder.Services.AddOllamaResilienceHandler(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.UseStaticFiles(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +// By default, we ingest PDF files from the /wwwroot/Data directory. You can ingest from +// other sources by implementing IIngestionSource. +// Important: ensure that any content you ingest is trusted, as it may be reflected back +// to users or could be a source of prompt injection risk. +await DataIngestor.IngestDataAsync( + app.Services, + new PDFDirectorySource(Path.Combine(builder.Environment.WebRootPath, "Data"))); + +app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json new file mode 100644 index 00000000000..e2d900a219d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9999;http://localhost:9999", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs new file mode 100644 index 00000000000..0e161a6278b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedChunk +{ + private const int VectorDimensions = 384; // 384 is the default vector size for the all-minilm embedding model + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; + + [VectorStoreKey] + public required Guid Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string DocumentId { get; set; } + + [VectorStoreData] + public int PageNumber { get; set; } + + [VectorStoreData] + public required string Text { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs new file mode 100644 index 00000000000..8a6ec320251 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/IngestedDocument.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class IngestedDocument +{ + private const int VectorDimensions = 2; + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; + + [VectorStoreKey] + public required Guid Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string SourceId { get; set; } + + [VectorStoreData] + public required string DocumentId { get; set; } + + [VectorStoreData] + public required string DocumentVersion { get; set; } + + // The vector is not used but required for some vector databases + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public ReadOnlyMemory Vector { get; set; } = new ReadOnlyMemory([0, 0]); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs new file mode 100644 index 00000000000..894b85c10de --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services.Ingestion; + +public class DataIngestor( + ILogger logger, + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) +{ + public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source) + { + using var scope = services.CreateScope(); + var ingestor = scope.ServiceProvider.GetRequiredService(); + await ingestor.IngestDataAsync(source); + } + + public async Task IngestDataAsync(IIngestionSource source) + { + await chunksCollection.EnsureCollectionExistsAsync(); + await documentsCollection.EnsureCollectionExistsAsync(); + + var sourceId = source.SourceId; + var documentsForSource = await documentsCollection.GetAsync(doc => doc.SourceId == sourceId, top: int.MaxValue).ToListAsync(); + + var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); + foreach (var deletedDocument in deletedDocuments) + { + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); + await DeleteChunksForDocumentAsync(deletedDocument); + await documentsCollection.DeleteAsync(deletedDocument.Key); + } + + var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); + foreach (var modifiedDocument in modifiedDocuments) + { + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); + await DeleteChunksForDocumentAsync(modifiedDocument); + + await documentsCollection.UpsertAsync(modifiedDocument); + + var newRecords = await source.CreateChunksForDocumentAsync(modifiedDocument); + await chunksCollection.UpsertAsync(newRecords); + } + + logger.LogInformation("Ingestion is up-to-date"); + + async Task DeleteChunksForDocumentAsync(IngestedDocument document) + { + var documentId = document.DocumentId; + var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); + if (chunksToDelete.Count != 0) + { + await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs new file mode 100644 index 00000000000..a1c6b2191d1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/IIngestionSource.cs @@ -0,0 +1,12 @@ +namespace aichatweb.Web.Services.Ingestion; + +public interface IIngestionSource +{ + string SourceId { get; } + + Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments); + + Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments); + + Task> CreateChunksForDocumentAsync(IngestedDocument document); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs new file mode 100644 index 00000000000..da043feb526 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/PDFDirectorySource.cs @@ -0,0 +1,71 @@ +using Microsoft.SemanticKernel.Text; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; + +namespace aichatweb.Web.Services.Ingestion; + +public class PDFDirectorySource(string sourceDirectory) : IIngestionSource +{ + public static string SourceFileId(string path) => Path.GetFileName(path); + public static string SourceFileVersion(string path) => File.GetLastWriteTimeUtc(path).ToString("o"); + + public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}"; + + public Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments) + { + var results = new List(); + var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var existingDocumentsById = existingDocuments.ToDictionary(d => d.DocumentId); + + foreach (var sourceFile in sourceFiles) + { + var sourceFileId = SourceFileId(sourceFile); + var sourceFileVersion = SourceFileVersion(sourceFile); + var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; + if (existingDocumentVersion != sourceFileVersion) + { + results.Add(new() { Key = Guid.CreateVersion7(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); + } + } + + return Task.FromResult((IEnumerable)results); + } + + public Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments) + { + var currentFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var currentFileIds = currentFiles.ToLookup(SourceFileId); + var deletedDocuments = existingDocuments.Where(d => !currentFileIds.Contains(d.DocumentId)); + return Task.FromResult(deletedDocuments); + } + + public Task> CreateChunksForDocumentAsync(IngestedDocument document) + { + using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, document.DocumentId)); + var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList(); + + return Task.FromResult(paragraphs.Select(p => new IngestedChunk + { + Key = Guid.CreateVersion7(), + DocumentId = document.DocumentId, + PageNumber = p.PageNumber, + Text = p.Text, + })); + } + + private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage) + { + var letters = pdfPage.Letters; + var words = NearestNeighbourWordExtractor.Instance.GetWords(letters); + var textBlocks = DocstrumBoundingBoxes.Instance.GetBlocks(words); + var pageText = string.Join(Environment.NewLine + Environment.NewLine, + textBlocks.Select(t => t.Text.ReplaceLineEndings(" "))); + +#pragma warning disable SKEXP0050 // Type is for evaluation purposes only + return TextChunker.SplitPlainTextParagraphs([pageText], 200) + .Select((text, index) => (pdfPage.Number, index, text)); +#pragma warning restore SKEXP0050 // Type is for evaluation purposes only + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs new file mode 100644 index 00000000000..044e8378595 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.Services; + +public class SemanticSearch( + VectorStoreCollection vectorCollection) +{ + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions + { + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, + }); + + return await nearest.Select(result => result.Record).ToListAsync(); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj new file mode 100644 index 00000000000..2b573a14d47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + secret + + + + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/appsettings.Development.json new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf new file mode 100644 index 00000000000..94625f0e0e0 Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_Emergency_Survival_Kit.pdf differ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf new file mode 100644 index 00000000000..c87df644c58 Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/Data/Example_GPS_Watch.pdf differ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/app.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/app.css new file mode 100644 index 00000000000..0dec580e2fd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/app.css @@ -0,0 +1,94 @@ +@import url('lib/tailwindcss/dist/preflight.css'); + +html { + min-height: 100vh; +} + +html, .main-background-gradient { + background: linear-gradient(to bottom, rgb(225 227 233), #f4f4f4 25rem); +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + + html::after { + content: ''; + background-image: linear-gradient(to right, #3a4ed5, #3acfd5 15%, #d53abf 85%, red); + width: 100%; + height: 2px; + position: fixed; + top: 0; + } + +h1 { + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 600; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.btn-default { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #9CA3AF; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 600; + background-color: #D1D5DB; +} + + .btn-default:hover { + background-color: #E5E7EB; + } + +.btn-subtle { + display: flex; + padding: 0.25rem 0.75rem; + gap: 0.25rem; + align-items: center; + border-radius: 0.25rem; + border: 1px solid #D1D5DB; + font-size: 0.875rem; + line-height: 1.25rem; +} + + .btn-subtle:hover { + border-color: #93C5FD; + background-color: #DBEAFE; + } + +.page-width { + max-width: 1024px; + margin: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/app.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/app.js new file mode 100644 index 00000000000..8b2cecd007d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/app.js @@ -0,0 +1,24 @@ +import DOMPurify from './lib/dompurify/dist/purify.es.mjs'; +import * as marked from './lib/marked/dist/marked.esm.js'; + +const purify = DOMPurify(window); + +customElements.define('assistant-message', class extends HTMLElement { + static observedAttributes = ['markdown']; + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'markdown') { + newValue = newValue.replace(//gs, ''); + const elements = marked.parse(newValue.replace(/ 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + return apply(func, thisArg, args); + }; +} +/** + * Creates a new function that constructs an instance of the given constructor function with the provided arguments. + * + * @param func - The constructor function to be wrapped and called. + * @returns A new function that constructs an instance of the given constructor function with the provided arguments. + */ +function unconstruct(func) { + return function () { + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + return construct(func, args); + }; +} +/** + * Add properties to a lookup table + * + * @param set - The set to which elements will be added. + * @param array - The array containing elements to be added to the set. + * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. + * @returns The modified set with added elements. + */ +function addToSet(set, array) { + let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; + if (setPrototypeOf) { + // Make 'in' and truthy checks like Boolean(set.constructor) + // independent of any properties defined on Object.prototype. + // Prevent prototype setters from intercepting set as a this value. + setPrototypeOf(set, null); + } + let l = array.length; + while (l--) { + let element = array[l]; + if (typeof element === 'string') { + const lcElement = transformCaseFunc(element); + if (lcElement !== element) { + // Config presets (e.g. tags.js, attrs.js) are immutable. + if (!isFrozen(array)) { + array[l] = lcElement; + } + element = lcElement; + } + } + set[element] = true; + } + return set; +} +/** + * Clean up an array to harden against CSPP + * + * @param array - The array to be cleaned. + * @returns The cleaned version of the array + */ +function cleanArray(array) { + for (let index = 0; index < array.length; index++) { + const isPropertyExist = objectHasOwnProperty(array, index); + if (!isPropertyExist) { + array[index] = null; + } + } + return array; +} +/** + * Shallow clone an object + * + * @param object - The object to be cloned. + * @returns A new object that copies the original. + */ +function clone(object) { + const newObject = create(null); + for (const [property, value] of entries(object)) { + const isPropertyExist = objectHasOwnProperty(object, property); + if (isPropertyExist) { + if (Array.isArray(value)) { + newObject[property] = cleanArray(value); + } else if (value && typeof value === 'object' && value.constructor === Object) { + newObject[property] = clone(value); + } else { + newObject[property] = value; + } + } + } + return newObject; +} +/** + * This method automatically checks if the prop is function or getter and behaves accordingly. + * + * @param object - The object to look up the getter function in its prototype chain. + * @param prop - The property name for which to find the getter function. + * @returns The getter function found in the prototype chain or a fallback function. + */ +function lookupGetter(object, prop) { + while (object !== null) { + const desc = getOwnPropertyDescriptor(object, prop); + if (desc) { + if (desc.get) { + return unapply(desc.get); + } + if (typeof desc.value === 'function') { + return unapply(desc.value); + } + } + object = getPrototypeOf(object); + } + function fallbackValue() { + return null; + } + return fallbackValue; +} + +const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); +const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); +const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); +// List of SVG elements that are disallowed by default. +// We still need to know them so that we can do namespace +// checks properly in case one wants to add them to +// allow-list. +const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); +const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); +// Similarly to SVG, we want to know all MathML elements, +// even those that we disallow by default. +const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); +const text = freeze(['#text']); + +const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']); +const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); +const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); +const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + +// eslint-disable-next-line unicorn/better-regex +const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode +const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); +const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex +const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape +const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape +const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape +); +const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); +const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex +); +const DOCTYPE_NAME = seal(/^html$/i); +const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); + +var EXPRESSIONS = /*#__PURE__*/Object.freeze({ + __proto__: null, + ARIA_ATTR: ARIA_ATTR, + ATTR_WHITESPACE: ATTR_WHITESPACE, + CUSTOM_ELEMENT: CUSTOM_ELEMENT, + DATA_ATTR: DATA_ATTR, + DOCTYPE_NAME: DOCTYPE_NAME, + ERB_EXPR: ERB_EXPR, + IS_ALLOWED_URI: IS_ALLOWED_URI, + IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA, + MUSTACHE_EXPR: MUSTACHE_EXPR, + TMPLIT_EXPR: TMPLIT_EXPR +}); + +/* eslint-disable @typescript-eslint/indent */ +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType +const NODE_TYPE = { + element: 1, + attribute: 2, + text: 3, + cdataSection: 4, + entityReference: 5, + // Deprecated + entityNode: 6, + // Deprecated + progressingInstruction: 7, + comment: 8, + document: 9, + documentType: 10, + documentFragment: 11, + notation: 12 // Deprecated +}; +const getGlobal = function getGlobal() { + return typeof window === 'undefined' ? null : window; +}; +/** + * Creates a no-op policy for internal use only. + * Don't export this function outside this module! + * @param trustedTypes The policy factory. + * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). + * @return The policy created (or null, if Trusted Types + * are not supported or creating the policy failed). + */ +const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { + if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { + return null; + } + // Allow the callers to control the unique policy name + // by adding a data-tt-policy-suffix to the script element with the DOMPurify. + // Policy creation with duplicate names throws in Trusted Types. + let suffix = null; + const ATTR_NAME = 'data-tt-policy-suffix'; + if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { + suffix = purifyHostElement.getAttribute(ATTR_NAME); + } + const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); + try { + return trustedTypes.createPolicy(policyName, { + createHTML(html) { + return html; + }, + createScriptURL(scriptUrl) { + return scriptUrl; + } + }); + } catch (_) { + // Policy creation failed (most likely another DOMPurify script has + // already run). Skip creating the policy, as this will only cause errors + // if TT are enforced. + console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); + return null; + } +}; +const _createHooksMap = function _createHooksMap() { + return { + afterSanitizeAttributes: [], + afterSanitizeElements: [], + afterSanitizeShadowDOM: [], + beforeSanitizeAttributes: [], + beforeSanitizeElements: [], + beforeSanitizeShadowDOM: [], + uponSanitizeAttribute: [], + uponSanitizeElement: [], + uponSanitizeShadowNode: [] + }; +}; +function createDOMPurify() { + let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); + const DOMPurify = root => createDOMPurify(root); + DOMPurify.version = '3.2.4'; + DOMPurify.removed = []; + if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { + // Not running in a browser, provide a factory function + // so that you can pass your own Window + DOMPurify.isSupported = false; + return DOMPurify; + } + let { + document + } = window; + const originalDocument = document; + const currentScript = originalDocument.currentScript; + const { + DocumentFragment, + HTMLTemplateElement, + Node, + Element, + NodeFilter, + NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap, + HTMLFormElement, + DOMParser, + trustedTypes + } = window; + const ElementPrototype = Element.prototype; + const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); + const remove = lookupGetter(ElementPrototype, 'remove'); + const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); + const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); + const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); + // As per issue #47, the web-components registry is inherited by a + // new document created via createHTMLDocument. As per the spec + // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) + // a new empty registry is used when creating a template contents owner + // document, so we use that as our parent document to ensure nothing + // is inherited. + if (typeof HTMLTemplateElement === 'function') { + const template = document.createElement('template'); + if (template.content && template.content.ownerDocument) { + document = template.content.ownerDocument; + } + } + let trustedTypesPolicy; + let emptyHTML = ''; + const { + implementation, + createNodeIterator, + createDocumentFragment, + getElementsByTagName + } = document; + const { + importNode + } = originalDocument; + let hooks = _createHooksMap(); + /** + * Expose whether this browser supports running the full DOMPurify. + */ + DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; + const { + MUSTACHE_EXPR, + ERB_EXPR, + TMPLIT_EXPR, + DATA_ATTR, + ARIA_ATTR, + IS_SCRIPT_OR_DATA, + ATTR_WHITESPACE, + CUSTOM_ELEMENT + } = EXPRESSIONS; + let { + IS_ALLOWED_URI: IS_ALLOWED_URI$1 + } = EXPRESSIONS; + /** + * We consider the elements and attributes below to be safe. Ideally + * don't add any new ones but feel free to remove unwanted ones. + */ + /* allowed element names */ + let ALLOWED_TAGS = null; + const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); + /* Allowed attribute names */ + let ALLOWED_ATTR = null; + const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); + /* + * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements. + * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements) + * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list) + * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`. + */ + let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { + tagNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + attributeNameCheck: { + writable: true, + configurable: false, + enumerable: true, + value: null + }, + allowCustomizedBuiltInElements: { + writable: true, + configurable: false, + enumerable: true, + value: false + } + })); + /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ + let FORBID_TAGS = null; + /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ + let FORBID_ATTR = null; + /* Decide if ARIA attributes are okay */ + let ALLOW_ARIA_ATTR = true; + /* Decide if custom data attributes are okay */ + let ALLOW_DATA_ATTR = true; + /* Decide if unknown protocols are okay */ + let ALLOW_UNKNOWN_PROTOCOLS = false; + /* Decide if self-closing tags in attributes are allowed. + * Usually removed due to a mXSS issue in jQuery 3.0 */ + let ALLOW_SELF_CLOSE_IN_ATTR = true; + /* Output should be safe for common template engines. + * This means, DOMPurify removes data attributes, mustaches and ERB + */ + let SAFE_FOR_TEMPLATES = false; + /* Output should be safe even for XML used within HTML and alike. + * This means, DOMPurify removes comments when containing risky content. + */ + let SAFE_FOR_XML = true; + /* Decide if document with ... should be returned */ + let WHOLE_DOCUMENT = false; + /* Track whether config is already set on this instance of DOMPurify. */ + let SET_CONFIG = false; + /* Decide if all elements (e.g. style, script) must be children of + * document.body. By default, browsers might move them to document.head */ + let FORCE_BODY = false; + /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported). + * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead + */ + let RETURN_DOM = false; + /* Decide if a DOM `DocumentFragment` should be returned, instead of a html + * string (or a TrustedHTML object if Trusted Types are supported) */ + let RETURN_DOM_FRAGMENT = false; + /* Try to return a Trusted Type object instead of a string, return a string in + * case Trusted Types are not supported */ + let RETURN_TRUSTED_TYPE = false; + /* Output should be free from DOM clobbering attacks? + * This sanitizes markups named with colliding, clobberable built-in DOM APIs. + */ + let SANITIZE_DOM = true; + /* Achieve full DOM Clobbering protection by isolating the namespace of named + * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. + * + * HTML/DOM spec rules that enable DOM Clobbering: + * - Named Access on Window (§7.3.3) + * - DOM Tree Accessors (§3.1.5) + * - Form Element Parent-Child Relations (§4.10.3) + * - Iframe srcdoc / Nested WindowProxies (§4.8.5) + * - HTMLCollection (§4.2.10.2) + * + * Namespace isolation is implemented by prefixing `id` and `name` attributes + * with a constant string, i.e., `user-content-` + */ + let SANITIZE_NAMED_PROPS = false; + const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; + /* Keep element content when removing element? */ + let KEEP_CONTENT = true; + /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead + * of importing it into a new Document and returning a sanitized copy */ + let IN_PLACE = false; + /* Allow usage of profiles like html, svg and mathMl */ + let USE_PROFILES = {}; + /* Tags to ignore content of when KEEP_CONTENT is true */ + let FORBID_CONTENTS = null; + const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); + /* Tags that are safe for data: URIs */ + let DATA_URI_TAGS = null; + const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); + /* Attributes safe for values like "javascript:" */ + let URI_SAFE_ATTRIBUTES = null; + const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']); + const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; + /* Document namespace */ + let NAMESPACE = HTML_NAMESPACE; + let IS_EMPTY_INPUT = false; + /* Allowed XHTML+XML namespaces */ + let ALLOWED_NAMESPACES = null; + const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString); + let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); + let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']); + // Certain elements are allowed in both SVG and HTML + // namespace. We need to specify them explicitly + // so that they don't get erroneously deleted from + // HTML namespace. + const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']); + /* Parsing of strict XHTML documents */ + let PARSER_MEDIA_TYPE = null; + const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html']; + const DEFAULT_PARSER_MEDIA_TYPE = 'text/html'; + let transformCaseFunc = null; + /* Keep a reference to config to pass to hooks */ + let CONFIG = null; + /* Ideally, do not touch anything below this line */ + /* ______________________________________________ */ + const formElement = document.createElement('form'); + const isRegexOrFunction = function isRegexOrFunction(testValue) { + return testValue instanceof RegExp || testValue instanceof Function; + }; + /** + * _parseConfig + * + * @param cfg optional config literal + */ + // eslint-disable-next-line complexity + const _parseConfig = function _parseConfig() { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + if (CONFIG && CONFIG === cfg) { + return; + } + /* Shield configuration object from tampering */ + if (!cfg || typeof cfg !== 'object') { + cfg = {}; + } + /* Shield configuration object from prototype pollution */ + cfg = clone(cfg); + PARSER_MEDIA_TYPE = + // eslint-disable-next-line unicorn/prefer-includes + SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; + // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is. + transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase; + /* Set configuration parameters */ + ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS; + ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR; + ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES; + URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES; + DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS; + FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS; + FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {}; + FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {}; + USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false; + ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true + ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true + ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false + ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true + SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false + SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true + WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false + RETURN_DOM = cfg.RETURN_DOM || false; // Default false + RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false + RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false + FORCE_BODY = cfg.FORCE_BODY || false; // Default false + SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true + SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false + KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true + IN_PLACE = cfg.IN_PLACE || false; // Default false + IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI; + NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE; + MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS; + HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS; + CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {}; + if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) { + CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck; + } + if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) { + CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck; + } + if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') { + CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements; + } + if (SAFE_FOR_TEMPLATES) { + ALLOW_DATA_ATTR = false; + } + if (RETURN_DOM_FRAGMENT) { + RETURN_DOM = true; + } + /* Parse profile info */ + if (USE_PROFILES) { + ALLOWED_TAGS = addToSet({}, text); + ALLOWED_ATTR = []; + if (USE_PROFILES.html === true) { + addToSet(ALLOWED_TAGS, html$1); + addToSet(ALLOWED_ATTR, html); + } + if (USE_PROFILES.svg === true) { + addToSet(ALLOWED_TAGS, svg$1); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.svgFilters === true) { + addToSet(ALLOWED_TAGS, svgFilters); + addToSet(ALLOWED_ATTR, svg); + addToSet(ALLOWED_ATTR, xml); + } + if (USE_PROFILES.mathMl === true) { + addToSet(ALLOWED_TAGS, mathMl$1); + addToSet(ALLOWED_ATTR, mathMl); + addToSet(ALLOWED_ATTR, xml); + } + } + /* Merge configuration parameters */ + if (cfg.ADD_TAGS) { + if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { + ALLOWED_TAGS = clone(ALLOWED_TAGS); + } + addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); + } + if (cfg.ADD_ATTR) { + if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { + ALLOWED_ATTR = clone(ALLOWED_ATTR); + } + addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); + } + if (cfg.ADD_URI_SAFE_ATTR) { + addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc); + } + if (cfg.FORBID_CONTENTS) { + if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) { + FORBID_CONTENTS = clone(FORBID_CONTENTS); + } + addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc); + } + /* Add #text in case KEEP_CONTENT is set to true */ + if (KEEP_CONTENT) { + ALLOWED_TAGS['#text'] = true; + } + /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ + if (WHOLE_DOCUMENT) { + addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); + } + /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ + if (ALLOWED_TAGS.table) { + addToSet(ALLOWED_TAGS, ['tbody']); + delete FORBID_TAGS.tbody; + } + if (cfg.TRUSTED_TYPES_POLICY) { + if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') { + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.'); + } + if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') { + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.'); + } + // Overwrite existing TrustedTypes policy. + trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; + // Sign local variables required by `sanitize`. + emptyHTML = trustedTypesPolicy.createHTML(''); + } else { + // Uninitialized policy, attempt to initialize the internal dompurify policy. + if (trustedTypesPolicy === undefined) { + trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); + } + // If creating the internal policy succeeded sign internal variables. + if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') { + emptyHTML = trustedTypesPolicy.createHTML(''); + } + } + // Prevent further manipulation of configuration. + // Not available in IE8, Safari 5, etc. + if (freeze) { + freeze(cfg); + } + CONFIG = cfg; + }; + /* Keep track of all possible SVG and MathML tags + * so that we can perform the namespace checks + * correctly. */ + const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]); + const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); + /** + * @param element a DOM element whose namespace is being checked + * @returns Return false if the element has a + * namespace that a spec-compliant parser would never + * return. Return true otherwise. + */ + const _checkValidNamespace = function _checkValidNamespace(element) { + let parent = getParentNode(element); + // In JSDOM, if we're inside shadow DOM, then parentNode + // can be null. We just simulate parent in this case. + if (!parent || !parent.tagName) { + parent = { + namespaceURI: NAMESPACE, + tagName: 'template' + }; + } + const tagName = stringToLowerCase(element.tagName); + const parentTagName = stringToLowerCase(parent.tagName); + if (!ALLOWED_NAMESPACES[element.namespaceURI]) { + return false; + } + if (element.namespaceURI === SVG_NAMESPACE) { + // The only way to switch from HTML namespace to SVG + // is via . If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'svg'; + } + // The only way to switch from MathML to SVG is via` + // svg if parent is either or MathML + // text integration points. + if (parent.namespaceURI === MATHML_NAMESPACE) { + return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); + } + // We only allow elements that are defined in SVG + // spec. All others are disallowed in SVG namespace. + return Boolean(ALL_SVG_TAGS[tagName]); + } + if (element.namespaceURI === MATHML_NAMESPACE) { + // The only way to switch from HTML namespace to MathML + // is via . If it happens via any other tag, then + // it should be killed. + if (parent.namespaceURI === HTML_NAMESPACE) { + return tagName === 'math'; + } + // The only way to switch from SVG to MathML is via + // and HTML integration points + if (parent.namespaceURI === SVG_NAMESPACE) { + return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; + } + // We only allow elements that are defined in MathML + // spec. All others are disallowed in MathML namespace. + return Boolean(ALL_MATHML_TAGS[tagName]); + } + if (element.namespaceURI === HTML_NAMESPACE) { + // The only way to switch from SVG to HTML is via + // HTML integration points, and from MathML to HTML + // is via MathML text integration points + if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { + return false; + } + if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { + return false; + } + // We disallow tags that are specific for MathML + // or SVG and should never appear in HTML namespace + return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); + } + // For XHTML and XML documents that support custom namespaces + if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) { + return true; + } + // The code should never reach this place (this means + // that the element somehow got namespace that is not + // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES). + // Return false just in case. + return false; + }; + /** + * _forceRemove + * + * @param node a DOM node + */ + const _forceRemove = function _forceRemove(node) { + arrayPush(DOMPurify.removed, { + element: node + }); + try { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + getParentNode(node).removeChild(node); + } catch (_) { + remove(node); + } + }; + /** + * _removeAttribute + * + * @param name an Attribute name + * @param element a DOM node + */ + const _removeAttribute = function _removeAttribute(name, element) { + try { + arrayPush(DOMPurify.removed, { + attribute: element.getAttributeNode(name), + from: element + }); + } catch (_) { + arrayPush(DOMPurify.removed, { + attribute: null, + from: element + }); + } + element.removeAttribute(name); + // We void attribute values for unremovable "is" attributes + if (name === 'is') { + if (RETURN_DOM || RETURN_DOM_FRAGMENT) { + try { + _forceRemove(element); + } catch (_) {} + } else { + try { + element.setAttribute(name, ''); + } catch (_) {} + } + } + }; + /** + * _initDocument + * + * @param dirty - a string of dirty markup + * @return a DOM, filled with the dirty markup + */ + const _initDocument = function _initDocument(dirty) { + /* Create a HTML document */ + let doc = null; + let leadingWhitespace = null; + if (FORCE_BODY) { + dirty = '' + dirty; + } else { + /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ + const matches = stringMatch(dirty, /^[\r\n\t ]+/); + leadingWhitespace = matches && matches[0]; + } + if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) { + // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict) + dirty = '' + dirty + ''; + } + const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; + /* + * Use the DOMParser API by default, fallback later if needs be + * DOMParser not work for svg when has multiple root element. + */ + if (NAMESPACE === HTML_NAMESPACE) { + try { + doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE); + } catch (_) {} + } + /* Use createHTMLDocument in case DOMParser is not available */ + if (!doc || !doc.documentElement) { + doc = implementation.createDocument(NAMESPACE, 'template', null); + try { + doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload; + } catch (_) { + // Syntax error if dirtyPayload is invalid xml + } + } + const body = doc.body || doc.documentElement; + if (dirty && leadingWhitespace) { + body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null); + } + /* Work on whole document or just its body */ + if (NAMESPACE === HTML_NAMESPACE) { + return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; + } + return WHOLE_DOCUMENT ? doc.documentElement : body; + }; + /** + * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document. + * + * @param root The root element or node to start traversing on. + * @return The created NodeIterator + */ + const _createNodeIterator = function _createNodeIterator(root) { + return createNodeIterator.call(root.ownerDocument || root, root, + // eslint-disable-next-line no-bitwise + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null); + }; + /** + * _isClobbered + * + * @param element element to check for clobbering attacks + * @return true if clobbered, false if safe + */ + const _isClobbered = function _isClobbered(element) { + return element instanceof HTMLFormElement && (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function'); + }; + /** + * Checks whether the given object is a DOM node. + * + * @param value object to check whether it's a DOM node + * @return true is object is a DOM node + */ + const _isNode = function _isNode(value) { + return typeof Node === 'function' && value instanceof Node; + }; + function _executeHooks(hooks, currentNode, data) { + arrayForEach(hooks, hook => { + hook.call(DOMPurify, currentNode, data, CONFIG); + }); + } + /** + * _sanitizeElements + * + * @protect nodeName + * @protect textContent + * @protect removeChild + * @param currentNode to check for permission to exist + * @return true if node was killed, false if left alive + */ + const _sanitizeElements = function _sanitizeElements(currentNode) { + let content = null; + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeElements, currentNode, null); + /* Check if element is clobbered or can clobber */ + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + return true; + } + /* Now let's check the element's type and name */ + const tagName = transformCaseFunc(currentNode.nodeName); + /* Execute a hook if present */ + _executeHooks(hooks.uponSanitizeElement, currentNode, { + tagName, + allowedTags: ALLOWED_TAGS + }); + /* Detect mXSS attempts abusing namespace confusion */ + if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { + _forceRemove(currentNode); + return true; + } + /* Remove any occurrence of processing instructions */ + if (currentNode.nodeType === NODE_TYPE.progressingInstruction) { + _forceRemove(currentNode); + return true; + } + /* Remove any kind of possibly harmful comments */ + if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\w]/g, currentNode.data)) { + _forceRemove(currentNode); + return true; + } + /* Remove element if anything forbids its presence */ + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + /* Check if we have a custom element to handle */ + if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) { + if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) { + return false; + } + if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) { + return false; + } + } + /* Keep content except for bad-listed elements */ + if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { + const parentNode = getParentNode(currentNode) || currentNode.parentNode; + const childNodes = getChildNodes(currentNode) || currentNode.childNodes; + if (childNodes && parentNode) { + const childCount = childNodes.length; + for (let i = childCount - 1; i >= 0; --i) { + const childClone = cloneNode(childNodes[i], true); + childClone.__removalCount = (currentNode.__removalCount || 0) + 1; + parentNode.insertBefore(childClone, getNextSibling(currentNode)); + } + } + } + _forceRemove(currentNode); + return true; + } + /* Check whether element has a valid namespace */ + if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { + _forceRemove(currentNode); + return true; + } + /* Make sure that older browsers don't get fallback-tag mXSS */ + if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) { + _forceRemove(currentNode); + return true; + } + /* Sanitize element content to be template-safe */ + if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) { + /* Get the element's text content */ + content = currentNode.textContent; + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + content = stringReplace(content, expr, ' '); + }); + if (currentNode.textContent !== content) { + arrayPush(DOMPurify.removed, { + element: currentNode.cloneNode() + }); + currentNode.textContent = content; + } + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeElements, currentNode, null); + return false; + }; + /** + * _isValidAttribute + * + * @param lcTag Lowercase tag name of containing element. + * @param lcName Lowercase attribute name. + * @param value Attribute value. + * @return Returns true if `value` is valid, otherwise false. + */ + // eslint-disable-next-line complexity + const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { + /* Make sure attribute cannot clobber */ + if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { + return false; + } + /* Allow valid data-* attributes: At least one character after "-" + (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes) + XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804) + We don't need to check the value; it's always URI safe. */ + if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ; else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) { + if ( + // First condition does a very basic check if a) it's basically a valid custom element tagname AND + // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck + // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck + _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || + // Alternative, second condition checks if it's an `is`-attribute, AND + // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck + lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ; else { + return false; + } + /* Check value is safe. First, is attr inert? If so, is safe */ + } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if (value) { + return false; + } else ; + return true; + }; + /** + * _isBasicCustomElement + * checks if at least one dash is included in tagName, and it's not the first char + * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name + * + * @param tagName name of the tag of the node to sanitize + * @returns Returns true if the tag name meets the basic criteria for a custom element, otherwise false. + */ + const _isBasicCustomElement = function _isBasicCustomElement(tagName) { + return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT); + }; + /** + * _sanitizeAttributes + * + * @protect attributes + * @protect nodeName + * @protect removeAttribute + * @protect setAttribute + * + * @param currentNode to sanitize + */ + const _sanitizeAttributes = function _sanitizeAttributes(currentNode) { + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null); + const { + attributes + } = currentNode; + /* Check if we have attributes; if not we might have a text node */ + if (!attributes || _isClobbered(currentNode)) { + return; + } + const hookEvent = { + attrName: '', + attrValue: '', + keepAttr: true, + allowedAttributes: ALLOWED_ATTR, + forceKeepAttr: undefined + }; + let l = attributes.length; + /* Go backwards over all attributes; safely remove bad ones */ + while (l--) { + const attr = attributes[l]; + const { + name, + namespaceURI, + value: attrValue + } = attr; + const lcName = transformCaseFunc(name); + let value = name === 'value' ? attrValue : stringTrim(attrValue); + /* Execute a hook if present */ + hookEvent.attrName = lcName; + hookEvent.attrValue = value; + hookEvent.keepAttr = true; + hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set + _executeHooks(hooks.uponSanitizeAttribute, currentNode, hookEvent); + value = hookEvent.attrValue; + /* Full DOM Clobbering protection via namespace isolation, + * Prefix id and name attributes with `user-content-` + */ + if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) { + // Remove the attribute with this value + _removeAttribute(name, currentNode); + // Prefix the value and later re-create the attribute with the sanitized value + value = SANITIZE_NAMED_PROPS_PREFIX + value; + } + /* Work around a security issue with comments inside attributes */ + if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title)/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + /* Did the hooks approve of the attribute? */ + if (hookEvent.forceKeepAttr) { + continue; + } + /* Remove attribute */ + _removeAttribute(name, currentNode); + /* Did the hooks approve of the attribute? */ + if (!hookEvent.keepAttr) { + continue; + } + /* Work around a security issue in jQuery 3.0 */ + if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) { + _removeAttribute(name, currentNode); + continue; + } + /* Sanitize attribute content to be template-safe */ + if (SAFE_FOR_TEMPLATES) { + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + value = stringReplace(value, expr, ' '); + }); + } + /* Is `value` valid for this attribute? */ + const lcTag = transformCaseFunc(currentNode.nodeName); + if (!_isValidAttribute(lcTag, lcName, value)) { + continue; + } + /* Handle attributes that require Trusted Types */ + if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') { + if (namespaceURI) ; else { + switch (trustedTypes.getAttributeType(lcTag, lcName)) { + case 'TrustedHTML': + { + value = trustedTypesPolicy.createHTML(value); + break; + } + case 'TrustedScriptURL': + { + value = trustedTypesPolicy.createScriptURL(value); + break; + } + } + } + } + /* Handle invalid data-* attribute set by try-catching it */ + try { + if (namespaceURI) { + currentNode.setAttributeNS(namespaceURI, name, value); + } else { + /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ + currentNode.setAttribute(name, value); + } + if (_isClobbered(currentNode)) { + _forceRemove(currentNode); + } else { + arrayPop(DOMPurify.removed); + } + } catch (_) {} + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeAttributes, currentNode, null); + }; + /** + * _sanitizeShadowDOM + * + * @param fragment to iterate over recursively + */ + const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { + let shadowNode = null; + const shadowIterator = _createNodeIterator(fragment); + /* Execute a hook if present */ + _executeHooks(hooks.beforeSanitizeShadowDOM, fragment, null); + while (shadowNode = shadowIterator.nextNode()) { + /* Execute a hook if present */ + _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null); + /* Sanitize tags and elements */ + _sanitizeElements(shadowNode); + /* Check attributes next */ + _sanitizeAttributes(shadowNode); + /* Deep shadow DOM detected */ + if (shadowNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(shadowNode.content); + } + } + /* Execute a hook if present */ + _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null); + }; + // eslint-disable-next-line complexity + DOMPurify.sanitize = function (dirty) { + let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + let body = null; + let importedNode = null; + let currentNode = null; + let returnNode = null; + /* Make sure we have a string to sanitize. + DO NOT return early, as this will return the wrong type if + the user has requested a DOM object rather than a string */ + IS_EMPTY_INPUT = !dirty; + if (IS_EMPTY_INPUT) { + dirty = ''; + } + /* Stringify, in case dirty is an object */ + if (typeof dirty !== 'string' && !_isNode(dirty)) { + if (typeof dirty.toString === 'function') { + dirty = dirty.toString(); + if (typeof dirty !== 'string') { + throw typeErrorCreate('dirty is not a string, aborting'); + } + } else { + throw typeErrorCreate('toString is not a function'); + } + } + /* Return dirty HTML if DOMPurify cannot run */ + if (!DOMPurify.isSupported) { + return dirty; + } + /* Assign config vars */ + if (!SET_CONFIG) { + _parseConfig(cfg); + } + /* Clean up removed elements */ + DOMPurify.removed = []; + /* Check if dirty is correctly typed for IN_PLACE */ + if (typeof dirty === 'string') { + IN_PLACE = false; + } + if (IN_PLACE) { + /* Do some early pre-sanitization to avoid unsafe root nodes */ + if (dirty.nodeName) { + const tagName = transformCaseFunc(dirty.nodeName); + if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { + throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place'); + } + } + } else if (dirty instanceof Node) { + /* If dirty is a DOM element, append to an empty document to avoid + elements being stripped by the parser */ + body = _initDocument(''); + importedNode = body.ownerDocument.importNode(dirty, true); + if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === 'BODY') { + /* Node is already a body, use as is */ + body = importedNode; + } else if (importedNode.nodeName === 'HTML') { + body = importedNode; + } else { + // eslint-disable-next-line unicorn/prefer-dom-node-append + body.appendChild(importedNode); + } + } else { + /* Exit directly if we have nothing to do */ + if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && + // eslint-disable-next-line unicorn/prefer-includes + dirty.indexOf('<') === -1) { + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; + } + /* Initialize the document to work on */ + body = _initDocument(dirty); + /* Check we have a DOM node from the data */ + if (!body) { + return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : ''; + } + } + /* Remove first element node (ours) if FORCE_BODY is set */ + if (body && FORCE_BODY) { + _forceRemove(body.firstChild); + } + /* Get node iterator */ + const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body); + /* Now start iterating over the created document */ + while (currentNode = nodeIterator.nextNode()) { + /* Sanitize tags and elements */ + _sanitizeElements(currentNode); + /* Check attributes next */ + _sanitizeAttributes(currentNode); + /* Shadow DOM detected, sanitize it */ + if (currentNode.content instanceof DocumentFragment) { + _sanitizeShadowDOM(currentNode.content); + } + } + /* If we sanitized `dirty` in-place, return it. */ + if (IN_PLACE) { + return dirty; + } + /* Return sanitized string or DOM */ + if (RETURN_DOM) { + if (RETURN_DOM_FRAGMENT) { + returnNode = createDocumentFragment.call(body.ownerDocument); + while (body.firstChild) { + // eslint-disable-next-line unicorn/prefer-dom-node-append + returnNode.appendChild(body.firstChild); + } + } else { + returnNode = body; + } + if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) { + /* + AdoptNode() is not used because internal state is not reset + (e.g. the past names map of a HTMLFormElement), this is safe + in theory but we would rather not risk another attack vector. + The state that is cloned by importNode() is explicitly defined + by the specs. + */ + returnNode = importNode.call(originalDocument, returnNode, true); + } + return returnNode; + } + let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; + /* Serialize doctype if allowed */ + if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) { + serializedHTML = '\n' + serializedHTML; + } + /* Sanitize final string template-safe */ + if (SAFE_FOR_TEMPLATES) { + arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { + serializedHTML = stringReplace(serializedHTML, expr, ' '); + }); + } + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; + }; + DOMPurify.setConfig = function () { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + _parseConfig(cfg); + SET_CONFIG = true; + }; + DOMPurify.clearConfig = function () { + CONFIG = null; + SET_CONFIG = false; + }; + DOMPurify.isValidAttribute = function (tag, attr, value) { + /* Initialize shared config vars if necessary. */ + if (!CONFIG) { + _parseConfig({}); + } + const lcTag = transformCaseFunc(tag); + const lcName = transformCaseFunc(attr); + return _isValidAttribute(lcTag, lcName, value); + }; + DOMPurify.addHook = function (entryPoint, hookFunction) { + if (typeof hookFunction !== 'function') { + return; + } + arrayPush(hooks[entryPoint], hookFunction); + }; + DOMPurify.removeHook = function (entryPoint, hookFunction) { + if (hookFunction !== undefined) { + const index = arrayLastIndexOf(hooks[entryPoint], hookFunction); + return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0]; + } + return arrayPop(hooks[entryPoint]); + }; + DOMPurify.removeHooks = function (entryPoint) { + hooks[entryPoint] = []; + }; + DOMPurify.removeAllHooks = function () { + hooks = _createHooksMap(); + }; + return DOMPurify; +} +var purify = createDOMPurify(); + +export { purify as default }; +//# sourceMappingURL=purify.es.mjs.map diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/README.md new file mode 100644 index 00000000000..352b52d5503 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/README.md @@ -0,0 +1,5 @@ +marked version 15.0.6 +https://github.com/markedjs/marked +License: MIT + +To update, replace the `dist/marked.esm.js` file with with an updated version from https://www.npmjs.com/package/marked. diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/dist/marked.esm.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/dist/marked.esm.js new file mode 100644 index 00000000000..a32cd778363 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/marked/dist/marked.esm.js @@ -0,0 +1,2568 @@ +/** + * marked v15.0.6 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null, + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +const noopTest = { exec: () => null }; +function edit(regex, opt = '') { + let source = typeof regex === 'string' ? regex : regex.source; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(other.caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + }, + }; + return obj; +} +const other = { + codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, + outputLinkReplace: /\\([\[\]])/g, + indentCodeCompensation: /^(\s+)(?:```)/, + beginningSpace: /^\s+/, + endingHash: /#$/, + startingSpaceChar: /^ /, + endingSpaceChar: / $/, + nonSpaceChar: /[^ ]/, + newLineCharGlobal: /\n/g, + tabCharGlobal: /\t/g, + multipleSpaceGlobal: /\s+/g, + blankLine: /^[ \t]*$/, + doubleBlankLine: /\n[ \t]*\n[ \t]*$/, + blockquoteStart: /^ {0,3}>/, + blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, + blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, + listReplaceTabs: /^\t+/, + listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, + listIsTask: /^\[[ xX]\] /, + listReplaceTask: /^\[[ xX]\] +/, + anyLine: /\n.*\n/, + hrefBrackets: /^<(.*)>$/, + tableDelimiter: /[:|]/, + tableAlignChars: /^\||\| *$/g, + tableRowBlankLine: /\n[ \t]*$/, + tableAlignRight: /^ *-+: *$/, + tableAlignCenter: /^ *:-+: *$/, + tableAlignLeft: /^ *:-+ *$/, + startATag: /^/i, + startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, + endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, + startAngleBracket: /^$/, + pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, + unicodeAlphaNumeric: /[\p{L}\p{N}]/u, + escapeTest: /[&<>"']/, + escapeReplace: /[&<>"']/g, + escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, + unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, + caret: /(^|[^\[])\^/g, + percentDecode: /%25/g, + findPipe: /\|/g, + splitPipe: / \|/, + slashPipe: /\\\|/g, + carriageReturn: /\r\n|\r/g, + spaceLine: /^ +$/gm, + notSpaceStart: /^\S*/, + endingNewline: /\n$/, + listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), + nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`), + hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), + fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), + headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), + htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, 'i'), +}; +/** + * Block-Level Grammar + */ +const newline = /^(?:[ \t]*(?:\n|$))+/; +const blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheading = edit(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' + + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' + + '|tr|track|ul'; +const _comment = /|$))/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText, +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', '(?: {4}| {0,3}\t)[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(), +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .replace(/tag/g, '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') + .getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex(), +}; +/** + * Inline-Level Grammar + */ +const escape$1 = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ +const blockSkip = /\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; +const emStrongLDelim = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongLDelimGfm = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +const emStrongRDelimAstCore = '^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)punct(\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=punct)' // (4) ***# can only be Left Delimiter + + '|(?!\\*)punct(\\*+)(?!\\*)(?=punct)' // (5) #***# can be either Left or Right Delimiter + + '|notPunctSpace(\\*+)(?=notPunctSpace)'; // (6) a***a can be either Left or Right Delimiter +const emStrongRDelimAst = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm) + .replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm) + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)punct(_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|notPunctSpace(_+)(?!_)(?=punctSpace|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)punctSpace(_+)(?=notPunctSpace)' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=punct)' // (4) ___# can only be Left Delimiter + + '|(?!_)punct(_+)(?!_)(?=punct)', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\(punct)/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) + .getRegex(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape: escape$1, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest, +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex(), +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + emStrongRDelimAst: emStrongRDelimAstGfm, + emStrongLDelim: emStrongLDelimGfm, + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') + .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) + .getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\': '>', + '"': '"', + "'": ''', +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape(html, encode) { + if (encode) { + if (other.escapeTest.test(html)) { + return html.replace(other.escapeReplace, getEscapeReplacement); + } + } + else { + if (other.escapeTestNoEncode.test(html)) { + return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(other.percentDecode, '%'); + } + catch { + return null; + } + return href; +} +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(other.findPipe, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(other.splitPipe); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells.at(-1)?.trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(other.slashPipe, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && true) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer, rules) { + const href = link.href; + const title = link.title || null; + const text = cap[1].replace(rules.other.outputLinkReplace, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text), + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text, + }; +} +function indentCodeCompensation(raw, text, rules) { + const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(rules.other.beginningSpace); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0], + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(this.rules.other.codeRemoveIndent, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text, + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || '', this.rules); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text, + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (this.rules.other.endingHash.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text), + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: rtrim(cap[0], '\n'), + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], '\n').split('\n'); + let raw = ''; + let text = ''; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + // get lines up to a continuation + if (this.rules.other.blockquoteStart.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } + else if (!inBlockquote) { + currentLines.push(lines[i]); + } + else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join('\n'); + const currentText = currentRaw + // precede setext continuation with 4 spaces so it isn't a setext + .replace(this.rules.other.blockquoteSetextReplace, '\n $1') + .replace(this.rules.other.blockquoteSetextReplace2, ''); + raw = raw ? `${raw}\n${currentRaw}` : currentRaw; + text = text ? `${text}\n${currentText}` : currentText; + // parse blockquote lines as top level tokens + // merge paragraphs if this is a continuation + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + // if there is no continuation then we are done + if (lines.length === 0) { + break; + } + const lastToken = tokens.at(-1); + if (lastToken?.type === 'code') { + // blockquote continuation cannot be preceded by a code block + break; + } + else if (lastToken?.type === 'blockquote') { + // include continuation in nested blockquote + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } + else if (lastToken?.type === 'list') { + // include continuation in nested list + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens.at(-1).raw.length).split('\n'); + continue; + } + } + return { + type: 'blockquote', + raw, + tokens, + text, + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [], + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = this.rules.other.listItemRegex(bull); + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + let raw = ''; + let itemContents = ''; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(this.rules.other.listReplaceTabs, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else if (blankLine) { + indent = cap[1].length + 1; + } + else { + indent = cap[2].search(this.rules.other.nonSpaceChar); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && this.rules.other.blankLine.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = this.rules.other.nextBulletRegex(indent); + const hrRegex = this.rules.other.hrRegex(indent); + const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); + const headingBeginRegex = this.rules.other.headingBeginRegex(indent); + const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + let nextLineWithoutTabs; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(this.rules.other.listReplaceNesting, ' '); + nextLineWithoutTabs = nextLine; + } + else { + nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of html block + if (htmlBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(nextLine)) { + break; + } + if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLineWithoutTabs.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.replace(this.rules.other.tabCharGlobal, ' ').search(this.rules.other.nonSpaceChar) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLineWithoutTabs.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (this.rules.other.doubleBlankLine.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = this.rules.other.listIsTask.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(this.rules.other.listReplaceTask, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [], + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + const lastItem = list.items.at(-1); + if (lastItem) { + lastItem.raw = lastItem.raw.trimEnd(); + lastItem.text = lastItem.text.trimEnd(); + } + else { + // not a list since there were no items + return; + } + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => this.rules.other.anyLine.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0], + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, ' '); + const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title, + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!this.rules.other.tableDelimiter.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(this.rules.other.tableAlignChars, '').split('|'); + const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [], + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (this.rules.other.tableAlignRight.test(align)) { + item.align.push('right'); + } + else if (this.rules.other.tableAlignCenter.test(align)) { + item.align.push('center'); + } + else if (this.rules.other.tableAlignLeft.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i], + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i], + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]), + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text), + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]), + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: cap[1], + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { + this.lexer.state.inLink = true; + } + else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0], + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { + // commonmark requires matching angle brackets + if (!(this.rules.other.endAngleBracket.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = this.rules.other.pedanticHrefTitle.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (this.rules.other.startAngleBracket.test(href)) { + if (this.options.pedantic && !(this.rules.other.endAngleBracket.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, + }, cap[0], this.lexer, this.rules); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text, + }; + } + return outputLink(cap, link, cap[0], this.lexer, this.rules); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(this.rules.other.newLineCharGlobal, ' '); + const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); + const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + return { + type: 'codespan', + raw: cap[0], + text, + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0], + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]), + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = cap[1]; + href = 'mailto:' + text; + } + else { + text = cap[1]; + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = cap[0]; + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = cap[0]; + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + const escaped = this.lexer.state.inRawBlock; + return { + type: 'text', + raw: cap[0], + text: cap[0], + escaped, + }; + } + } +} + +/** + * Block Lexer + */ +class _Lexer { + tokens; + options; + state; + tokenizer; + inlineQueue; + constructor(options) { + // TokenList cannot be created in one go + this.tokens = []; + this.tokens.links = Object.create(null); + this.options = options || _defaults; + this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); + this.tokenizer = this.options.tokenizer; + this.tokenizer.options = this.options; + this.tokenizer.lexer = this; + this.inlineQueue = []; + this.state = { + inLink: false, + inRawBlock: false, + top: true, + }; + const rules = { + other, + block: block.normal, + inline: inline.normal, + }; + if (this.options.pedantic) { + rules.block = block.pedantic; + rules.inline = inline.pedantic; + } + else if (this.options.gfm) { + rules.block = block.gfm; + if (this.options.breaks) { + rules.inline = inline.breaks; + } + else { + rules.inline = inline.gfm; + } + } + this.tokenizer.rules = rules; + } + /** + * Expose Rules + */ + static get rules() { + return { + block, + inline, + }; + } + /** + * Static Lex Method + */ + static lex(src, options) { + const lexer = new _Lexer(options); + return lexer.lex(src); + } + /** + * Static Lex Inline Method + */ + static lexInline(src, options) { + const lexer = new _Lexer(options); + return lexer.inlineTokens(src); + } + /** + * Preprocessing + */ + lex(src) { + src = src.replace(other.carriageReturn, '\n'); + this.blockTokens(src, this.tokens); + for (let i = 0; i < this.inlineQueue.length; i++) { + const next = this.inlineQueue[i]; + this.inlineTokens(next.src, next.tokens); + } + this.inlineQueue = []; + return this.tokens; + } + blockTokens(src, tokens = [], lastParagraphClipped = false) { + if (this.options.pedantic) { + src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''); + } + while (src) { + let token; + if (this.options.extensions?.block?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.raw.length === 1 && lastToken !== undefined) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + lastToken.raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + // An indented code block cannot interrupt a paragraph. + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue.at(-1).src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title, + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + const lastToken = tokens.at(-1); + if (lastParagraphClipped && lastToken?.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = cutSrc.length !== src.length; + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match = null; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + + '[' + 'a'.repeat(match[0].length - 2) + ']' + + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + let keepPrevChar = false; + let prevChar = ''; + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + let token; + // extensions + if (this.options.extensions?.inline?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.type === 'text' && lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + parser; // set by the parser + constructor(options) { + this.options = options || _defaults; + } + space(token) { + return ''; + } + code({ text, lang, escaped }) { + const langString = (lang || '').match(other.notSpaceStart)?.[0]; + const code = text.replace(other.endingNewline, '') + '\n'; + if (!langString) { + return '
    '
    +                + (escaped ? code : escape(code, true))
    +                + '
    \n'; + } + return '
    '
    +            + (escaped ? code : escape(code, true))
    +            + '
    \n'; + } + blockquote({ tokens }) { + const body = this.parser.parse(tokens); + return `
    \n${body}
    \n`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `${this.parser.parseInline(tokens)}\n`; + } + hr(token) { + return '
    \n'; + } + list(token) { + const ordered = token.ordered; + const start = token.start; + let body = ''; + for (let j = 0; j < token.items.length; j++) { + const item = token.items[j]; + body += this.listitem(item); + } + const type = ordered ? 'ol' : 'ul'; + const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; + return '<' + type + startAttr + '>\n' + body + '\n'; + } + listitem(item) { + let itemBody = ''; + if (item.task) { + const checkbox = this.checkbox({ checked: !!item.checked }); + if (item.loose) { + if (item.tokens[0]?.type === 'paragraph') { + item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { + item.tokens[0].tokens[0].text = checkbox + ' ' + escape(item.tokens[0].tokens[0].text); + item.tokens[0].tokens[0].escaped = true; + } + } + else { + item.tokens.unshift({ + type: 'text', + raw: checkbox + ' ', + text: checkbox + ' ', + escaped: true, + }); + } + } + else { + itemBody += checkbox + ' '; + } + } + itemBody += this.parser.parse(item.tokens, !!item.loose); + return `
  • ${itemBody}
  • \n`; + } + checkbox({ checked }) { + return ''; + } + paragraph({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    \n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `${body}`; + return '\n' + + '\n' + + header + + '\n' + + body + + '
    \n'; + } + tablerow({ text }) { + return `\n${text}\n`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? 'th' : 'td'; + const tag = token.align + ? `<${type} align="${token.align}">` + : `<${type}>`; + return tag + content + `\n`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${escape(text, true)}`; + } + br(token) { + return '
    '; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    '; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return escape(text); + } + href = cleanHref; + let out = `${text} { + const tokens = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens, callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (['options', 'parser'].includes(prop)) { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (['options', 'block'].includes(prop)) { + // ignore options and block properties + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + parseMarkdown(blockType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parse = (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + // throw error if an extension set async to true but parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); + } + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); + const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + return parse; + } + onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '

    An error occurred:

    '
    +                    + escape(e.message + '', true)
    +                    + '
    '; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; +//# sourceMappingURL=marked.esm.js.map diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.html b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.html new file mode 100644 index 00000000000..32ac36286a7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.html @@ -0,0 +1,31 @@ + + + + + + PDF viewer + + + + + + +
    +
    +
    + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.mjs new file mode 100644 index 00000000000..8a4a6b76f5e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdf_viewer/viewer.mjs @@ -0,0 +1,62 @@ +import { GlobalWorkerOptions } from '../pdfjs-dist/dist/build/pdf.min.mjs'; +import { EventBus, PDFLinkService, PDFFindController, PDFViewer } from '../pdfjs-dist/dist/web/pdf_viewer.mjs'; + +GlobalWorkerOptions.workerSrc = '../pdfjs-dist/dist/build/pdf.worker.min.mjs'; + +// Extract the file path from the URL query string. +const url = new URL(window.location); +const fileUrl = url.searchParams.get('file'); +if (!fileUrl) { + throw new Error('File not specified in the URL query string'); +} + +const container = document.getElementById('viewerContainer'); +const eventBus = new EventBus(); + +// Enable hyperlinks within PDF files. +const pdfLinkService = new PDFLinkService({ + eventBus, +}); + +// Enable the find controller. +const pdfFindController = new PDFFindController({ + eventBus, + linkService: pdfLinkService, +}); + +// Create the PDF viewer. +const pdfViewer = new PDFViewer({ + container, + eventBus, + linkService: pdfLinkService, + findController: pdfFindController, +}); +pdfLinkService.setViewer(pdfViewer); + +// Allow navigation to a citation from the URL hash. +eventBus.on('pagesinit', function () { + pdfLinkService.setHash(window.location.hash.substring(1)); +}); + +// Define how the "search" query parameter is handled. +eventBus.on('findfromurlhash', function(evt) { + eventBus.dispatch('find', { + source: evt.source, + type: '', + query: evt.query, + caseSensitive: false, + entireWord: false, + highlightAll: false, + findPrevious: false, + matchDiacritics: true, + }); +}); + +// Load and initialize the document. +const pdfDocument = await pdfjsLib.getDocument({ + url: fileUrl, + enableXfa: true, +}).promise; + +pdfViewer.setDocument(pdfDocument); +pdfLinkService.setDocument(pdfDocument, null); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/README.md new file mode 100644 index 00000000000..8e77fba7d43 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/README.md @@ -0,0 +1,10 @@ +pdfjs-dist version 4.10.38 +https://github.com/mozilla/pdf.js +License: Apache-2.0 + +To update, replace the following files with updated versions from https://www.npmjs.com/package/pdfjs-dist: +* `build/pdf.min.mjs` +* `build/pdf.worker.min.mjs` +* `web/pdf_viewer.css` +* `web/pdf_viewer.mjs` +* `web/images/loading-icon.gif` diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs new file mode 100644 index 00000000000..d7cfa914562 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.min.mjs @@ -0,0 +1,21 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2024 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */var t={d:(e,i)=>{for(var s in i)t.o(i,s)&&!t.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:i[s]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)},__webpack_exports__ = globalThis.pdfjsLib = {};t.d(__webpack_exports__,{AbortException:()=>AbortException,AnnotationEditorLayer:()=>AnnotationEditorLayer,AnnotationEditorParamsType:()=>m,AnnotationEditorType:()=>g,AnnotationEditorUIManager:()=>AnnotationEditorUIManager,AnnotationLayer:()=>AnnotationLayer,AnnotationMode:()=>p,ColorPicker:()=>ColorPicker,DOMSVGFactory:()=>DOMSVGFactory,DrawLayer:()=>DrawLayer,FeatureTest:()=>util_FeatureTest,GlobalWorkerOptions:()=>GlobalWorkerOptions,ImageKind:()=>_,InvalidPDFException:()=>InvalidPDFException,MissingPDFException:()=>MissingPDFException,OPS:()=>X,OutputScale:()=>OutputScale,PDFDataRangeTransport:()=>PDFDataRangeTransport,PDFDateString:()=>PDFDateString,PDFWorker:()=>PDFWorker,PasswordResponses:()=>K,PermissionFlag:()=>f,PixelsPerInch:()=>PixelsPerInch,RenderingCancelledException:()=>RenderingCancelledException,TextLayer:()=>TextLayer,TouchManager:()=>TouchManager,UnexpectedResponseException:()=>UnexpectedResponseException,Util:()=>Util,VerbosityLevel:()=>q,XfaLayer:()=>XfaLayer,build:()=>Nt,createValidAbsoluteUrl:()=>createValidAbsoluteUrl,fetchData:()=>fetchData,getDocument:()=>getDocument,getFilenameFromUrl:()=>getFilenameFromUrl,getPdfFilenameFromUrl:()=>getPdfFilenameFromUrl,getXfaPageViewport:()=>getXfaPageViewport,isDataScheme:()=>isDataScheme,isPdfFile:()=>isPdfFile,noContextMenu:()=>noContextMenu,normalizeUnicode:()=>normalizeUnicode,setLayerDimensions:()=>setLayerDimensions,shadow:()=>shadow,stopEvent:()=>stopEvent,version:()=>Ot});const e=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],s=[.001,0,0,.001,0,0],n=1.35,a=1,r=2,o=4,l=16,h=32,d=64,c=128,u=256,p={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3},g={DISABLE:-1,NONE:0,FREETEXT:3,HIGHLIGHT:9,STAMP:13,INK:15},m={RESIZE:1,CREATE:2,FREETEXT_SIZE:11,FREETEXT_COLOR:12,FREETEXT_OPACITY:13,INK_COLOR:21,INK_THICKNESS:22,INK_OPACITY:23,HIGHLIGHT_COLOR:31,HIGHLIGHT_DEFAULT_COLOR:32,HIGHLIGHT_THICKNESS:33,HIGHLIGHT_FREE:34,HIGHLIGHT_SHOW_ALL:35,DRAW_STEP:41},f={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},b=0,A=1,w=2,v=3,y=3,x=4,_={GRAYSCALE_1BPP:1,RGB_24BPP:2,RGBA_32BPP:3},E=1,S=2,C=3,T=4,M=5,P=6,D=7,k=8,R=9,I=10,F=11,L=12,O=13,N=14,B=15,H=16,z=17,U=20,G=1,$=2,V=3,j=4,W=5,q={ERRORS:0,WARNINGS:1,INFOS:5},X={dependency:1,setLineWidth:2,setLineCap:3,setLineJoin:4,setMiterLimit:5,setDash:6,setRenderingIntent:7,setFlatness:8,setGState:9,save:10,restore:11,transform:12,moveTo:13,lineTo:14,curveTo:15,curveTo2:16,curveTo3:17,closePath:18,rectangle:19,stroke:20,closeStroke:21,fill:22,eoFill:23,fillStroke:24,eoFillStroke:25,closeFillStroke:26,closeEOFillStroke:27,endPath:28,clip:29,eoClip:30,beginText:31,endText:32,setCharSpacing:33,setWordSpacing:34,setHScale:35,setLeading:36,setFont:37,setTextRenderingMode:38,setTextRise:39,moveText:40,setLeadingMoveText:41,setTextMatrix:42,nextLine:43,showText:44,showSpacedText:45,nextLineShowText:46,nextLineSetSpacingShowText:47,setCharWidth:48,setCharWidthAndBounds:49,setStrokeColorSpace:50,setFillColorSpace:51,setStrokeColor:52,setStrokeColorN:53,setFillColor:54,setFillColorN:55,setStrokeGray:56,setFillGray:57,setStrokeRGBColor:58,setFillRGBColor:59,setStrokeCMYKColor:60,setFillCMYKColor:61,shadingFill:62,beginInlineImage:63,beginImageData:64,endInlineImage:65,paintXObject:66,markPoint:67,markPointProps:68,beginMarkedContent:69,beginMarkedContentProps:70,endMarkedContent:71,beginCompat:72,endCompat:73,paintFormXObjectBegin:74,paintFormXObjectEnd:75,beginGroup:76,endGroup:77,beginAnnotation:80,endAnnotation:81,paintImageMaskXObject:83,paintImageMaskXObjectGroup:84,paintImageXObject:85,paintInlineImageXObject:86,paintInlineImageXObjectGroup:87,paintImageXObjectRepeat:88,paintImageMaskXObjectRepeat:89,paintSolidColorImageMask:90,constructPath:91,setStrokeTransparent:92,setFillTransparent:93},K={NEED_PASSWORD:1,INCORRECT_PASSWORD:2};let Y=q.WARNINGS;function setVerbosityLevel(t){Number.isInteger(t)&&(Y=t)}function getVerbosityLevel(){return Y}function info(t){Y>=q.INFOS&&console.log(`Info: ${t}`)}function warn(t){Y>=q.WARNINGS&&console.log(`Warning: ${t}`)}function unreachable(t){throw new Error(t)}function assert(t,e){t||unreachable(e)}function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=function stringToUTF8String(t){return decodeURIComponent(escape(t))}(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null}function shadow(t,e,i,s=!1){Object.defineProperty(t,e,{value:i,enumerable:!s,configurable:!0,writable:!1});return i}const Q=function BaseExceptionClosure(){function BaseException(t,e){this.message=t;this.name=e}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends Q{constructor(t,e){super(t,"PasswordException");this.code=e}}class UnknownErrorException extends Q{constructor(t,e){super(t,"UnknownErrorException");this.details=e}}class InvalidPDFException extends Q{constructor(t){super(t,"InvalidPDFException")}}class MissingPDFException extends Q{constructor(t){super(t,"MissingPDFException")}}class UnexpectedResponseException extends Q{constructor(t,e){super(t,"UnexpectedResponseException");this.status=e}}class FormatError extends Q{constructor(t){super(t,"FormatError")}}class AbortException extends Q{constructor(t){super(t,"AbortException")}}function bytesToString(t){"object"==typeof t&&void 0!==t?.length||unreachable("Invalid argument for bytesToString");const e=t.length,i=8192;if(et.toString(16).padStart(2,"0")));class Util{static makeHexColor(t,e,i){return`#${J[t]}${J[e]}${J[i]}`}static scaleMinMax(t,e){let i;if(t[0]){if(t[0]<0){i=e[0];e[0]=e[2];e[2]=i}e[0]*=t[0];e[2]*=t[0];if(t[3]<0){i=e[1];e[1]=e[3];e[3]=i}e[1]*=t[3];e[3]*=t[3]}else{i=e[0];e[0]=e[1];e[1]=i;i=e[2];e[2]=e[3];e[3]=i;if(t[1]<0){i=e[1];e[1]=e[3];e[3]=i}e[1]*=t[1];e[3]*=t[1];if(t[2]<0){i=e[0];e[0]=e[2];e[2]=i}e[0]*=t[2];e[2]*=t[2]}e[0]+=t[4];e[1]+=t[5];e[2]+=t[4];e[3]+=t[5]}static transform(t,e){return[t[0]*e[0]+t[2]*e[1],t[1]*e[0]+t[3]*e[1],t[0]*e[2]+t[2]*e[3],t[1]*e[2]+t[3]*e[3],t[0]*e[4]+t[2]*e[5]+t[4],t[1]*e[4]+t[3]*e[5]+t[5]]}static applyTransform(t,e){return[t[0]*e[0]+t[1]*e[2]+e[4],t[0]*e[1]+t[1]*e[3]+e[5]]}static applyInverseTransform(t,e){const i=e[0]*e[3]-e[1]*e[2];return[(t[0]*e[3]-t[1]*e[2]+e[2]*e[5]-e[4]*e[3])/i,(-t[0]*e[1]+t[1]*e[0]+e[4]*e[1]-e[5]*e[0])/i]}static getAxialAlignedBoundingBox(t,e){const i=this.applyTransform(t,e),s=this.applyTransform(t.slice(2,4),e),n=this.applyTransform([t[0],t[3]],e),a=this.applyTransform([t[2],t[1]],e);return[Math.min(i[0],s[0],n[0],a[0]),Math.min(i[1],s[1],n[1],a[1]),Math.max(i[0],s[0],n[0],a[0]),Math.max(i[1],s[1],n[1],a[1])]}static inverseTransform(t){const e=t[0]*t[3]-t[1]*t[2];return[t[3]/e,-t[1]/e,-t[2]/e,t[0]/e,(t[2]*t[5]-t[4]*t[3])/e,(t[4]*t[1]-t[5]*t[0])/e]}static singularValueDecompose2dScale(t){const e=[t[0],t[2],t[1],t[3]],i=t[0]*e[0]+t[1]*e[2],s=t[0]*e[1]+t[1]*e[3],n=t[2]*e[0]+t[3]*e[2],a=t[2]*e[1]+t[3]*e[3],r=(i+a)/2,o=Math.sqrt((i+a)**2-4*(i*a-n*s))/2,l=r+o||1,h=r-o||1;return[Math.sqrt(l),Math.sqrt(h)]}static normalizeRect(t){const e=t.slice(0);if(t[0]>t[2]){e[0]=t[2];e[2]=t[0]}if(t[1]>t[3]){e[1]=t[3];e[3]=t[1]}return e}static intersect(t,e){const i=Math.max(Math.min(t[0],t[2]),Math.min(e[0],e[2])),s=Math.min(Math.max(t[0],t[2]),Math.max(e[0],e[2]));if(i>s)return null;const n=Math.max(Math.min(t[1],t[3]),Math.min(e[1],e[3])),a=Math.min(Math.max(t[1],t[3]),Math.max(e[1],e[3]));return n>a?null:[i,n,s,a]}static#t(t,e,i,s,n,a,r,o,l,h){if(l<=0||l>=1)return;const d=1-l,c=l*l,u=c*l,p=d*(d*(d*t+3*l*e)+3*c*i)+u*s,g=d*(d*(d*n+3*l*a)+3*c*r)+u*o;h[0]=Math.min(h[0],p);h[1]=Math.min(h[1],g);h[2]=Math.max(h[2],p);h[3]=Math.max(h[3],g)}static#e(t,e,i,s,n,a,r,o,l,h,d,c){if(Math.abs(l)<1e-12){Math.abs(h)>=1e-12&&this.#t(t,e,i,s,n,a,r,o,-d/h,c);return}const u=h**2-4*d*l;if(u<0)return;const p=Math.sqrt(u),g=2*l;this.#t(t,e,i,s,n,a,r,o,(-h+p)/g,c);this.#t(t,e,i,s,n,a,r,o,(-h-p)/g,c)}static bezierBoundingBox(t,e,i,s,n,a,r,o,l){if(l){l[0]=Math.min(l[0],t,r);l[1]=Math.min(l[1],e,o);l[2]=Math.max(l[2],t,r);l[3]=Math.max(l[3],e,o)}else l=[Math.min(t,r),Math.min(e,o),Math.max(t,r),Math.max(e,o)];this.#e(t,i,n,r,e,s,a,o,3*(3*(i-n)-t+r),6*(t-2*i+n),3*(i-t),l);this.#e(t,i,n,r,e,s,a,o,3*(3*(s-a)-e+o),6*(e-2*s+a),3*(s-e),l);return l}}let Z=null,tt=null;function normalizeUnicode(t){if(!Z){Z=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;tt=new Map([["ſt","ſt"]])}return t.replaceAll(Z,((t,e,i)=>e?e.normalize("NFKC"):tt.get(i)))}const et="pdfjs_internal_id_";"function"!=typeof Promise.try&&(Promise.try=function(t,...e){return new Promise((i=>{i(t(...e))}))});const it="http://www.w3.org/2000/svg";class PixelsPerInch{static CSS=96;static PDF=72;static PDF_TO_CSS_UNITS=this.CSS/this.PDF}async function fetchData(t,e="text"){if(isValidFetchUrl(t,document.baseURI)){const i=await fetch(t);if(!i.ok)throw new Error(i.statusText);switch(e){case"arraybuffer":return i.arrayBuffer();case"blob":return i.blob();case"json":return i.json()}return i.text()}return new Promise(((i,s)=>{const n=new XMLHttpRequest;n.open("GET",t,!0);n.responseType=e;n.onreadystatechange=()=>{if(n.readyState===XMLHttpRequest.DONE)if(200!==n.status&&0!==n.status)s(new Error(n.statusText));else{switch(e){case"arraybuffer":case"blob":case"json":i(n.response);return}i(n.responseText)}};n.send(null)}))}class PageViewport{constructor({viewBox:t,userUnit:e,scale:i,rotation:s,offsetX:n=0,offsetY:a=0,dontFlip:r=!1}){this.viewBox=t;this.userUnit=e;this.scale=i;this.rotation=s;this.offsetX=n;this.offsetY=a;i*=e;const o=(t[2]+t[0])/2,l=(t[3]+t[1])/2;let h,d,c,u,p,g,m,f;(s%=360)<0&&(s+=360);switch(s){case 180:h=-1;d=0;c=0;u=1;break;case 90:h=0;d=1;c=1;u=0;break;case 270:h=0;d=-1;c=-1;u=0;break;case 0:h=1;d=0;c=0;u=-1;break;default:throw new Error("PageViewport: Invalid rotation, must be a multiple of 90 degrees.")}if(r){c=-c;u=-u}if(0===h){p=Math.abs(l-t[1])*i+n;g=Math.abs(o-t[0])*i+a;m=(t[3]-t[1])*i;f=(t[2]-t[0])*i}else{p=Math.abs(o-t[0])*i+n;g=Math.abs(l-t[1])*i+a;m=(t[2]-t[0])*i;f=(t[3]-t[1])*i}this.transform=[h*i,d*i,c*i,u*i,p-h*i*o-c*i*l,g-d*i*o-u*i*l];this.width=m;this.height=f}get rawDims(){const{userUnit:t,viewBox:e}=this,i=e.map((e=>e*t));return shadow(this,"rawDims",{pageWidth:i[2]-i[0],pageHeight:i[3]-i[1],pageX:i[0],pageY:i[1]})}clone({scale:t=this.scale,rotation:e=this.rotation,offsetX:i=this.offsetX,offsetY:s=this.offsetY,dontFlip:n=!1}={}){return new PageViewport({viewBox:this.viewBox.slice(),userUnit:this.userUnit,scale:t,rotation:e,offsetX:i,offsetY:s,dontFlip:n})}convertToViewportPoint(t,e){return Util.applyTransform([t,e],this.transform)}convertToViewportRectangle(t){const e=Util.applyTransform([t[0],t[1]],this.transform),i=Util.applyTransform([t[2],t[3]],this.transform);return[e[0],e[1],i[0],i[1]]}convertToPdfPoint(t,e){return Util.applyInverseTransform([t,e],this.transform)}}class RenderingCancelledException extends Q{constructor(t,e=0){super(t,"RenderingCancelledException");this.extraDelay=e}}function isDataScheme(t){const e=t.length;let i=0;for(;i=1&&s<=12?s-1:0;let n=parseInt(e[3],10);n=n>=1&&n<=31?n:1;let a=parseInt(e[4],10);a=a>=0&&a<=23?a:0;let r=parseInt(e[5],10);r=r>=0&&r<=59?r:0;let o=parseInt(e[6],10);o=o>=0&&o<=59?o:0;const l=e[7]||"Z";let h=parseInt(e[8],10);h=h>=0&&h<=23?h:0;let d=parseInt(e[9],10)||0;d=d>=0&&d<=59?d:0;if("-"===l){a+=h;r+=d}else if("+"===l){a-=h;r-=d}return new Date(Date.UTC(i,s,n,a,r,o))}}function getXfaPageViewport(t,{scale:e=1,rotation:i=0}){const{width:s,height:n}=t.attributes.style,a=[0,0,parseInt(s),parseInt(n)];return new PageViewport({viewBox:a,userUnit:1,scale:e,rotation:i})}function getRGB(t){if(t.startsWith("#")){const e=parseInt(t.slice(1),16);return[(16711680&e)>>16,(65280&e)>>8,255&e]}if(t.startsWith("rgb("))return t.slice(4,-1).split(",").map((t=>parseInt(t)));if(t.startsWith("rgba("))return t.slice(5,-1).split(",").map((t=>parseInt(t))).slice(0,3);warn(`Not a valid color format: "${t}"`);return[0,0,0]}function getCurrentTransform(t){const{a:e,b:i,c:s,d:n,e:a,f:r}=t.getTransform();return[e,i,s,n,a,r]}function getCurrentTransformInverse(t){const{a:e,b:i,c:s,d:n,e:a,f:r}=t.getTransform().invertSelf();return[e,i,s,n,a,r]}function setLayerDimensions(t,e,i=!1,s=!0){if(e instanceof PageViewport){const{pageWidth:s,pageHeight:n}=e.rawDims,{style:a}=t,r=util_FeatureTest.isCSSRoundSupported,o=`var(--scale-factor) * ${s}px`,l=`var(--scale-factor) * ${n}px`,h=r?`round(down, ${o}, var(--scale-round-x, 1px))`:`calc(${o})`,d=r?`round(down, ${l}, var(--scale-round-y, 1px))`:`calc(${l})`;if(i&&e.rotation%180!=0){a.width=d;a.height=h}else{a.width=h;a.height=d}}s&&t.setAttribute("data-main-rotation",e.rotation)}class OutputScale{constructor(){const t=window.devicePixelRatio||1;this.sx=t;this.sy=t}get scaled(){return 1!==this.sx||1!==this.sy}get symmetric(){return this.sx===this.sy}}class EditorToolbar{#s=null;#n=null;#a;#r=null;#o=null;static#l=null;constructor(t){this.#a=t;EditorToolbar.#l||=Object.freeze({freetext:"pdfjs-editor-remove-freetext-button",highlight:"pdfjs-editor-remove-highlight-button",ink:"pdfjs-editor-remove-ink-button",stamp:"pdfjs-editor-remove-stamp-button"})}render(){const t=this.#s=document.createElement("div");t.classList.add("editToolbar","hidden");t.setAttribute("role","toolbar");const e=this.#a._uiManager._signal;t.addEventListener("contextmenu",noContextMenu,{signal:e});t.addEventListener("pointerdown",EditorToolbar.#h,{signal:e});const i=this.#r=document.createElement("div");i.className="buttons";t.append(i);const s=this.#a.toolbarPosition;if(s){const{style:e}=t,i="ltr"===this.#a._uiManager.direction?1-s[0]:s[0];e.insetInlineEnd=100*i+"%";e.top=`calc(${100*s[1]}% + var(--editor-toolbar-vert-offset))`}this.#d();return t}get div(){return this.#s}static#h(t){t.stopPropagation()}#c(t){this.#a._focusEventsAllowed=!1;stopEvent(t)}#u(t){this.#a._focusEventsAllowed=!0;stopEvent(t)}#p(t){const e=this.#a._uiManager._signal;t.addEventListener("focusin",this.#c.bind(this),{capture:!0,signal:e});t.addEventListener("focusout",this.#u.bind(this),{capture:!0,signal:e});t.addEventListener("contextmenu",noContextMenu,{signal:e})}hide(){this.#s.classList.add("hidden");this.#n?.hideDropdown()}show(){this.#s.classList.remove("hidden");this.#o?.shown()}#d(){const{editorType:t,_uiManager:e}=this.#a,i=document.createElement("button");i.className="delete";i.tabIndex=0;i.setAttribute("data-l10n-id",EditorToolbar.#l[t]);this.#p(i);i.addEventListener("click",(t=>{e.delete()}),{signal:e._signal});this.#r.append(i)}get#g(){const t=document.createElement("div");t.className="divider";return t}async addAltText(t){const e=await t.render();this.#p(e);this.#r.prepend(e,this.#g);this.#o=t}addColorPicker(t){this.#n=t;const e=t.renderButton();this.#p(e);this.#r.prepend(e,this.#g)}remove(){this.#s.remove();this.#n?.destroy();this.#n=null}}class HighlightToolbar{#r=null;#s=null;#m;constructor(t){this.#m=t}#f(){const t=this.#s=document.createElement("div");t.className="editToolbar";t.setAttribute("role","toolbar");t.addEventListener("contextmenu",noContextMenu,{signal:this.#m._signal});const e=this.#r=document.createElement("div");e.className="buttons";t.append(e);this.#b();return t}#A(t,e){let i=0,s=0;for(const n of t){const t=n.y+n.height;if(ti){s=a;i=t}else e?a>s&&(s=a):a{this.#m.highlightSelection("floating_button")}),{signal:i});this.#r.append(t)}}function bindEvents(t,e,i){for(const s of i)e.addEventListener(s,t[s].bind(t))}class IdManager{#w=0;get id(){return"pdfjs_internal_editor_"+this.#w++}}class ImageManager{#v=function getUuid(){if("function"==typeof crypto.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);crypto.getRandomValues(t);return bytesToString(t)}();#w=0;#y=null;static get _isSVGFittingCanvas(){const t=new OffscreenCanvas(1,3).getContext("2d",{willReadFrequently:!0}),e=new Image;e.src='data:image/svg+xml;charset=UTF-8,';return shadow(this,"_isSVGFittingCanvas",e.decode().then((()=>{t.drawImage(e,0,0,1,1,0,0,1,3);return 0===new Uint32Array(t.getImageData(0,0,1,1).data.buffer)[0]})))}async#x(t,e){this.#y||=new Map;let i=this.#y.get(t);if(null===i)return null;if(i?.bitmap){i.refCounter+=1;return i}try{i||={bitmap:null,id:`image_${this.#v}_${this.#w++}`,refCounter:0,isSvg:!1};let t;if("string"==typeof e){i.url=e;t=await fetchData(e,"blob")}else e instanceof File?t=i.file=e:e instanceof Blob&&(t=e);if("image/svg+xml"===t.type){const e=ImageManager._isSVGFittingCanvas,s=new FileReader,n=new Image,a=new Promise(((t,a)=>{n.onload=()=>{i.bitmap=n;i.isSvg=!0;t()};s.onload=async()=>{const t=i.svgUrl=s.result;n.src=await e?`${t}#svgView(preserveAspectRatio(none))`:t};n.onerror=s.onerror=a}));s.readAsDataURL(t);await a}else i.bitmap=await createImageBitmap(t);i.refCounter=1}catch(t){warn(t);i=null}this.#y.set(t,i);i&&this.#y.set(i.id,i);return i}async getFromFile(t){const{lastModified:e,name:i,size:s,type:n}=t;return this.#x(`${e}_${i}_${s}_${n}`,t)}async getFromUrl(t){return this.#x(t,t)}async getFromBlob(t,e){const i=await e;return this.#x(t,i)}async getFromId(t){this.#y||=new Map;const e=this.#y.get(t);if(!e)return null;if(e.bitmap){e.refCounter+=1;return e}if(e.file)return this.getFromFile(e.file);if(e.blobPromise){const{blobPromise:t}=e;delete e.blobPromise;return this.getFromBlob(e.id,t)}return this.getFromUrl(e.url)}getFromCanvas(t,e){this.#y||=new Map;let i=this.#y.get(t);if(i?.bitmap){i.refCounter+=1;return i}const s=new OffscreenCanvas(e.width,e.height);s.getContext("2d").drawImage(e,0,0);i={bitmap:s.transferToImageBitmap(),id:`image_${this.#v}_${this.#w++}`,refCounter:1,isSvg:!1};this.#y.set(t,i);this.#y.set(i.id,i);return i}getSvgUrl(t){const e=this.#y.get(t);return e?.isSvg?e.svgUrl:null}deleteId(t){this.#y||=new Map;const e=this.#y.get(t);if(!e)return;e.refCounter-=1;if(0!==e.refCounter)return;const{bitmap:i}=e;if(!e.url&&!e.file){const t=new OffscreenCanvas(i.width,i.height);t.getContext("bitmaprenderer").transferFromImageBitmap(i);e.blobPromise=t.convertToBlob()}i.close?.();e.bitmap=null}isValidId(t){return t.startsWith(`image_${this.#v}_`)}}class CommandManager{#_=[];#E=!1;#S;#C=-1;constructor(t=128){this.#S=t}add({cmd:t,undo:e,post:i,mustExec:s,type:n=NaN,overwriteIfSameType:a=!1,keepUndo:r=!1}){s&&t();if(this.#E)return;const o={cmd:t,undo:e,post:i,type:n};if(-1===this.#C){this.#_.length>0&&(this.#_.length=0);this.#C=0;this.#_.push(o);return}if(a&&this.#_[this.#C].type===n){r&&(o.undo=this.#_[this.#C].undo);this.#_[this.#C]=o;return}const l=this.#C+1;if(l===this.#S)this.#_.splice(0,1);else{this.#C=l;l=0;e--)if(this.#_[e].type!==t){this.#_.splice(e+1,this.#C-e);this.#C=e;return}this.#_.length=0;this.#C=-1}}destroy(){this.#_=null}}class KeyboardManager{constructor(t){this.buffer=[];this.callbacks=new Map;this.allKeys=new Set;const{isMac:e}=util_FeatureTest.platform;for(const[i,s,n={}]of t)for(const t of i){const i=t.startsWith("mac+");if(e&&i){this.callbacks.set(t.slice(4),{callback:s,options:n});this.allKeys.add(t.split("+").at(-1))}else if(!e&&!i){this.callbacks.set(t,{callback:s,options:n});this.allKeys.add(t.split("+").at(-1))}}}#T(t){t.altKey&&this.buffer.push("alt");t.ctrlKey&&this.buffer.push("ctrl");t.metaKey&&this.buffer.push("meta");t.shiftKey&&this.buffer.push("shift");this.buffer.push(t.key);const e=this.buffer.join("+");this.buffer.length=0;return e}exec(t,e){if(!this.allKeys.has(e.key))return;const i=this.callbacks.get(this.#T(e));if(!i)return;const{callback:s,options:{bubbles:n=!1,args:a=[],checker:r=null}}=i;if(!r||r(t,e)){s.bind(t,...a,e)();n||stopEvent(e)}}}class ColorManager{static _colorsMapping=new Map([["CanvasText",[0,0,0]],["Canvas",[255,255,255]]]);get _colors(){const t=new Map([["CanvasText",null],["Canvas",null]]);!function getColorValues(t){const e=document.createElement("span");e.style.visibility="hidden";document.body.append(e);for(const i of t.keys()){e.style.color=i;const s=window.getComputedStyle(e).color;t.set(i,getRGB(s))}e.remove()}(t);return shadow(this,"_colors",t)}convert(t){const e=getRGB(t);if(!window.matchMedia("(forced-colors: active)").matches)return e;for(const[t,i]of this._colors)if(i.every(((t,i)=>t===e[i])))return ColorManager._colorsMapping.get(t);return e}getHexCode(t){const e=this._colors.get(t);return e?Util.makeHexColor(...e):t}}class AnnotationEditorUIManager{#M=new AbortController;#P=null;#D=new Map;#k=new Map;#R=null;#I=null;#F=null;#L=new CommandManager;#O=null;#N=null;#B=0;#H=new Set;#z=null;#U=null;#G=new Set;_editorUndoBar=null;#$=!1;#V=!1;#j=!1;#W=null;#q=null;#X=null;#K=null;#Y=!1;#Q=null;#J=new IdManager;#Z=!1;#tt=!1;#et=null;#it=null;#st=null;#nt=null;#at=g.NONE;#rt=new Set;#ot=null;#lt=null;#ht=null;#dt={isEditing:!1,isEmpty:!0,hasSomethingToUndo:!1,hasSomethingToRedo:!1,hasSelectedEditor:!1,hasSelectedText:!1};#ct=[0,0];#ut=null;#pt=null;#gt=null;#mt=null;static TRANSLATE_SMALL=1;static TRANSLATE_BIG=10;static get _keyboardManager(){const t=AnnotationEditorUIManager.prototype,arrowChecker=t=>t.#pt.contains(document.activeElement)&&"BUTTON"!==document.activeElement.tagName&&t.hasSomethingToControl(),textInputChecker=(t,{target:e})=>{if(e instanceof HTMLInputElement){const{type:t}=e;return"text"!==t&&"number"!==t}return!0},e=this.TRANSLATE_SMALL,i=this.TRANSLATE_BIG;return shadow(this,"_keyboardManager",new KeyboardManager([[["ctrl+a","mac+meta+a"],t.selectAll,{checker:textInputChecker}],[["ctrl+z","mac+meta+z"],t.undo,{checker:textInputChecker}],[["ctrl+y","ctrl+shift+z","mac+meta+shift+z","ctrl+shift+Z","mac+meta+shift+Z"],t.redo,{checker:textInputChecker}],[["Backspace","alt+Backspace","ctrl+Backspace","shift+Backspace","mac+Backspace","mac+alt+Backspace","mac+ctrl+Backspace","Delete","ctrl+Delete","shift+Delete","mac+Delete"],t.delete,{checker:textInputChecker}],[["Enter","mac+Enter"],t.addNewEditorFromKeyboard,{checker:(t,{target:e})=>!(e instanceof HTMLButtonElement)&&t.#pt.contains(e)&&!t.isEnterHandled}],[[" ","mac+ "],t.addNewEditorFromKeyboard,{checker:(t,{target:e})=>!(e instanceof HTMLButtonElement)&&t.#pt.contains(document.activeElement)}],[["Escape","mac+Escape"],t.unselectAll],[["ArrowLeft","mac+ArrowLeft"],t.translateSelectedEditors,{args:[-e,0],checker:arrowChecker}],[["ctrl+ArrowLeft","mac+shift+ArrowLeft"],t.translateSelectedEditors,{args:[-i,0],checker:arrowChecker}],[["ArrowRight","mac+ArrowRight"],t.translateSelectedEditors,{args:[e,0],checker:arrowChecker}],[["ctrl+ArrowRight","mac+shift+ArrowRight"],t.translateSelectedEditors,{args:[i,0],checker:arrowChecker}],[["ArrowUp","mac+ArrowUp"],t.translateSelectedEditors,{args:[0,-e],checker:arrowChecker}],[["ctrl+ArrowUp","mac+shift+ArrowUp"],t.translateSelectedEditors,{args:[0,-i],checker:arrowChecker}],[["ArrowDown","mac+ArrowDown"],t.translateSelectedEditors,{args:[0,e],checker:arrowChecker}],[["ctrl+ArrowDown","mac+shift+ArrowDown"],t.translateSelectedEditors,{args:[0,i],checker:arrowChecker}]]))}constructor(t,e,i,s,n,a,r,o,l,h,d,c,u){const p=this._signal=this.#M.signal;this.#pt=t;this.#gt=e;this.#R=i;this._eventBus=s;s._on("editingaction",this.onEditingAction.bind(this),{signal:p});s._on("pagechanging",this.onPageChanging.bind(this),{signal:p});s._on("scalechanging",this.onScaleChanging.bind(this),{signal:p});s._on("rotationchanging",this.onRotationChanging.bind(this),{signal:p});s._on("setpreference",this.onSetPreference.bind(this),{signal:p});s._on("switchannotationeditorparams",(t=>this.updateParams(t.type,t.value)),{signal:p});this.#ft();this.#bt();this.#At();this.#I=n.annotationStorage;this.#W=n.filterFactory;this.#lt=a;this.#K=r||null;this.#$=o;this.#V=l;this.#j=h;this.#nt=d||null;this.viewParameters={realScale:PixelsPerInch.PDF_TO_CSS_UNITS,rotation:0};this.isShiftKeyDown=!1;this._editorUndoBar=c||null;this._supportsPinchToZoom=!1!==u}destroy(){this.#mt?.resolve();this.#mt=null;this.#M?.abort();this.#M=null;this._signal=null;for(const t of this.#k.values())t.destroy();this.#k.clear();this.#D.clear();this.#G.clear();this.#P=null;this.#rt.clear();this.#L.destroy();this.#R?.destroy();this.#Q?.hide();this.#Q=null;if(this.#q){clearTimeout(this.#q);this.#q=null}if(this.#ut){clearTimeout(this.#ut);this.#ut=null}this._editorUndoBar?.destroy()}combinedSignal(t){return AbortSignal.any([this._signal,t.signal])}get mlManager(){return this.#nt}get useNewAltTextFlow(){return this.#V}get useNewAltTextWhenAddingImage(){return this.#j}get hcmFilter(){return shadow(this,"hcmFilter",this.#lt?this.#W.addHCMFilter(this.#lt.foreground,this.#lt.background):"none")}get direction(){return shadow(this,"direction",getComputedStyle(this.#pt).direction)}get highlightColors(){return shadow(this,"highlightColors",this.#K?new Map(this.#K.split(",").map((t=>t.split("=").map((t=>t.trim()))))):null)}get highlightColorNames(){return shadow(this,"highlightColorNames",this.highlightColors?new Map(Array.from(this.highlightColors,(t=>t.reverse()))):null)}setCurrentDrawingSession(t){if(t){this.unselectAll();this.disableUserSelect(!0)}else this.disableUserSelect(!1);this.#N=t}setMainHighlightColorPicker(t){this.#st=t}editAltText(t,e=!1){this.#R?.editAltText(this,t,e)}switchToMode(t,e){this._eventBus.on("annotationeditormodechanged",e,{once:!0,signal:this._signal});this._eventBus.dispatch("showannotationeditorui",{source:this,mode:t})}setPreference(t,e){this._eventBus.dispatch("setpreference",{source:this,name:t,value:e})}onSetPreference({name:t,value:e}){if("enableNewAltTextWhenAddingImage"===t)this.#j=e}onPageChanging({pageNumber:t}){this.#B=t-1}focusMainContainer(){this.#pt.focus()}findParent(t,e){for(const i of this.#k.values()){const{x:s,y:n,width:a,height:r}=i.div.getBoundingClientRect();if(t>=s&&t<=s+a&&e>=n&&e<=n+r)return i}return null}disableUserSelect(t=!1){this.#gt.classList.toggle("noUserSelect",t)}addShouldRescale(t){this.#G.add(t)}removeShouldRescale(t){this.#G.delete(t)}onScaleChanging({scale:t}){this.commitOrRemove();this.viewParameters.realScale=t*PixelsPerInch.PDF_TO_CSS_UNITS;for(const t of this.#G)t.onScaleChanging();this.#N?.onScaleChanging()}onRotationChanging({pagesRotation:t}){this.commitOrRemove();this.viewParameters.rotation=t}#wt({anchorNode:t}){return t.nodeType===Node.TEXT_NODE?t.parentElement:t}#vt(t){const{currentLayer:e}=this;if(e.hasTextLayer(t))return e;for(const e of this.#k.values())if(e.hasTextLayer(t))return e;return null}highlightSelection(t=""){const e=document.getSelection();if(!e||e.isCollapsed)return;const{anchorNode:i,anchorOffset:s,focusNode:n,focusOffset:a}=e,r=e.toString(),o=this.#wt(e).closest(".textLayer"),l=this.getSelectionBoxes(o);if(!l)return;e.empty();const h=this.#vt(o),d=this.#at===g.NONE,callback=()=>{h?.createAndAddNewEditor({x:0,y:0},!1,{methodOfCreation:t,boxes:l,anchorNode:i,anchorOffset:s,focusNode:n,focusOffset:a,text:r});d&&this.showAllEditors("highlight",!0,!0)};d?this.switchToMode(g.HIGHLIGHT,callback):callback()}#yt(){const t=document.getSelection();if(!t||t.isCollapsed)return;const e=this.#wt(t).closest(".textLayer"),i=this.getSelectionBoxes(e);if(i){this.#Q||=new HighlightToolbar(this);this.#Q.show(e,i,"ltr"===this.direction)}}addToAnnotationStorage(t){t.isEmpty()||!this.#I||this.#I.has(t.id)||this.#I.setValue(t.id,t)}#xt(){const t=document.getSelection();if(!t||t.isCollapsed){if(this.#ot){this.#Q?.hide();this.#ot=null;this.#_t({hasSelectedText:!1})}return}const{anchorNode:e}=t;if(e===this.#ot)return;const i=this.#wt(t).closest(".textLayer");if(i){this.#Q?.hide();this.#ot=e;this.#_t({hasSelectedText:!0});if(this.#at===g.HIGHLIGHT||this.#at===g.NONE){this.#at===g.HIGHLIGHT&&this.showAllEditors("highlight",!0,!0);this.#Y=this.isShiftKeyDown;if(!this.isShiftKeyDown){const t=this.#at===g.HIGHLIGHT?this.#vt(i):null;t?.toggleDrawing();const e=new AbortController,s=this.combinedSignal(e),pointerup=i=>{if("pointerup"!==i.type||0===i.button){e.abort();t?.toggleDrawing(!0);"pointerup"===i.type&&this.#Et("main_toolbar")}};window.addEventListener("pointerup",pointerup,{signal:s});window.addEventListener("blur",pointerup,{signal:s})}}}else if(this.#ot){this.#Q?.hide();this.#ot=null;this.#_t({hasSelectedText:!1})}}#Et(t=""){this.#at===g.HIGHLIGHT?this.highlightSelection(t):this.#$&&this.#yt()}#ft(){document.addEventListener("selectionchange",this.#xt.bind(this),{signal:this._signal})}#St(){if(this.#X)return;this.#X=new AbortController;const t=this.combinedSignal(this.#X);window.addEventListener("focus",this.focus.bind(this),{signal:t});window.addEventListener("blur",this.blur.bind(this),{signal:t})}#Ct(){this.#X?.abort();this.#X=null}blur(){this.isShiftKeyDown=!1;if(this.#Y){this.#Y=!1;this.#Et("main_toolbar")}if(!this.hasSelection)return;const{activeElement:t}=document;for(const e of this.#rt)if(e.div.contains(t)){this.#it=[e,t];e._focusEventsAllowed=!1;break}}focus(){if(!this.#it)return;const[t,e]=this.#it;this.#it=null;e.addEventListener("focusin",(()=>{t._focusEventsAllowed=!0}),{once:!0,signal:this._signal});e.focus()}#At(){if(this.#et)return;this.#et=new AbortController;const t=this.combinedSignal(this.#et);window.addEventListener("keydown",this.keydown.bind(this),{signal:t});window.addEventListener("keyup",this.keyup.bind(this),{signal:t})}#Tt(){this.#et?.abort();this.#et=null}#Mt(){if(this.#O)return;this.#O=new AbortController;const t=this.combinedSignal(this.#O);document.addEventListener("copy",this.copy.bind(this),{signal:t});document.addEventListener("cut",this.cut.bind(this),{signal:t});document.addEventListener("paste",this.paste.bind(this),{signal:t})}#Pt(){this.#O?.abort();this.#O=null}#bt(){const t=this._signal;document.addEventListener("dragover",this.dragOver.bind(this),{signal:t});document.addEventListener("drop",this.drop.bind(this),{signal:t})}addEditListeners(){this.#At();this.#Mt()}removeEditListeners(){this.#Tt();this.#Pt()}dragOver(t){for(const{type:e}of t.dataTransfer.items)for(const i of this.#U)if(i.isHandlingMimeForPasting(e)){t.dataTransfer.dropEffect="copy";t.preventDefault();return}}drop(t){for(const e of t.dataTransfer.items)for(const i of this.#U)if(i.isHandlingMimeForPasting(e.type)){i.paste(e,this.currentLayer);t.preventDefault();return}}copy(t){t.preventDefault();this.#P?.commitOrRemove();if(!this.hasSelection)return;const e=[];for(const t of this.#rt){const i=t.serialize(!0);i&&e.push(i)}0!==e.length&&t.clipboardData.setData("application/pdfjs",JSON.stringify(e))}cut(t){this.copy(t);this.delete()}async paste(t){t.preventDefault();const{clipboardData:e}=t;for(const t of e.items)for(const e of this.#U)if(e.isHandlingMimeForPasting(t.type)){e.paste(t,this.currentLayer);return}let i=e.getData("application/pdfjs");if(!i)return;try{i=JSON.parse(i)}catch(t){warn(`paste: "${t.message}".`);return}if(!Array.isArray(i))return;this.unselectAll();const s=this.currentLayer;try{const t=[];for(const e of i){const i=await s.deserialize(e);if(!i)return;t.push(i)}const cmd=()=>{for(const e of t)this.#Dt(e);this.#kt(t)},undo=()=>{for(const e of t)e.remove()};this.addCommands({cmd,undo,mustExec:!0})}catch(t){warn(`paste: "${t.message}".`)}}keydown(t){this.isShiftKeyDown||"Shift"!==t.key||(this.isShiftKeyDown=!0);this.#at===g.NONE||this.isEditorHandlingKeyboard||AnnotationEditorUIManager._keyboardManager.exec(this,t)}keyup(t){if(this.isShiftKeyDown&&"Shift"===t.key){this.isShiftKeyDown=!1;if(this.#Y){this.#Y=!1;this.#Et("main_toolbar")}}}onEditingAction({name:t}){switch(t){case"undo":case"redo":case"delete":case"selectAll":this[t]();break;case"highlightSelection":this.highlightSelection("context_menu")}}#_t(t){if(Object.entries(t).some((([t,e])=>this.#dt[t]!==e))){this._eventBus.dispatch("annotationeditorstateschanged",{source:this,details:Object.assign(this.#dt,t)});this.#at===g.HIGHLIGHT&&!1===t.hasSelectedEditor&&this.#Rt([[m.HIGHLIGHT_FREE,!0]])}}#Rt(t){this._eventBus.dispatch("annotationeditorparamschanged",{source:this,details:t})}setEditingState(t){if(t){this.#St();this.#Mt();this.#_t({isEditing:this.#at!==g.NONE,isEmpty:this.#It(),hasSomethingToUndo:this.#L.hasSomethingToUndo(),hasSomethingToRedo:this.#L.hasSomethingToRedo(),hasSelectedEditor:!1})}else{this.#Ct();this.#Pt();this.#_t({isEditing:!1});this.disableUserSelect(!1)}}registerEditorTypes(t){if(!this.#U){this.#U=t;for(const t of this.#U)this.#Rt(t.defaultPropertiesToUpdate)}}getId(){return this.#J.id}get currentLayer(){return this.#k.get(this.#B)}getLayer(t){return this.#k.get(t)}get currentPageIndex(){return this.#B}addLayer(t){this.#k.set(t.pageIndex,t);this.#Z?t.enable():t.disable()}removeLayer(t){this.#k.delete(t.pageIndex)}async updateMode(t,e=null,i=!1){if(this.#at!==t){if(this.#mt){await this.#mt.promise;if(!this.#mt)return}this.#mt=Promise.withResolvers();this.#at=t;if(t!==g.NONE){this.setEditingState(!0);await this.#Ft();this.unselectAll();for(const e of this.#k.values())e.updateMode(t);if(e){for(const t of this.#D.values())if(t.annotationElementId===e){this.setSelected(t);t.enterInEditMode()}else t.unselect();this.#mt.resolve()}else{i&&this.addNewEditorFromKeyboard();this.#mt.resolve()}}else{this.setEditingState(!1);this.#Lt();this._editorUndoBar?.hide();this.#mt.resolve()}}}addNewEditorFromKeyboard(){this.currentLayer.canCreateNewEmptyEditor()&&this.currentLayer.addNewEditor()}updateToolbar(t){t!==this.#at&&this._eventBus.dispatch("switchannotationeditormode",{source:this,mode:t})}updateParams(t,e){if(this.#U){switch(t){case m.CREATE:this.currentLayer.addNewEditor();return;case m.HIGHLIGHT_DEFAULT_COLOR:this.#st?.updateColor(e);break;case m.HIGHLIGHT_SHOW_ALL:this._eventBus.dispatch("reporttelemetry",{source:this,details:{type:"editing",data:{type:"highlight",action:"toggle_visibility"}}});(this.#ht||=new Map).set(t,e);this.showAllEditors("highlight",e)}for(const i of this.#rt)i.updateParams(t,e);for(const i of this.#U)i.updateDefaultParams(t,e)}}showAllEditors(t,e,i=!1){for(const i of this.#D.values())i.editorType===t&&i.show(e);(this.#ht?.get(m.HIGHLIGHT_SHOW_ALL)??!0)!==e&&this.#Rt([[m.HIGHLIGHT_SHOW_ALL,e]])}enableWaiting(t=!1){if(this.#tt!==t){this.#tt=t;for(const e of this.#k.values()){t?e.disableClick():e.enableClick();e.div.classList.toggle("waiting",t)}}}async#Ft(){if(!this.#Z){this.#Z=!0;const t=[];for(const e of this.#k.values())t.push(e.enable());await Promise.all(t);for(const t of this.#D.values())t.enable()}}#Lt(){this.unselectAll();if(this.#Z){this.#Z=!1;for(const t of this.#k.values())t.disable();for(const t of this.#D.values())t.disable()}}getEditors(t){const e=[];for(const i of this.#D.values())i.pageIndex===t&&e.push(i);return e}getEditor(t){return this.#D.get(t)}addEditor(t){this.#D.set(t.id,t)}removeEditor(t){if(t.div.contains(document.activeElement)){this.#q&&clearTimeout(this.#q);this.#q=setTimeout((()=>{this.focusMainContainer();this.#q=null}),0)}this.#D.delete(t.id);this.unselect(t);t.annotationElementId&&this.#H.has(t.annotationElementId)||this.#I?.remove(t.id)}addDeletedAnnotationElement(t){this.#H.add(t.annotationElementId);this.addChangedExistingAnnotation(t);t.deleted=!0}isDeletedAnnotationElement(t){return this.#H.has(t)}removeDeletedAnnotationElement(t){this.#H.delete(t.annotationElementId);this.removeChangedExistingAnnotation(t);t.deleted=!1}#Dt(t){const e=this.#k.get(t.pageIndex);if(e)e.addOrRebuild(t);else{this.addEditor(t);this.addToAnnotationStorage(t)}}setActiveEditor(t){if(this.#P!==t){this.#P=t;t&&this.#Rt(t.propertiesToUpdate)}}get#Ot(){let t=null;for(t of this.#rt);return t}updateUI(t){this.#Ot===t&&this.#Rt(t.propertiesToUpdate)}updateUIForDefaultProperties(t){this.#Rt(t.defaultPropertiesToUpdate)}toggleSelected(t){if(this.#rt.has(t)){this.#rt.delete(t);t.unselect();this.#_t({hasSelectedEditor:this.hasSelection})}else{this.#rt.add(t);t.select();this.#Rt(t.propertiesToUpdate);this.#_t({hasSelectedEditor:!0})}}setSelected(t){this.#N?.commitOrRemove();for(const e of this.#rt)e!==t&&e.unselect();this.#rt.clear();this.#rt.add(t);t.select();this.#Rt(t.propertiesToUpdate);this.#_t({hasSelectedEditor:!0})}isSelected(t){return this.#rt.has(t)}get firstSelectedEditor(){return this.#rt.values().next().value}unselect(t){t.unselect();this.#rt.delete(t);this.#_t({hasSelectedEditor:this.hasSelection})}get hasSelection(){return 0!==this.#rt.size}get isEnterHandled(){return 1===this.#rt.size&&this.firstSelectedEditor.isEnterHandled}undo(){this.#L.undo();this.#_t({hasSomethingToUndo:this.#L.hasSomethingToUndo(),hasSomethingToRedo:!0,isEmpty:this.#It()});this._editorUndoBar?.hide()}redo(){this.#L.redo();this.#_t({hasSomethingToUndo:!0,hasSomethingToRedo:this.#L.hasSomethingToRedo(),isEmpty:this.#It()})}addCommands(t){this.#L.add(t);this.#_t({hasSomethingToUndo:!0,hasSomethingToRedo:!1,isEmpty:this.#It()})}cleanUndoStack(t){this.#L.cleanType(t)}#It(){if(0===this.#D.size)return!0;if(1===this.#D.size)for(const t of this.#D.values())return t.isEmpty();return!1}delete(){this.commitOrRemove();const t=this.currentLayer?.endDrawingSession(!0);if(!this.hasSelection&&!t)return;const e=t?[t]:[...this.#rt],undo=()=>{for(const t of e)this.#Dt(t)};this.addCommands({cmd:()=>{this._editorUndoBar?.show(undo,1===e.length?e[0].editorType:e.length);for(const t of e)t.remove()},undo,mustExec:!0})}commitOrRemove(){this.#P?.commitOrRemove()}hasSomethingToControl(){return this.#P||this.hasSelection}#kt(t){for(const t of this.#rt)t.unselect();this.#rt.clear();for(const e of t)if(!e.isEmpty()){this.#rt.add(e);e.select()}this.#_t({hasSelectedEditor:this.hasSelection})}selectAll(){for(const t of this.#rt)t.commit();this.#kt(this.#D.values())}unselectAll(){if(this.#P){this.#P.commitOrRemove();if(this.#at!==g.NONE)return}if(!this.#N?.commitOrRemove()&&this.hasSelection){for(const t of this.#rt)t.unselect();this.#rt.clear();this.#_t({hasSelectedEditor:!1})}}translateSelectedEditors(t,e,i=!1){i||this.commitOrRemove();if(!this.hasSelection)return;this.#ct[0]+=t;this.#ct[1]+=e;const[s,n]=this.#ct,a=[...this.#rt];this.#ut&&clearTimeout(this.#ut);this.#ut=setTimeout((()=>{this.#ut=null;this.#ct[0]=this.#ct[1]=0;this.addCommands({cmd:()=>{for(const t of a)this.#D.has(t.id)&&t.translateInPage(s,n)},undo:()=>{for(const t of a)this.#D.has(t.id)&&t.translateInPage(-s,-n)},mustExec:!1})}),1e3);for(const i of a)i.translateInPage(t,e)}setUpDragSession(){if(this.hasSelection){this.disableUserSelect(!0);this.#z=new Map;for(const t of this.#rt)this.#z.set(t,{savedX:t.x,savedY:t.y,savedPageIndex:t.pageIndex,newX:0,newY:0,newPageIndex:-1})}}endDragSession(){if(!this.#z)return!1;this.disableUserSelect(!1);const t=this.#z;this.#z=null;let e=!1;for(const[{x:i,y:s,pageIndex:n},a]of t){a.newX=i;a.newY=s;a.newPageIndex=n;e||=i!==a.savedX||s!==a.savedY||n!==a.savedPageIndex}if(!e)return!1;const move=(t,e,i,s)=>{if(this.#D.has(t.id)){const n=this.#k.get(s);if(n)t._setParentAndPosition(n,e,i);else{t.pageIndex=s;t.x=e;t.y=i}}};this.addCommands({cmd:()=>{for(const[e,{newX:i,newY:s,newPageIndex:n}]of t)move(e,i,s,n)},undo:()=>{for(const[e,{savedX:i,savedY:s,savedPageIndex:n}]of t)move(e,i,s,n)},mustExec:!0});return!0}dragSelectedEditors(t,e){if(this.#z)for(const i of this.#z.keys())i.drag(t,e)}rebuild(t){if(null===t.parent){const e=this.getLayer(t.pageIndex);if(e){e.changeParent(t);e.addOrRebuild(t)}else{this.addEditor(t);this.addToAnnotationStorage(t);t.rebuild()}}else t.parent.addOrRebuild(t)}get isEditorHandlingKeyboard(){return this.getActive()?.shouldGetKeyboardEvents()||1===this.#rt.size&&this.firstSelectedEditor.shouldGetKeyboardEvents()}isActive(t){return this.#P===t}getActive(){return this.#P}getMode(){return this.#at}get imageManager(){return shadow(this,"imageManager",new ImageManager)}getSelectionBoxes(t){if(!t)return null;const e=document.getSelection();for(let i=0,s=e.rangeCount;i({x:(e-s)/a,y:1-(t+r-i)/n,width:o/a,height:r/n});break;case"180":r=(t,e,r,o)=>({x:1-(t+r-i)/n,y:1-(e+o-s)/a,width:r/n,height:o/a});break;case"270":r=(t,e,r,o)=>({x:1-(e+o-s)/a,y:(t-i)/n,width:o/a,height:r/n});break;default:r=(t,e,r,o)=>({x:(t-i)/n,y:(e-s)/a,width:r/n,height:o/a})}const o=[];for(let t=0,i=e.rangeCount;tt.stopPropagation()),{signal:i});const onClick=t=>{t.preventDefault();this.#a._uiManager.editAltText(this.#a);this.#Wt&&this.#a._reportTelemetry({action:"pdfjs.image.alt_text.image_status_label_clicked",data:{label:this.#Xt}})};t.addEventListener("click",onClick,{capture:!0,signal:i});t.addEventListener("keydown",(e=>{if(e.target===t&&"Enter"===e.key){this.#Gt=!0;onClick(e)}}),{signal:i});await this.#Kt();return t}get#Xt(){return(this.#o?"added":null===this.#o&&this.guessedText&&"review")||"missing"}finish(){if(this.#Bt){this.#Bt.focus({focusVisible:this.#Gt});this.#Gt=!1}}isEmpty(){return this.#Wt?null===this.#o:!this.#o&&!this.#Nt}hasData(){return this.#Wt?null!==this.#o||!!this.#Vt:this.isEmpty()}get guessedText(){return this.#Vt}async setGuessedText(t){if(null===this.#o){this.#Vt=t;this.#jt=await AltText._l10n.get("pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer",{generatedAltText:t});this.#Kt()}}toggleAltTextBadge(t=!1){if(this.#Wt&&!this.#o){if(!this.#$t){const t=this.#$t=document.createElement("div");t.className="noAltTextBadge";this.#a.div.append(t)}this.#$t.classList.toggle("hidden",!t)}else{this.#$t?.remove();this.#$t=null}}serialize(t){let e=this.#o;t||this.#Vt!==e||(e=this.#jt);return{altText:e,decorative:this.#Nt,guessedText:this.#Vt,textWithDisclaimer:this.#jt}}get data(){return{altText:this.#o,decorative:this.#Nt}}set data({altText:t,decorative:e,guessedText:i,textWithDisclaimer:s,cancel:n=!1}){if(i){this.#Vt=i;this.#jt=s}if(this.#o!==t||this.#Nt!==e){if(!n){this.#o=t;this.#Nt=e}this.#Kt()}}toggle(t=!1){if(this.#Bt){if(!t&&this.#Ut){clearTimeout(this.#Ut);this.#Ut=null}this.#Bt.disabled=!t}}shown(){this.#a._reportTelemetry({action:"pdfjs.image.alt_text.image_status_label_displayed",data:{label:this.#Xt}})}destroy(){this.#Bt?.remove();this.#Bt=null;this.#Ht=null;this.#zt=null;this.#$t?.remove();this.#$t=null}async#Kt(){const t=this.#Bt;if(!t)return;if(this.#Wt){t.classList.toggle("done",!!this.#o);t.setAttribute("data-l10n-id",AltText.#qt[this.#Xt]);this.#Ht?.setAttribute("data-l10n-id",AltText.#qt[`${this.#Xt}-label`]);if(!this.#o){this.#zt?.remove();return}}else{if(!this.#o&&!this.#Nt){t.classList.remove("done");this.#zt?.remove();return}t.classList.add("done");t.setAttribute("data-l10n-id","pdfjs-editor-alt-text-edit-button")}let e=this.#zt;if(!e){this.#zt=e=document.createElement("span");e.className="tooltip";e.setAttribute("role","tooltip");e.id=`alt-text-tooltip-${this.#a.id}`;const i=100,s=this.#a._uiManager._signal;s.addEventListener("abort",(()=>{clearTimeout(this.#Ut);this.#Ut=null}),{once:!0});t.addEventListener("mouseenter",(()=>{this.#Ut=setTimeout((()=>{this.#Ut=null;this.#zt.classList.add("show");this.#a._reportTelemetry({action:"alt_text_tooltip"})}),i)}),{signal:s});t.addEventListener("mouseleave",(()=>{if(this.#Ut){clearTimeout(this.#Ut);this.#Ut=null}this.#zt?.classList.remove("show")}),{signal:s})}if(this.#Nt)e.setAttribute("data-l10n-id","pdfjs-editor-alt-text-decorative-tooltip");else{e.removeAttribute("data-l10n-id");e.textContent=this.#o}e.parentNode||t.append(e);const i=this.#a.getImageForAltText();i?.setAttribute("aria-describedby",e.id)}}class TouchManager{#pt;#Yt=!1;#Qt=null;#Jt;#Zt;#te;#ee;#ie;#se=null;#ne;#ae=null;constructor({container:t,isPinchingDisabled:e=null,isPinchingStopped:i=null,onPinchStart:s=null,onPinching:n=null,onPinchEnd:a=null,signal:r}){this.#pt=t;this.#Qt=i;this.#Jt=e;this.#Zt=s;this.#te=n;this.#ee=a;this.#ne=new AbortController;this.#ie=AbortSignal.any([r,this.#ne.signal]);t.addEventListener("touchstart",this.#re.bind(this),{passive:!1,signal:this.#ie})}get MIN_TOUCH_DISTANCE_TO_PINCH(){return shadow(this,"MIN_TOUCH_DISTANCE_TO_PINCH",35/(window.devicePixelRatio||1))}#re(t){if(this.#Jt?.()||t.touches.length<2)return;if(!this.#ae){this.#ae=new AbortController;const t=AbortSignal.any([this.#ie,this.#ae.signal]),e=this.#pt,i={signal:t,passive:!1};e.addEventListener("touchmove",this.#oe.bind(this),i);e.addEventListener("touchend",this.#le.bind(this),i);e.addEventListener("touchcancel",this.#le.bind(this),i);this.#Zt?.()}stopEvent(t);if(2!==t.touches.length||this.#Qt?.()){this.#se=null;return}let[e,i]=t.touches;e.identifier>i.identifier&&([e,i]=[i,e]);this.#se={touch0X:e.screenX,touch0Y:e.screenY,touch1X:i.screenX,touch1Y:i.screenY}}#oe(t){if(!this.#se||2!==t.touches.length)return;let[e,i]=t.touches;e.identifier>i.identifier&&([e,i]=[i,e]);const{screenX:s,screenY:n}=e,{screenX:a,screenY:r}=i,o=this.#se,{touch0X:l,touch0Y:h,touch1X:d,touch1Y:c}=o,u=d-l,p=c-h,g=a-s,m=r-n,f=Math.hypot(g,m)||1,b=Math.hypot(u,p)||1;if(!this.#Yt&&Math.abs(b-f)<=TouchManager.MIN_TOUCH_DISTANCE_TO_PINCH)return;o.touch0X=s;o.touch0Y=n;o.touch1X=a;o.touch1Y=r;t.preventDefault();if(!this.#Yt){this.#Yt=!0;return}const A=[(s+a)/2,(n+r)/2];this.#te?.(A,b,f)}#le(t){this.#ae.abort();this.#ae=null;this.#ee?.();if(this.#se){t.preventDefault();this.#se=null;this.#Yt=!1}}destroy(){this.#ne?.abort();this.#ne=null}}class AnnotationEditor{#he=null;#de=null;#o=null;#ce=!1;#ue=null;#pe="";#ge=!1;#me=null;#fe=null;#be=null;#Ae=null;#we="";#ve=!1;#ye=null;#xe=!1;#_e=!1;#Ee=!1;#Se=null;#Ce=0;#Te=0;#Me=null;#Pe=null;_editToolbar=null;_initialOptions=Object.create(null);_initialData=null;_isVisible=!0;_uiManager=null;_focusEventsAllowed=!0;static _l10n=null;static _l10nResizer=null;#De=!1;#ke=AnnotationEditor._zIndex++;static _borderLineWidth=-1;static _colorManager=new ColorManager;static _zIndex=1;static _telemetryTimeout=1e3;static get _resizerKeyboardManager(){const t=AnnotationEditor.prototype._resizeWithKeyboard,e=AnnotationEditorUIManager.TRANSLATE_SMALL,i=AnnotationEditorUIManager.TRANSLATE_BIG;return shadow(this,"_resizerKeyboardManager",new KeyboardManager([[["ArrowLeft","mac+ArrowLeft"],t,{args:[-e,0]}],[["ctrl+ArrowLeft","mac+shift+ArrowLeft"],t,{args:[-i,0]}],[["ArrowRight","mac+ArrowRight"],t,{args:[e,0]}],[["ctrl+ArrowRight","mac+shift+ArrowRight"],t,{args:[i,0]}],[["ArrowUp","mac+ArrowUp"],t,{args:[0,-e]}],[["ctrl+ArrowUp","mac+shift+ArrowUp"],t,{args:[0,-i]}],[["ArrowDown","mac+ArrowDown"],t,{args:[0,e]}],[["ctrl+ArrowDown","mac+shift+ArrowDown"],t,{args:[0,i]}],[["Escape","mac+Escape"],AnnotationEditor.prototype._stopResizingWithKeyboard]]))}constructor(t){this.parent=t.parent;this.id=t.id;this.width=this.height=null;this.pageIndex=t.parent.pageIndex;this.name=t.name;this.div=null;this._uiManager=t.uiManager;this.annotationElementId=null;this._willKeepAspectRatio=!1;this._initialOptions.isCentered=t.isCentered;this._structTreeParentId=null;const{rotation:e,rawDims:{pageWidth:i,pageHeight:s,pageX:n,pageY:a}}=this.parent.viewport;this.rotation=e;this.pageRotation=(360+e-this._uiManager.viewParameters.rotation)%360;this.pageDimensions=[i,s];this.pageTranslation=[n,a];const[r,o]=this.parentDimensions;this.x=t.x/r;this.y=t.y/o;this.isAttachedToDOM=!1;this.deleted=!1}get editorType(){return Object.getPrototypeOf(this).constructor._type}static get isDrawer(){return!1}static get _defaultLineColor(){return shadow(this,"_defaultLineColor",this._colorManager.getHexCode("CanvasText"))}static deleteAnnotationElement(t){const e=new FakeEditor({id:t.parent.getNextId(),parent:t.parent,uiManager:t._uiManager});e.annotationElementId=t.annotationElementId;e.deleted=!0;e._uiManager.addToAnnotationStorage(e)}static initialize(t,e){AnnotationEditor._l10n??=t;AnnotationEditor._l10nResizer||=Object.freeze({topLeft:"pdfjs-editor-resizer-top-left",topMiddle:"pdfjs-editor-resizer-top-middle",topRight:"pdfjs-editor-resizer-top-right",middleRight:"pdfjs-editor-resizer-middle-right",bottomRight:"pdfjs-editor-resizer-bottom-right",bottomMiddle:"pdfjs-editor-resizer-bottom-middle",bottomLeft:"pdfjs-editor-resizer-bottom-left",middleLeft:"pdfjs-editor-resizer-middle-left"});if(-1!==AnnotationEditor._borderLineWidth)return;const i=getComputedStyle(document.documentElement);AnnotationEditor._borderLineWidth=parseFloat(i.getPropertyValue("--outline-width"))||0}static updateDefaultParams(t,e){}static get defaultPropertiesToUpdate(){return[]}static isHandlingMimeForPasting(t){return!1}static paste(t,e){unreachable("Not implemented")}get propertiesToUpdate(){return[]}get _isDraggable(){return this.#De}set _isDraggable(t){this.#De=t;this.div?.classList.toggle("draggable",t)}get isEnterHandled(){return!0}center(){const[t,e]=this.pageDimensions;switch(this.parentRotation){case 90:this.x-=this.height*e/(2*t);this.y+=this.width*t/(2*e);break;case 180:this.x+=this.width/2;this.y+=this.height/2;break;case 270:this.x+=this.height*e/(2*t);this.y-=this.width*t/(2*e);break;default:this.x-=this.width/2;this.y-=this.height/2}this.fixAndSetPosition()}addCommands(t){this._uiManager.addCommands(t)}get currentLayer(){return this._uiManager.currentLayer}setInBackground(){this.div.style.zIndex=0}setInForeground(){this.div.style.zIndex=this.#ke}setParent(t){if(null!==t){this.pageIndex=t.pageIndex;this.pageDimensions=t.pageDimensions}else this.#Re();this.parent=t}focusin(t){this._focusEventsAllowed&&(this.#ve?this.#ve=!1:this.parent.setSelected(this))}focusout(t){if(!this._focusEventsAllowed)return;if(!this.isAttachedToDOM)return;const e=t.relatedTarget;if(!e?.closest(`#${this.id}`)){t.preventDefault();this.parent?.isMultipleSelection||this.commitOrRemove()}}commitOrRemove(){this.isEmpty()?this.remove():this.commit()}commit(){this.addToAnnotationStorage()}addToAnnotationStorage(){this._uiManager.addToAnnotationStorage(this)}setAt(t,e,i,s){const[n,a]=this.parentDimensions;[i,s]=this.screenToPageTranslation(i,s);this.x=(t+i)/n;this.y=(e+s)/a;this.fixAndSetPosition()}#Ie([t,e],i,s){[i,s]=this.screenToPageTranslation(i,s);this.x+=i/t;this.y+=s/e;this._onTranslating(this.x,this.y);this.fixAndSetPosition()}translate(t,e){this.#Ie(this.parentDimensions,t,e)}translateInPage(t,e){this.#ye||=[this.x,this.y,this.width,this.height];this.#Ie(this.pageDimensions,t,e);this.div.scrollIntoView({block:"nearest"})}drag(t,e){this.#ye||=[this.x,this.y,this.width,this.height];const{div:i,parentDimensions:[s,n]}=this;this.x+=t/s;this.y+=e/n;if(this.parent&&(this.x<0||this.x>1||this.y<0||this.y>1)){const{x:t,y:e}=this.div.getBoundingClientRect();if(this.parent.findNewParent(this,t,e)){this.x-=Math.floor(this.x);this.y-=Math.floor(this.y)}}let{x:a,y:r}=this;const[o,l]=this.getBaseTranslation();a+=o;r+=l;const{style:h}=i;h.left=`${(100*a).toFixed(2)}%`;h.top=`${(100*r).toFixed(2)}%`;this._onTranslating(a,r);i.scrollIntoView({block:"nearest"})}_onTranslating(t,e){}_onTranslated(t,e){}get _hasBeenMoved(){return!!this.#ye&&(this.#ye[0]!==this.x||this.#ye[1]!==this.y)}get _hasBeenResized(){return!!this.#ye&&(this.#ye[2]!==this.width||this.#ye[3]!==this.height)}getBaseTranslation(){const[t,e]=this.parentDimensions,{_borderLineWidth:i}=AnnotationEditor,s=i/t,n=i/e;switch(this.rotation){case 90:return[-s,n];case 180:return[s,n];case 270:return[s,-n];default:return[-s,-n]}}get _mustFixPosition(){return!0}fixAndSetPosition(t=this.rotation){const{div:{style:e},pageDimensions:[i,s]}=this;let{x:n,y:a,width:r,height:o}=this;r*=i;o*=s;n*=i;a*=s;if(this._mustFixPosition)switch(t){case 0:n=Math.max(0,Math.min(i-r,n));a=Math.max(0,Math.min(s-o,a));break;case 90:n=Math.max(0,Math.min(i-o,n));a=Math.min(s,Math.max(r,a));break;case 180:n=Math.min(i,Math.max(r,n));a=Math.min(s,Math.max(o,a));break;case 270:n=Math.min(i,Math.max(o,n));a=Math.max(0,Math.min(s-r,a))}this.x=n/=i;this.y=a/=s;const[l,h]=this.getBaseTranslation();n+=l;a+=h;e.left=`${(100*n).toFixed(2)}%`;e.top=`${(100*a).toFixed(2)}%`;this.moveInDOM()}static#Fe(t,e,i){switch(i){case 90:return[e,-t];case 180:return[-t,-e];case 270:return[-e,t];default:return[t,e]}}screenToPageTranslation(t,e){return AnnotationEditor.#Fe(t,e,this.parentRotation)}pageTranslationToScreen(t,e){return AnnotationEditor.#Fe(t,e,360-this.parentRotation)}#Le(t){switch(t){case 90:{const[t,e]=this.pageDimensions;return[0,-t/e,e/t,0]}case 180:return[-1,0,0,-1];case 270:{const[t,e]=this.pageDimensions;return[0,t/e,-e/t,0]}default:return[1,0,0,1]}}get parentScale(){return this._uiManager.viewParameters.realScale}get parentRotation(){return(this._uiManager.viewParameters.rotation+this.pageRotation)%360}get parentDimensions(){const{parentScale:t,pageDimensions:[e,i]}=this;return[e*t,i*t]}setDims(t,e){const[i,s]=this.parentDimensions,{style:n}=this.div;n.width=`${(100*t/i).toFixed(2)}%`;this.#ge||(n.height=`${(100*e/s).toFixed(2)}%`)}fixDims(){const{style:t}=this.div,{height:e,width:i}=t,s=i.endsWith("%"),n=!this.#ge&&e.endsWith("%");if(s&&n)return;const[a,r]=this.parentDimensions;s||(t.width=`${(100*parseFloat(i)/a).toFixed(2)}%`);this.#ge||n||(t.height=`${(100*parseFloat(e)/r).toFixed(2)}%`)}getInitialTranslation(){return[0,0]}#Oe(){if(this.#me)return;this.#me=document.createElement("div");this.#me.classList.add("resizers");const t=this._willKeepAspectRatio?["topLeft","topRight","bottomRight","bottomLeft"]:["topLeft","topMiddle","topRight","middleRight","bottomRight","bottomMiddle","bottomLeft","middleLeft"],e=this._uiManager._signal;for(const i of t){const t=document.createElement("div");this.#me.append(t);t.classList.add("resizer",i);t.setAttribute("data-resizer-name",i);t.addEventListener("pointerdown",this.#Ne.bind(this,i),{signal:e});t.addEventListener("contextmenu",noContextMenu,{signal:e});t.tabIndex=-1}this.div.prepend(this.#me)}#Ne(t,e){e.preventDefault();const{isMac:i}=util_FeatureTest.platform;if(0!==e.button||e.ctrlKey&&i)return;this.#o?.toggle(!1);const s=this._isDraggable;this._isDraggable=!1;this.#fe=[e.screenX,e.screenY];const n=new AbortController,a=this._uiManager.combinedSignal(n);this.parent.togglePointerEvents(!1);window.addEventListener("pointermove",this.#Be.bind(this,t),{passive:!0,capture:!0,signal:a});window.addEventListener("touchmove",stopEvent,{passive:!1,signal:a});window.addEventListener("contextmenu",noContextMenu,{signal:a});this.#be={savedX:this.x,savedY:this.y,savedWidth:this.width,savedHeight:this.height};const r=this.parent.div.style.cursor,o=this.div.style.cursor;this.div.style.cursor=this.parent.div.style.cursor=window.getComputedStyle(e.target).cursor;const pointerUpCallback=()=>{n.abort();this.parent.togglePointerEvents(!0);this.#o?.toggle(!0);this._isDraggable=s;this.parent.div.style.cursor=r;this.div.style.cursor=o;this.#He()};window.addEventListener("pointerup",pointerUpCallback,{signal:a});window.addEventListener("blur",pointerUpCallback,{signal:a})}#ze(t,e,i,s){this.width=i;this.height=s;this.x=t;this.y=e;const[n,a]=this.parentDimensions;this.setDims(n*i,a*s);this.fixAndSetPosition();this._onResized()}_onResized(){}#He(){if(!this.#be)return;const{savedX:t,savedY:e,savedWidth:i,savedHeight:s}=this.#be;this.#be=null;const n=this.x,a=this.y,r=this.width,o=this.height;n===t&&a===e&&r===i&&o===s||this.addCommands({cmd:this.#ze.bind(this,n,a,r,o),undo:this.#ze.bind(this,t,e,i,s),mustExec:!0})}static _round(t){return Math.round(1e4*t)/1e4}#Be(t,e){const[i,s]=this.parentDimensions,n=this.x,a=this.y,r=this.width,o=this.height,l=AnnotationEditor.MIN_SIZE/i,h=AnnotationEditor.MIN_SIZE/s,d=this.#Le(this.rotation),transf=(t,e)=>[d[0]*t+d[2]*e,d[1]*t+d[3]*e],c=this.#Le(360-this.rotation);let u,p,g=!1,m=!1;switch(t){case"topLeft":g=!0;u=(t,e)=>[0,0];p=(t,e)=>[t,e];break;case"topMiddle":u=(t,e)=>[t/2,0];p=(t,e)=>[t/2,e];break;case"topRight":g=!0;u=(t,e)=>[t,0];p=(t,e)=>[0,e];break;case"middleRight":m=!0;u=(t,e)=>[t,e/2];p=(t,e)=>[0,e/2];break;case"bottomRight":g=!0;u=(t,e)=>[t,e];p=(t,e)=>[0,0];break;case"bottomMiddle":u=(t,e)=>[t/2,e];p=(t,e)=>[t/2,0];break;case"bottomLeft":g=!0;u=(t,e)=>[0,e];p=(t,e)=>[t,0];break;case"middleLeft":m=!0;u=(t,e)=>[0,e/2];p=(t,e)=>[t,e/2]}const f=u(r,o),b=p(r,o);let A=transf(...b);const w=AnnotationEditor._round(n+A[0]),v=AnnotationEditor._round(a+A[1]);let y,x,_=1,E=1;if(e.fromKeyboard)({deltaX:y,deltaY:x}=e);else{const{screenX:t,screenY:i}=e,[s,n]=this.#fe;[y,x]=this.screenToPageTranslation(t-s,i-n);this.#fe[0]=t;this.#fe[1]=i}[y,x]=(S=y/i,C=x/s,[c[0]*S+c[2]*C,c[1]*S+c[3]*C]);var S,C;if(g){const t=Math.hypot(r,o);_=E=Math.max(Math.min(Math.hypot(b[0]-f[0]-y,b[1]-f[1]-x)/t,1/r,1/o),l/r,h/o)}else m?_=Math.max(l,Math.min(1,Math.abs(b[0]-f[0]-y)))/r:E=Math.max(h,Math.min(1,Math.abs(b[1]-f[1]-x)))/o;const T=AnnotationEditor._round(r*_),M=AnnotationEditor._round(o*E);A=transf(...p(T,M));const P=w-A[0],D=v-A[1];this.#ye||=[this.x,this.y,this.width,this.height];this.width=T;this.height=M;this.x=P;this.y=D;this.setDims(i*T,s*M);this.fixAndSetPosition();this._onResizing()}_onResizing(){}altTextFinish(){this.#o?.finish()}async addEditToolbar(){if(this._editToolbar||this.#_e)return this._editToolbar;this._editToolbar=new EditorToolbar(this);this.div.append(this._editToolbar.render());this.#o&&await this._editToolbar.addAltText(this.#o);return this._editToolbar}removeEditToolbar(){if(this._editToolbar){this._editToolbar.remove();this._editToolbar=null;this.#o?.destroy()}}addContainer(t){const e=this._editToolbar?.div;e?e.before(t):this.div.append(t)}getClientDimensions(){return this.div.getBoundingClientRect()}async addAltTextButton(){if(!this.#o){AltText.initialize(AnnotationEditor._l10n);this.#o=new AltText(this);if(this.#he){this.#o.data=this.#he;this.#he=null}await this.addEditToolbar()}}get altTextData(){return this.#o?.data}set altTextData(t){this.#o&&(this.#o.data=t)}get guessedAltText(){return this.#o?.guessedText}async setGuessedAltText(t){await(this.#o?.setGuessedText(t))}serializeAltText(t){return this.#o?.serialize(t)}hasAltText(){return!!this.#o&&!this.#o.isEmpty()}hasAltTextData(){return this.#o?.hasData()??!1}render(){this.div=document.createElement("div");this.div.setAttribute("data-editor-rotation",(360-this.rotation)%360);this.div.className=this.name;this.div.setAttribute("id",this.id);this.div.tabIndex=this.#ce?-1:0;this._isVisible||this.div.classList.add("hidden");this.setInForeground();this.#Ue();const[t,e]=this.parentDimensions;if(this.parentRotation%180!=0){this.div.style.maxWidth=`${(100*e/t).toFixed(2)}%`;this.div.style.maxHeight=`${(100*t/e).toFixed(2)}%`}const[i,s]=this.getInitialTranslation();this.translate(i,s);bindEvents(this,this.div,["pointerdown"]);this.isResizable&&this._uiManager._supportsPinchToZoom&&(this.#Pe||=new TouchManager({container:this.div,isPinchingDisabled:()=>!this.isSelected,onPinchStart:this.#Ge.bind(this),onPinching:this.#$e.bind(this),onPinchEnd:this.#Ve.bind(this),signal:this._uiManager._signal}));this._uiManager._editorUndoBar?.hide();return this.div}#Ge(){this.#be={savedX:this.x,savedY:this.y,savedWidth:this.width,savedHeight:this.height};this.#o?.toggle(!1);this.parent.togglePointerEvents(!1)}#$e(t,e,i){let s=i/e*.7+1-.7;if(1===s)return;const n=this.#Le(this.rotation),transf=(t,e)=>[n[0]*t+n[2]*e,n[1]*t+n[3]*e],[a,r]=this.parentDimensions,o=this.x,l=this.y,h=this.width,d=this.height,c=AnnotationEditor.MIN_SIZE/a,u=AnnotationEditor.MIN_SIZE/r;s=Math.max(Math.min(s,1/h,1/d),c/h,u/d);const p=AnnotationEditor._round(h*s),g=AnnotationEditor._round(d*s);if(p===h&&g===d)return;this.#ye||=[o,l,h,d];const m=transf(h/2,d/2),f=AnnotationEditor._round(o+m[0]),b=AnnotationEditor._round(l+m[1]),A=transf(p/2,g/2);this.x=f-A[0];this.y=b-A[1];this.width=p;this.height=g;this.setDims(a*p,r*g);this.fixAndSetPosition();this._onResizing()}#Ve(){this.#o?.toggle(!0);this.parent.togglePointerEvents(!0);this.#He()}pointerdown(t){const{isMac:e}=util_FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)t.preventDefault();else{this.#ve=!0;this._isDraggable?this.#je(t):this.#We(t)}}get isSelected(){return this._uiManager.isSelected(this)}#We(t){const{isMac:e}=util_FeatureTest.platform;t.ctrlKey&&!e||t.shiftKey||t.metaKey&&e?this.parent.toggleSelected(this):this.parent.setSelected(this)}#je(t){const{isSelected:e}=this;this._uiManager.setUpDragSession();let i=!1;const s=new AbortController,n=this._uiManager.combinedSignal(s),a={capture:!0,passive:!1,signal:n},cancelDrag=t=>{s.abort();this.#ue=null;this.#ve=!1;this._uiManager.endDragSession()||this.#We(t);i&&this._onStopDragging()};if(e){this.#Ce=t.clientX;this.#Te=t.clientY;this.#ue=t.pointerId;this.#pe=t.pointerType;window.addEventListener("pointermove",(t=>{if(!i){i=!0;this._onStartDragging()}const{clientX:e,clientY:s,pointerId:n}=t;if(n!==this.#ue){stopEvent(t);return}const[a,r]=this.screenToPageTranslation(e-this.#Ce,s-this.#Te);this.#Ce=e;this.#Te=s;this._uiManager.dragSelectedEditors(a,r)}),a);window.addEventListener("touchmove",stopEvent,a);window.addEventListener("pointerdown",(t=>{t.pointerType===this.#pe&&(this.#Pe||t.isPrimary)&&cancelDrag(t);stopEvent(t)}),a)}const pointerUpCallback=t=>{this.#ue&&this.#ue!==t.pointerId?stopEvent(t):cancelDrag(t)};window.addEventListener("pointerup",pointerUpCallback,{signal:n});window.addEventListener("blur",pointerUpCallback,{signal:n})}_onStartDragging(){}_onStopDragging(){}moveInDOM(){this.#Se&&clearTimeout(this.#Se);this.#Se=setTimeout((()=>{this.#Se=null;this.parent?.moveEditorInDOM(this)}),0)}_setParentAndPosition(t,e,i){t.changeParent(this);this.x=e;this.y=i;this.fixAndSetPosition();this._onTranslated()}getRect(t,e,i=this.rotation){const s=this.parentScale,[n,a]=this.pageDimensions,[r,o]=this.pageTranslation,l=t/s,h=e/s,d=this.x*n,c=this.y*a,u=this.width*n,p=this.height*a;switch(i){case 0:return[d+l+r,a-c-h-p+o,d+l+u+r,a-c-h+o];case 90:return[d+h+r,a-c+l+o,d+h+p+r,a-c+l+u+o];case 180:return[d-l-u+r,a-c+h+o,d-l+r,a-c+h+p+o];case 270:return[d-h-p+r,a-c-l-u+o,d-h+r,a-c-l+o];default:throw new Error("Invalid rotation")}}getRectInCurrentCoords(t,e){const[i,s,n,a]=t,r=n-i,o=a-s;switch(this.rotation){case 0:return[i,e-a,r,o];case 90:return[i,e-s,o,r];case 180:return[n,e-s,r,o];case 270:return[n,e-a,o,r];default:throw new Error("Invalid rotation")}}onceAdded(t){}isEmpty(){return!1}enableEditMode(){this.#_e=!0}disableEditMode(){this.#_e=!1}isInEditMode(){return this.#_e}shouldGetKeyboardEvents(){return this.#Ee}needsToBeRebuilt(){return this.div&&!this.isAttachedToDOM}get isOnScreen(){const{top:t,left:e,bottom:i,right:s}=this.getClientDimensions(),{innerHeight:n,innerWidth:a}=window;return e0&&t0}#Ue(){if(this.#Ae||!this.div)return;this.#Ae=new AbortController;const t=this._uiManager.combinedSignal(this.#Ae);this.div.addEventListener("focusin",this.focusin.bind(this),{signal:t});this.div.addEventListener("focusout",this.focusout.bind(this),{signal:t})}rebuild(){this.#Ue()}rotate(t){}resize(){}serializeDeleted(){return{id:this.annotationElementId,deleted:!0,pageIndex:this.pageIndex,popupRef:this._initialData?.popupRef||""}}serialize(t=!1,e=null){unreachable("An editor must be serializable")}static async deserialize(t,e,i){const s=new this.prototype.constructor({parent:e,id:e.getNextId(),uiManager:i});s.rotation=t.rotation;s.#he=t.accessibilityData;const[n,a]=s.pageDimensions,[r,o,l,h]=s.getRectInCurrentCoords(t.rect,a);s.x=r/n;s.y=o/a;s.width=l/n;s.height=h/a;return s}get hasBeenModified(){return!!this.annotationElementId&&(this.deleted||null!==this.serialize())}remove(){this.#Ae?.abort();this.#Ae=null;this.isEmpty()||this.commit();this.parent?this.parent.remove(this):this._uiManager.removeEditor(this);if(this.#Se){clearTimeout(this.#Se);this.#Se=null}this.#Re();this.removeEditToolbar();if(this.#Me){for(const t of this.#Me.values())clearTimeout(t);this.#Me=null}this.parent=null;this.#Pe?.destroy();this.#Pe=null}get isResizable(){return!1}makeResizable(){if(this.isResizable){this.#Oe();this.#me.classList.remove("hidden");bindEvents(this,this.div,["keydown"])}}get toolbarPosition(){return null}keydown(t){if(!this.isResizable||t.target!==this.div||"Enter"!==t.key)return;this._uiManager.setSelected(this);this.#be={savedX:this.x,savedY:this.y,savedWidth:this.width,savedHeight:this.height};const e=this.#me.children;if(!this.#de){this.#de=Array.from(e);const t=this.#qe.bind(this),i=this.#Xe.bind(this),s=this._uiManager._signal;for(const e of this.#de){const n=e.getAttribute("data-resizer-name");e.setAttribute("role","spinbutton");e.addEventListener("keydown",t,{signal:s});e.addEventListener("blur",i,{signal:s});e.addEventListener("focus",this.#Ke.bind(this,n),{signal:s});e.setAttribute("data-l10n-id",AnnotationEditor._l10nResizer[n])}}const i=this.#de[0];let s=0;for(const t of e){if(t===i)break;s++}const n=(360-this.rotation+this.parentRotation)%360/90*(this.#de.length/4);if(n!==s){if(ns)for(let t=0;t{this.div?.classList.contains("selectedEditor")&&this._editToolbar?.show()}))}unselect(){this.#me?.classList.add("hidden");this.div?.classList.remove("selectedEditor");this.div?.contains(document.activeElement)&&this._uiManager.currentLayer.div.focus({preventScroll:!0});this._editToolbar?.hide();this.#o?.toggleAltTextBadge(!0)}updateParams(t,e){}disableEditing(){}enableEditing(){}enterInEditMode(){}getImageForAltText(){return null}get contentDiv(){return this.div}get isEditing(){return this.#xe}set isEditing(t){this.#xe=t;if(this.parent)if(t){this.parent.setSelected(this);this.parent.setActiveEditor(this)}else this.parent.setActiveEditor(null)}setAspectRatio(t,e){this.#ge=!0;const i=t/e,{style:s}=this.div;s.aspectRatio=i;s.height="auto"}static get MIN_SIZE(){return 16}static canCreateNewEmptyEditor(){return!0}get telemetryInitialData(){return{action:"added"}}get telemetryFinalData(){return null}_reportTelemetry(t,e=!1){if(e){this.#Me||=new Map;const{action:e}=t;let i=this.#Me.get(e);i&&clearTimeout(i);i=setTimeout((()=>{this._reportTelemetry(t);this.#Me.delete(e);0===this.#Me.size&&(this.#Me=null)}),AnnotationEditor._telemetryTimeout);this.#Me.set(e,i)}else{t.type||=this.editorType;this._uiManager._eventBus.dispatch("reporttelemetry",{source:this,details:{type:"editing",data:t}})}}show(t=this._isVisible){this.div.classList.toggle("hidden",!t);this._isVisible=t}enable(){this.div&&(this.div.tabIndex=0);this.#ce=!1}disable(){this.div&&(this.div.tabIndex=-1);this.#ce=!0}renderAnnotationElement(t){let e=t.container.querySelector(".annotationContent");if(e){if("CANVAS"===e.nodeName){const t=e;e=document.createElement("div");e.classList.add("annotationContent",this.editorType);t.before(e)}}else{e=document.createElement("div");e.classList.add("annotationContent",this.editorType);t.container.prepend(e)}return e}resetAnnotationElement(t){const{firstChild:e}=t.container;"DIV"===e?.nodeName&&e.classList.contains("annotationContent")&&e.remove()}}class FakeEditor extends AnnotationEditor{constructor(t){super(t);this.annotationElementId=t.annotationElementId;this.deleted=!0}serialize(){return this.serializeDeleted()}}const st=3285377520,nt=4294901760,at=65535;class MurmurHash3_64{constructor(t){this.h1=t?4294967295&t:st;this.h2=t?4294967295&t:st}update(t){let e,i;if("string"==typeof t){e=new Uint8Array(2*t.length);i=0;for(let s=0,n=t.length;s>>8;e[i++]=255&n}}}else{if(!ArrayBuffer.isView(t))throw new Error("Invalid data format, must be a string or TypedArray.");e=t.slice();i=e.byteLength}const s=i>>2,n=i-4*s,a=new Uint32Array(e.buffer,0,s);let r=0,o=0,l=this.h1,h=this.h2;const d=3432918353,c=461845907,u=11601,p=13715;for(let t=0;t>>17;r=r*c&nt|r*p&at;l^=r;l=l<<13|l>>>19;l=5*l+3864292196}else{o=a[t];o=o*d&nt|o*u&at;o=o<<15|o>>>17;o=o*c&nt|o*p&at;h^=o;h=h<<13|h>>>19;h=5*h+3864292196}r=0;switch(n){case 3:r^=e[4*s+2]<<16;case 2:r^=e[4*s+1]<<8;case 1:r^=e[4*s];r=r*d&nt|r*u&at;r=r<<15|r>>>17;r=r*c&nt|r*p&at;1&s?l^=r:h^=r}this.h1=l;this.h2=h}hexdigest(){let t=this.h1,e=this.h2;t^=e>>>1;t=3981806797*t&nt|36045*t&at;e=4283543511*e&nt|(2950163797*(e<<16|t>>>16)&nt)>>>16;t^=e>>>1;t=444984403*t&nt|60499*t&at;e=3301882366*e&nt|(3120437893*(e<<16|t>>>16)&nt)>>>16;t^=e>>>1;return(t>>>0).toString(16).padStart(8,"0")+(e>>>0).toString(16).padStart(8,"0")}}const rt=Object.freeze({map:null,hash:"",transfer:void 0});class AnnotationStorage{#Qe=!1;#Je=null;#Ze=new Map;constructor(){this.onSetModified=null;this.onResetModified=null;this.onAnnotationEditor=null}getValue(t,e){const i=this.#Ze.get(t);return void 0===i?e:Object.assign(e,i)}getRawValue(t){return this.#Ze.get(t)}remove(t){this.#Ze.delete(t);0===this.#Ze.size&&this.resetModified();if("function"==typeof this.onAnnotationEditor){for(const t of this.#Ze.values())if(t instanceof AnnotationEditor)return;this.onAnnotationEditor(null)}}setValue(t,e){const i=this.#Ze.get(t);let s=!1;if(void 0!==i){for(const[t,n]of Object.entries(e))if(i[t]!==n){s=!0;i[t]=n}}else{s=!0;this.#Ze.set(t,e)}s&&this.#ti();e instanceof AnnotationEditor&&"function"==typeof this.onAnnotationEditor&&this.onAnnotationEditor(e.constructor._type)}has(t){return this.#Ze.has(t)}getAll(){return this.#Ze.size>0?objectFromMap(this.#Ze):null}setAll(t){for(const[e,i]of Object.entries(t))this.setValue(e,i)}get size(){return this.#Ze.size}#ti(){if(!this.#Qe){this.#Qe=!0;"function"==typeof this.onSetModified&&this.onSetModified()}}resetModified(){if(this.#Qe){this.#Qe=!1;"function"==typeof this.onResetModified&&this.onResetModified()}}get print(){return new PrintAnnotationStorage(this)}get serializable(){if(0===this.#Ze.size)return rt;const t=new Map,e=new MurmurHash3_64,i=[],s=Object.create(null);let n=!1;for(const[i,a]of this.#Ze){const r=a instanceof AnnotationEditor?a.serialize(!1,s):a;if(r){t.set(i,r);e.update(`${i}:${JSON.stringify(r)}`);n||=!!r.bitmap}}if(n)for(const e of t.values())e.bitmap&&i.push(e.bitmap);return t.size>0?{map:t,hash:e.hexdigest(),transfer:i}:rt}get editorStats(){let t=null;const e=new Map;for(const i of this.#Ze.values()){if(!(i instanceof AnnotationEditor))continue;const s=i.telemetryFinalData;if(!s)continue;const{type:n}=s;e.has(n)||e.set(n,Object.getPrototypeOf(i).constructor);t||=Object.create(null);const a=t[n]||=new Map;for(const[t,e]of Object.entries(s)){if("type"===t)continue;let i=a.get(t);if(!i){i=new Map;a.set(t,i)}const s=i.get(e)??0;i.set(e,s+1)}}for(const[i,s]of e)t[i]=s.computeTelemetryFinalData(t[i]);return t}resetModifiedIds(){this.#Je=null}get modifiedIds(){if(this.#Je)return this.#Je;const t=[];for(const e of this.#Ze.values())e instanceof AnnotationEditor&&e.annotationElementId&&e.serialize()&&t.push(e.annotationElementId);return this.#Je={ids:new Set(t),hash:t.join(",")}}}class PrintAnnotationStorage extends AnnotationStorage{#ei;constructor(t){super();const{map:e,hash:i,transfer:s}=t.serializable,n=structuredClone(e,s?{transfer:s}:null);this.#ei={map:n,hash:i,transfer:s}}get print(){unreachable("Should not call PrintAnnotationStorage.print")}get serializable(){return this.#ei}get modifiedIds(){return shadow(this,"modifiedIds",{ids:new Set,hash:""})}}class FontLoader{#ii=new Set;constructor({ownerDocument:t=globalThis.document,styleElement:e=null}){this._document=t;this.nativeFontFaces=new Set;this.styleElement=null;this.loadingRequests=[];this.loadTestFontId=0}addNativeFontFace(t){this.nativeFontFaces.add(t);this._document.fonts.add(t)}removeNativeFontFace(t){this.nativeFontFaces.delete(t);this._document.fonts.delete(t)}insertRule(t){if(!this.styleElement){this.styleElement=this._document.createElement("style");this._document.documentElement.getElementsByTagName("head")[0].append(this.styleElement)}const e=this.styleElement.sheet;e.insertRule(t,e.cssRules.length)}clear(){for(const t of this.nativeFontFaces)this._document.fonts.delete(t);this.nativeFontFaces.clear();this.#ii.clear();if(this.styleElement){this.styleElement.remove();this.styleElement=null}}async loadSystemFont({systemFontInfo:t,_inspectFont:e}){if(t&&!this.#ii.has(t.loadedName)){assert(!this.disableFontFace,"loadSystemFont shouldn't be called when `disableFontFace` is set.");if(this.isFontLoadingAPISupported){const{loadedName:i,src:s,style:n}=t,a=new FontFace(i,s,n);this.addNativeFontFace(a);try{await a.load();this.#ii.add(i);e?.(t)}catch{warn(`Cannot load system font: ${t.baseFontName}, installing it could help to improve PDF rendering.`);this.removeNativeFontFace(a)}}else unreachable("Not implemented: loadSystemFont without the Font Loading API.")}}async bind(t){if(t.attached||t.missingFile&&!t.systemFontInfo)return;t.attached=!0;if(t.systemFontInfo){await this.loadSystemFont(t);return}if(this.isFontLoadingAPISupported){const e=t.createNativeFontFace();if(e){this.addNativeFontFace(e);try{await e.loaded}catch(i){warn(`Failed to load font '${e.family}': '${i}'.`);t.disableFontFace=!0;throw i}}return}const e=t.createFontFaceRule();if(e){this.insertRule(e);if(this.isSyncFontLoadingSupported)return;await new Promise((e=>{const i=this._queueLoadingCallback(e);this._prepareFontLoadEvent(t,i)}))}}get isFontLoadingAPISupported(){return shadow(this,"isFontLoadingAPISupported",!!this._document?.fonts)}get isSyncFontLoadingSupported(){let t=!1;(e||"undefined"!=typeof navigator&&"string"==typeof navigator?.userAgent&&/Mozilla\/5.0.*?rv:\d+.*? Gecko/.test(navigator.userAgent))&&(t=!0);return shadow(this,"isSyncFontLoadingSupported",t)}_queueLoadingCallback(t){const{loadingRequests:e}=this,i={done:!1,complete:function completeRequest(){assert(!i.done,"completeRequest() cannot be called twice.");i.done=!0;for(;e.length>0&&e[0].done;){const t=e.shift();setTimeout(t.callback,0)}},callback:t};e.push(i);return i}get _loadTestFont(){return shadow(this,"_loadTestFont",atob("T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQAFQAABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAAALwAAAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgAAAAGbmFtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1AAsD6AAAAADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD6AAAAAAD6AABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACMAooCvAAAAeAAMQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4DIP84AFoDIQAAAAAAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAAAAEAAQAAAAEAAAAAAAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUAAQAAAAEAAAAAAAYAAQAAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgABAAMAAQQJAAMAAgABAAMAAQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABYAAAAAAAAAwAAAAMAAAAcAAEAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAAAC7////TAAEAAAAAAAABBgAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAAAAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgcA/gXBIwMAYuL+nz5tQXkD5j3CBLnEQACAQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYAAABAQAADwACAQEEE/t3Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQAAAAAAAABAAAAAMmJbzEAAAAAzgTjFQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAgABAAAAAAAAAAAD6AAAAAAAAA=="))}_prepareFontLoadEvent(t,e){function int32(t,e){return t.charCodeAt(e)<<24|t.charCodeAt(e+1)<<16|t.charCodeAt(e+2)<<8|255&t.charCodeAt(e+3)}function spliceString(t,e,i,s){return t.substring(0,e)+s+t.substring(e+i)}let i,s;const n=this._document.createElement("canvas");n.width=1;n.height=1;const a=n.getContext("2d");let r=0;const o=`lt${Date.now()}${this.loadTestFontId++}`;let l=this._loadTestFont;l=spliceString(l,976,o.length,o);const h=1482184792;let d=int32(l,16);for(i=0,s=o.length-3;i>24&255,t>>16&255,t>>8&255,255&t)}(d));const c=`@font-face {font-family:"${o}";src:${`url(data:font/opentype;base64,${btoa(l)});`}}`;this.insertRule(c);const u=this._document.createElement("div");u.style.visibility="hidden";u.style.width=u.style.height="10px";u.style.position="absolute";u.style.top=u.style.left="0px";for(const e of[t.loadedName,o]){const t=this._document.createElement("span");t.textContent="Hi";t.style.fontFamily=e;u.append(t)}this._document.body.append(u);!function isFontReady(t,e){if(++r>30){warn("Load test font never loaded.");e();return}a.font="30px "+t;a.fillText(".",0,20);a.getImageData(0,0,1,1).data[3]>0?e():setTimeout(isFontReady.bind(null,t,e))}(o,(()=>{u.remove();e.complete()}))}}class FontFaceObject{constructor(t,{disableFontFace:e=!1,fontExtraProperties:i=!1,inspectFont:s=null}){this.compiledGlyphs=Object.create(null);for(const e in t)this[e]=t[e];this.disableFontFace=!0===e;this.fontExtraProperties=!0===i;this._inspectFont=s}createNativeFontFace(){if(!this.data||this.disableFontFace)return null;let t;if(this.cssFontInfo){const e={weight:this.cssFontInfo.fontWeight};this.cssFontInfo.italicAngle&&(e.style=`oblique ${this.cssFontInfo.italicAngle}deg`);t=new FontFace(this.cssFontInfo.fontFamily,this.data,e)}else t=new FontFace(this.loadedName,this.data,{});this._inspectFont?.(this);return t}createFontFaceRule(){if(!this.data||this.disableFontFace)return null;const t=`url(data:${this.mimetype};base64,${function toBase64Util(t){return Uint8Array.prototype.toBase64?t.toBase64():btoa(bytesToString(t))}(this.data)});`;let e;if(this.cssFontInfo){let i=`font-weight: ${this.cssFontInfo.fontWeight};`;this.cssFontInfo.italicAngle&&(i+=`font-style: oblique ${this.cssFontInfo.italicAngle}deg;`);e=`@font-face {font-family:"${this.cssFontInfo.fontFamily}";${i}src:${t}}`}else e=`@font-face {font-family:"${this.loadedName}";src:${t}}`;this._inspectFont?.(this,t);return e}getPathGenerator(t,e){if(void 0!==this.compiledGlyphs[e])return this.compiledGlyphs[e];const i=this.loadedName+"_path_"+e;let s;try{s=t.get(i)}catch(t){warn(`getPathGenerator - ignoring character: "${t}".`)}const n=new Path2D(s||"");this.fontExtraProperties||t.delete(i);return this.compiledGlyphs[e]=n}}const ot=1,lt=2,ht=1,dt=2,ct=3,ut=4,pt=5,gt=6,mt=7,ft=8;function onFn(){}function wrapReason(t){if(t instanceof AbortException||t instanceof InvalidPDFException||t instanceof MissingPDFException||t instanceof PasswordException||t instanceof UnexpectedResponseException||t instanceof UnknownErrorException)return t;t instanceof Error||"object"==typeof t&&null!==t||unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');switch(t.name){case"AbortException":return new AbortException(t.message);case"InvalidPDFException":return new InvalidPDFException(t.message);case"MissingPDFException":return new MissingPDFException(t.message);case"PasswordException":return new PasswordException(t.message,t.code);case"UnexpectedResponseException":return new UnexpectedResponseException(t.message,t.status);case"UnknownErrorException":return new UnknownErrorException(t.message,t.details)}return new UnknownErrorException(t.message,t.toString())}class MessageHandler{#si=new AbortController;constructor(t,e,i){this.sourceName=t;this.targetName=e;this.comObj=i;this.callbackId=1;this.streamId=1;this.streamSinks=Object.create(null);this.streamControllers=Object.create(null);this.callbackCapabilities=Object.create(null);this.actionHandler=Object.create(null);i.addEventListener("message",this.#ni.bind(this),{signal:this.#si.signal})}#ni({data:t}){if(t.targetName!==this.sourceName)return;if(t.stream){this.#ai(t);return}if(t.callback){const e=t.callbackId,i=this.callbackCapabilities[e];if(!i)throw new Error(`Cannot resolve callback ${e}`);delete this.callbackCapabilities[e];if(t.callback===ot)i.resolve(t.data);else{if(t.callback!==lt)throw new Error("Unexpected callback case");i.reject(wrapReason(t.reason))}return}const e=this.actionHandler[t.action];if(!e)throw new Error(`Unknown action from worker: ${t.action}`);if(t.callbackId){const i=this.sourceName,s=t.sourceName,n=this.comObj;Promise.try(e,t.data).then((function(e){n.postMessage({sourceName:i,targetName:s,callback:ot,callbackId:t.callbackId,data:e})}),(function(e){n.postMessage({sourceName:i,targetName:s,callback:lt,callbackId:t.callbackId,reason:wrapReason(e)})}))}else t.streamId?this.#ri(t):e(t.data)}on(t,e){const i=this.actionHandler;if(i[t])throw new Error(`There is already an actionName called "${t}"`);i[t]=e}send(t,e,i){this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,data:e},i)}sendWithPromise(t,e,i){const s=this.callbackId++,n=Promise.withResolvers();this.callbackCapabilities[s]=n;try{this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:t,callbackId:s,data:e},i)}catch(t){n.reject(t)}return n.promise}sendWithStream(t,e,i,s){const n=this.streamId++,a=this.sourceName,r=this.targetName,o=this.comObj;return new ReadableStream({start:i=>{const l=Promise.withResolvers();this.streamControllers[n]={controller:i,startCall:l,pullCall:null,cancelCall:null,isClosed:!1};o.postMessage({sourceName:a,targetName:r,action:t,streamId:n,data:e,desiredSize:i.desiredSize},s);return l.promise},pull:t=>{const e=Promise.withResolvers();this.streamControllers[n].pullCall=e;o.postMessage({sourceName:a,targetName:r,stream:gt,streamId:n,desiredSize:t.desiredSize});return e.promise},cancel:t=>{assert(t instanceof Error,"cancel must have a valid reason");const e=Promise.withResolvers();this.streamControllers[n].cancelCall=e;this.streamControllers[n].isClosed=!0;o.postMessage({sourceName:a,targetName:r,stream:ht,streamId:n,reason:wrapReason(t)});return e.promise}},i)}#ri(t){const e=t.streamId,i=this.sourceName,s=t.sourceName,n=this.comObj,a=this,r=this.actionHandler[t.action],o={enqueue(t,a=1,r){if(this.isCancelled)return;const o=this.desiredSize;this.desiredSize-=a;if(o>0&&this.desiredSize<=0){this.sinkCapability=Promise.withResolvers();this.ready=this.sinkCapability.promise}n.postMessage({sourceName:i,targetName:s,stream:ut,streamId:e,chunk:t},r)},close(){if(!this.isCancelled){this.isCancelled=!0;n.postMessage({sourceName:i,targetName:s,stream:ct,streamId:e});delete a.streamSinks[e]}},error(t){assert(t instanceof Error,"error must have a valid reason");if(!this.isCancelled){this.isCancelled=!0;n.postMessage({sourceName:i,targetName:s,stream:pt,streamId:e,reason:wrapReason(t)})}},sinkCapability:Promise.withResolvers(),onPull:null,onCancel:null,isCancelled:!1,desiredSize:t.desiredSize,ready:null};o.sinkCapability.resolve();o.ready=o.sinkCapability.promise;this.streamSinks[e]=o;Promise.try(r,t.data,o).then((function(){n.postMessage({sourceName:i,targetName:s,stream:ft,streamId:e,success:!0})}),(function(t){n.postMessage({sourceName:i,targetName:s,stream:ft,streamId:e,reason:wrapReason(t)})}))}#ai(t){const e=t.streamId,i=this.sourceName,s=t.sourceName,n=this.comObj,a=this.streamControllers[e],r=this.streamSinks[e];switch(t.stream){case ft:t.success?a.startCall.resolve():a.startCall.reject(wrapReason(t.reason));break;case mt:t.success?a.pullCall.resolve():a.pullCall.reject(wrapReason(t.reason));break;case gt:if(!r){n.postMessage({sourceName:i,targetName:s,stream:mt,streamId:e,success:!0});break}r.desiredSize<=0&&t.desiredSize>0&&r.sinkCapability.resolve();r.desiredSize=t.desiredSize;Promise.try(r.onPull||onFn).then((function(){n.postMessage({sourceName:i,targetName:s,stream:mt,streamId:e,success:!0})}),(function(t){n.postMessage({sourceName:i,targetName:s,stream:mt,streamId:e,reason:wrapReason(t)})}));break;case ut:assert(a,"enqueue should have stream controller");if(a.isClosed)break;a.controller.enqueue(t.chunk);break;case ct:assert(a,"close should have stream controller");if(a.isClosed)break;a.isClosed=!0;a.controller.close();this.#oi(a,e);break;case pt:assert(a,"error should have stream controller");a.controller.error(wrapReason(t.reason));this.#oi(a,e);break;case dt:t.success?a.cancelCall.resolve():a.cancelCall.reject(wrapReason(t.reason));this.#oi(a,e);break;case ht:if(!r)break;const o=wrapReason(t.reason);Promise.try(r.onCancel||onFn,o).then((function(){n.postMessage({sourceName:i,targetName:s,stream:dt,streamId:e,success:!0})}),(function(t){n.postMessage({sourceName:i,targetName:s,stream:dt,streamId:e,reason:wrapReason(t)})}));r.sinkCapability.reject(o);r.isCancelled=!0;delete this.streamSinks[e];break;default:throw new Error("Unexpected stream case")}}async#oi(t,e){await Promise.allSettled([t.startCall?.promise,t.pullCall?.promise,t.cancelCall?.promise]);delete this.streamControllers[e]}destroy(){this.#si?.abort();this.#si=null}}class BaseCanvasFactory{#li=!1;constructor({enableHWA:t=!1}){this.#li=t}create(t,e){if(t<=0||e<=0)throw new Error("Invalid canvas size");const i=this._createCanvas(t,e);return{canvas:i,context:i.getContext("2d",{willReadFrequently:!this.#li})}}reset(t,e,i){if(!t.canvas)throw new Error("Canvas is not specified");if(e<=0||i<=0)throw new Error("Invalid canvas size");t.canvas.width=e;t.canvas.height=i}destroy(t){if(!t.canvas)throw new Error("Canvas is not specified");t.canvas.width=0;t.canvas.height=0;t.canvas=null;t.context=null}_createCanvas(t,e){unreachable("Abstract method `_createCanvas` called.")}}class BaseCMapReaderFactory{constructor({baseUrl:t=null,isCompressed:e=!0}){this.baseUrl=t;this.isCompressed=e}async fetch({name:t}){if(!this.baseUrl)throw new Error("Ensure that the `cMapUrl` and `cMapPacked` API parameters are provided.");if(!t)throw new Error("CMap name must be specified.");const e=this.baseUrl+t+(this.isCompressed?".bcmap":"");return this._fetch(e).then((t=>({cMapData:t,isCompressed:this.isCompressed}))).catch((t=>{throw new Error(`Unable to load ${this.isCompressed?"binary ":""}CMap at: ${e}`)}))}async _fetch(t){unreachable("Abstract method `_fetch` called.")}}class DOMCMapReaderFactory extends BaseCMapReaderFactory{async _fetch(t){const e=await fetchData(t,this.isCompressed?"arraybuffer":"text");return e instanceof ArrayBuffer?new Uint8Array(e):stringToBytes(e)}}class BaseFilterFactory{addFilter(t){return"none"}addHCMFilter(t,e){return"none"}addAlphaFilter(t){return"none"}addLuminosityFilter(t){return"none"}addHighlightHCMFilter(t,e,i,s,n){return"none"}destroy(t=!1){}}class BaseStandardFontDataFactory{constructor({baseUrl:t=null}){this.baseUrl=t}async fetch({filename:t}){if(!this.baseUrl)throw new Error("Ensure that the `standardFontDataUrl` API parameter is provided.");if(!t)throw new Error("Font filename must be specified.");const e=`${this.baseUrl}${t}`;return this._fetch(e).catch((t=>{throw new Error(`Unable to load font data at: ${e}`)}))}async _fetch(t){unreachable("Abstract method `_fetch` called.")}}class DOMStandardFontDataFactory extends BaseStandardFontDataFactory{async _fetch(t){const e=await fetchData(t,"arraybuffer");return new Uint8Array(e)}}e&&warn("Please use the `legacy` build in Node.js environments.");async function node_utils_fetchData(t){const e=process.getBuiltinModule("fs"),i=await e.promises.readFile(t);return new Uint8Array(i)}const bt="Fill",At="Stroke",wt="Shading";function applyBoundingBox(t,e){if(!e)return;const i=e[2]-e[0],s=e[3]-e[1],n=new Path2D;n.rect(e[0],e[1],i,s);t.clip(n)}class BaseShadingPattern{getPattern(){unreachable("Abstract method `getPattern` called.")}}class RadialAxialShadingPattern extends BaseShadingPattern{constructor(t){super();this._type=t[1];this._bbox=t[2];this._colorStops=t[3];this._p0=t[4];this._p1=t[5];this._r0=t[6];this._r1=t[7];this.matrix=null}_createGradient(t){let e;"axial"===this._type?e=t.createLinearGradient(this._p0[0],this._p0[1],this._p1[0],this._p1[1]):"radial"===this._type&&(e=t.createRadialGradient(this._p0[0],this._p0[1],this._r0,this._p1[0],this._p1[1],this._r1));for(const t of this._colorStops)e.addColorStop(t[0],t[1]);return e}getPattern(t,e,i,s){let n;if(s===At||s===bt){const a=e.current.getClippedPathBoundingBox(s,getCurrentTransform(t))||[0,0,0,0],r=Math.ceil(a[2]-a[0])||1,o=Math.ceil(a[3]-a[1])||1,l=e.cachedCanvases.getCanvas("pattern",r,o),h=l.context;h.clearRect(0,0,h.canvas.width,h.canvas.height);h.beginPath();h.rect(0,0,h.canvas.width,h.canvas.height);h.translate(-a[0],-a[1]);i=Util.transform(i,[1,0,0,1,a[0],a[1]]);h.transform(...e.baseTransform);this.matrix&&h.transform(...this.matrix);applyBoundingBox(h,this._bbox);h.fillStyle=this._createGradient(h);h.fill();n=t.createPattern(l.canvas,"no-repeat");const d=new DOMMatrix(i);n.setTransform(d)}else{applyBoundingBox(t,this._bbox);n=this._createGradient(t)}return n}}function drawTriangle(t,e,i,s,n,a,r,o){const l=e.coords,h=e.colors,d=t.data,c=4*t.width;let u;if(l[i+1]>l[s+1]){u=i;i=s;s=u;u=a;a=r;r=u}if(l[s+1]>l[n+1]){u=s;s=n;n=u;u=r;r=o;o=u}if(l[i+1]>l[s+1]){u=i;i=s;s=u;u=a;a=r;r=u}const p=(l[i]+e.offsetX)*e.scaleX,g=(l[i+1]+e.offsetY)*e.scaleY,m=(l[s]+e.offsetX)*e.scaleX,f=(l[s+1]+e.offsetY)*e.scaleY,b=(l[n]+e.offsetX)*e.scaleX,A=(l[n+1]+e.offsetY)*e.scaleY;if(g>=A)return;const w=h[a],v=h[a+1],y=h[a+2],x=h[r],_=h[r+1],E=h[r+2],S=h[o],C=h[o+1],T=h[o+2],M=Math.round(g),P=Math.round(A);let D,k,R,I,F,L,O,N;for(let t=M;t<=P;t++){if(tA?1:f===A?0:(f-t)/(f-A);D=m-(m-b)*e;k=x-(x-S)*e;R=_-(_-C)*e;I=E-(E-T)*e}let e;e=tA?1:(g-t)/(g-A);F=p-(p-b)*e;L=w-(w-S)*e;O=v-(v-C)*e;N=y-(y-T)*e;const i=Math.round(Math.min(D,F)),s=Math.round(Math.max(D,F));let n=c*t+4*i;for(let t=i;t<=s;t++){e=(D-t)/(D-F);e<0?e=0:e>1&&(e=1);d[n++]=k-(k-L)*e|0;d[n++]=R-(R-O)*e|0;d[n++]=I-(I-N)*e|0;d[n++]=255}}}function drawFigure(t,e,i){const s=e.coords,n=e.colors;let a,r;switch(e.type){case"lattice":const o=e.verticesPerRow,l=Math.floor(s.length/o)-1,h=o-1;for(a=0;a=Math.ceil(p*b)?w=o:y=!0;E>=Math.ceil(g*A)?v=l:x=!0;const S=this.getSizeAndScale(w,this.ctx.canvas.width,b),C=this.getSizeAndScale(v,this.ctx.canvas.height,A),T=t.cachedCanvases.getCanvas("pattern",S.size,C.size),M=T.context,P=r.createCanvasGraphics(M);P.groupLevel=t.groupLevel;this.setFillAndStrokeStyleToContext(P,s,a);M.translate(-S.scale*h,-C.scale*d);P.transform(S.scale,0,0,C.scale,0,0);M.save();this.clipBbox(P,h,d,c,u);P.baseTransform=getCurrentTransform(P.ctx);P.executeOperatorList(i);P.endDrawing();M.restore();if(y||x){const e=T.canvas;y&&(w=o);x&&(v=l);const i=this.getSizeAndScale(w,this.ctx.canvas.width,b),s=this.getSizeAndScale(v,this.ctx.canvas.height,A),n=i.size,a=s.size,r=t.cachedCanvases.getCanvas("pattern-workaround",n,a),c=r.context,u=y?Math.floor(p/o):0,m=x?Math.floor(g/l):0;for(let t=0;t<=u;t++)for(let i=0;i<=m;i++)c.drawImage(e,n*t,a*i,n,a,0,0,n,a);return{canvas:r.canvas,scaleX:i.scale,scaleY:s.scale,offsetX:h,offsetY:d}}return{canvas:T.canvas,scaleX:S.scale,scaleY:C.scale,offsetX:h,offsetY:d}}getSizeAndScale(t,e,i){const s=Math.max(TilingPattern.MAX_PATTERN_SIZE,e);let n=Math.ceil(t*i);n>=s?n=s:i=n/t;return{scale:i,size:n}}clipBbox(t,e,i,s,n){const a=s-e,r=n-i;t.ctx.rect(e,i,a,r);t.current.updateRectMinMax(getCurrentTransform(t.ctx),[e,i,s,n]);t.clip();t.endPath()}setFillAndStrokeStyleToContext(t,e,i){const s=t.ctx,n=t.current;switch(e){case vt:const t=this.ctx;s.fillStyle=t.fillStyle;s.strokeStyle=t.strokeStyle;n.fillColor=t.fillStyle;n.strokeColor=t.strokeStyle;break;case yt:const a=Util.makeHexColor(i[0],i[1],i[2]);s.fillStyle=a;s.strokeStyle=a;n.fillColor=a;n.strokeColor=a;break;default:throw new FormatError(`Unsupported paint type: ${e}`)}}getPattern(t,e,i,s){let n=i;if(s!==wt){n=Util.transform(n,e.baseTransform);this.matrix&&(n=Util.transform(n,this.matrix))}const a=this.createPatternCanvas(e);let r=new DOMMatrix(n);r=r.translate(a.offsetX,a.offsetY);r=r.scale(1/a.scaleX,1/a.scaleY);const o=t.createPattern(a.canvas,"repeat");o.setTransform(r);return o}}function convertBlackAndWhiteToRGBA({src:t,srcPos:e=0,dest:i,width:s,height:n,nonBlackColor:a=4294967295,inverseDecode:r=!1}){const o=util_FeatureTest.isLittleEndian?4278190080:255,[l,h]=r?[a,o]:[o,a],d=s>>3,c=7&s,u=t.length;i=new Uint32Array(i.buffer);let p=0;for(let s=0;s>2),m=i.length,f=s+7>>3,b=4294967295,A=util_FeatureTest.isLittleEndian?4278190080:255;for(u=0;uf?s:8*t-7,r=-8&a;let o=0,c=0;for(;n>=1}}for(;l=a){g=n;m=s*g}l=0;for(p=m;p--;){c[l++]=d[h++];c[l++]=d[h++];c[l++]=d[h++];c[l++]=255}t.putImageData(o,0,u*xt)}}}function putBinaryImageMask(t,e){if(e.bitmap){t.drawImage(e.bitmap,0,0);return}const i=e.height,s=e.width,n=i%xt,a=(i-n)/xt,r=0===n?a:a+1,o=t.createImageData(s,xt);let l=0;const h=e.data,d=o.data;for(let e=0;e10&&"function"==typeof i,h=l?Date.now()+15:0;let d=0;const c=this.commonObjs,u=this.objs;let p;for(;;){if(void 0!==s&&r===s.nextBreakPoint){s.breakIt(r,i);return r}p=a[r];if(p!==X.dependency)this[p].apply(this,n[r]);else for(const t of n[r]){const e=t.startsWith("g_")?c:u;if(!e.has(t)){e.get(t,i);return r}}r++;if(r===o)return r;if(l&&++d>10){if(Date.now()>h){i();return r}d=0}}}#hi(){for(;this.stateStack.length||this.inSMaskMode;)this.restore();this.current.activeSMask=null;this.ctx.restore();if(this.transparentCanvas){this.ctx=this.compositeCtx;this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.drawImage(this.transparentCanvas,0,0);this.ctx.restore();this.transparentCanvas=null}}endDrawing(){this.#hi();this.cachedCanvases.clear();this.cachedPatterns.clear();for(const t of this._cachedBitmapsMap.values()){for(const e of t.values())"undefined"!=typeof HTMLCanvasElement&&e instanceof HTMLCanvasElement&&(e.width=e.height=0);t.clear()}this._cachedBitmapsMap.clear();this.#di()}#di(){if(this.pageColors){const t=this.filterFactory.addHCMFilter(this.pageColors.foreground,this.pageColors.background);if("none"!==t){const e=this.ctx.filter;this.ctx.filter=t;this.ctx.drawImage(this.ctx.canvas,0,0);this.ctx.filter=e}}}_scaleImage(t,e){const i=t.width??t.displayWidth,s=t.height??t.displayHeight;let n,a,r=Math.max(Math.hypot(e[0],e[1]),1),o=Math.max(Math.hypot(e[2],e[3]),1),l=i,h=s,d="prescale1";for(;r>2&&l>1||o>2&&h>1;){let e=l,i=h;if(r>2&&l>1){e=l>=16384?Math.floor(l/2)-1||1:Math.ceil(l/2);r/=l/e}if(o>2&&h>1){i=h>=16384?Math.floor(h/2)-1||1:Math.ceil(h)/2;o/=h/i}n=this.cachedCanvases.getCanvas(d,e,i);a=n.context;a.clearRect(0,0,e,i);a.drawImage(t,0,0,l,h,0,0,e,i);t=n.canvas;l=e;h=i;d="prescale1"===d?"prescale2":"prescale1"}return{img:t,paintWidth:l,paintHeight:h}}_createMaskCanvas(t){const e=this.ctx,{width:i,height:s}=t,n=this.current.fillColor,a=this.current.patternFill,r=getCurrentTransform(e);let o,l,h,d;if((t.bitmap||t.data)&&t.count>1){const e=t.bitmap||t.data.buffer;l=JSON.stringify(a?r:[r.slice(0,4),n]);o=this._cachedBitmapsMap.get(e);if(!o){o=new Map;this._cachedBitmapsMap.set(e,o)}const i=o.get(l);if(i&&!a){return{canvas:i,offsetX:Math.round(Math.min(r[0],r[2])+r[4]),offsetY:Math.round(Math.min(r[1],r[3])+r[5])}}h=i}if(!h){d=this.cachedCanvases.getCanvas("maskCanvas",i,s);putBinaryImageMask(d.context,t)}let c=Util.transform(r,[1/i,0,0,-1/s,0,0]);c=Util.transform(c,[1,0,0,1,0,-s]);const[u,p,g,m]=Util.getAxialAlignedBoundingBox([0,0,i,s],c),f=Math.round(g-u)||1,b=Math.round(m-p)||1,A=this.cachedCanvases.getCanvas("fillCanvas",f,b),w=A.context,v=u,y=p;w.translate(-v,-y);w.transform(...c);if(!h){h=this._scaleImage(d.canvas,getCurrentTransformInverse(w));h=h.img;o&&a&&o.set(l,h)}w.imageSmoothingEnabled=getImageSmoothingEnabled(getCurrentTransform(w),t.interpolate);drawImageAtIntegerCoords(w,h,0,0,h.width,h.height,0,0,i,s);w.globalCompositeOperation="source-in";const x=Util.transform(getCurrentTransformInverse(w),[1,0,0,1,-v,-y]);w.fillStyle=a?n.getPattern(e,this,x,bt):n;w.fillRect(0,0,i,s);if(o&&!a){this.cachedCanvases.delete("fillCanvas");o.set(l,A.canvas)}return{canvas:A.canvas,offsetX:Math.round(v),offsetY:Math.round(y)}}setLineWidth(t){t!==this.current.lineWidth&&(this._cachedScaleForStroking[0]=-1);this.current.lineWidth=t;this.ctx.lineWidth=t}setLineCap(t){this.ctx.lineCap=_t[t]}setLineJoin(t){this.ctx.lineJoin=Et[t]}setMiterLimit(t){this.ctx.miterLimit=t}setDash(t,e){const i=this.ctx;if(void 0!==i.setLineDash){i.setLineDash(t);i.lineDashOffset=e}}setRenderingIntent(t){}setFlatness(t){}setGState(t){for(const[e,i]of t)switch(e){case"LW":this.setLineWidth(i);break;case"LC":this.setLineCap(i);break;case"LJ":this.setLineJoin(i);break;case"ML":this.setMiterLimit(i);break;case"D":this.setDash(i[0],i[1]);break;case"RI":this.setRenderingIntent(i);break;case"FL":this.setFlatness(i);break;case"Font":this.setFont(i[0],i[1]);break;case"CA":this.current.strokeAlpha=i;break;case"ca":this.current.fillAlpha=i;this.ctx.globalAlpha=i;break;case"BM":this.ctx.globalCompositeOperation=i;break;case"SMask":this.current.activeSMask=i?this.tempSMask:null;this.tempSMask=null;this.checkSMaskState();break;case"TR":this.ctx.filter=this.current.transferMaps=this.filterFactory.addFilter(i)}}get inSMaskMode(){return!!this.suspendedCtx}checkSMaskState(){const t=this.inSMaskMode;this.current.activeSMask&&!t?this.beginSMaskMode():!this.current.activeSMask&&t&&this.endSMaskMode()}beginSMaskMode(){if(this.inSMaskMode)throw new Error("beginSMaskMode called while already in smask mode");const t=this.ctx.canvas.width,e=this.ctx.canvas.height,i="smaskGroupAt"+this.groupLevel,s=this.cachedCanvases.getCanvas(i,t,e);this.suspendedCtx=this.ctx;this.ctx=s.context;const n=this.ctx;n.setTransform(...getCurrentTransform(this.suspendedCtx));copyCtxState(this.suspendedCtx,n);!function mirrorContextOperations(t,e){if(t._removeMirroring)throw new Error("Context is already forwarding operations.");t.__originalSave=t.save;t.__originalRestore=t.restore;t.__originalRotate=t.rotate;t.__originalScale=t.scale;t.__originalTranslate=t.translate;t.__originalTransform=t.transform;t.__originalSetTransform=t.setTransform;t.__originalResetTransform=t.resetTransform;t.__originalClip=t.clip;t.__originalMoveTo=t.moveTo;t.__originalLineTo=t.lineTo;t.__originalBezierCurveTo=t.bezierCurveTo;t.__originalRect=t.rect;t.__originalClosePath=t.closePath;t.__originalBeginPath=t.beginPath;t._removeMirroring=()=>{t.save=t.__originalSave;t.restore=t.__originalRestore;t.rotate=t.__originalRotate;t.scale=t.__originalScale;t.translate=t.__originalTranslate;t.transform=t.__originalTransform;t.setTransform=t.__originalSetTransform;t.resetTransform=t.__originalResetTransform;t.clip=t.__originalClip;t.moveTo=t.__originalMoveTo;t.lineTo=t.__originalLineTo;t.bezierCurveTo=t.__originalBezierCurveTo;t.rect=t.__originalRect;t.closePath=t.__originalClosePath;t.beginPath=t.__originalBeginPath;delete t._removeMirroring};t.save=function ctxSave(){e.save();this.__originalSave()};t.restore=function ctxRestore(){e.restore();this.__originalRestore()};t.translate=function ctxTranslate(t,i){e.translate(t,i);this.__originalTranslate(t,i)};t.scale=function ctxScale(t,i){e.scale(t,i);this.__originalScale(t,i)};t.transform=function ctxTransform(t,i,s,n,a,r){e.transform(t,i,s,n,a,r);this.__originalTransform(t,i,s,n,a,r)};t.setTransform=function ctxSetTransform(t,i,s,n,a,r){e.setTransform(t,i,s,n,a,r);this.__originalSetTransform(t,i,s,n,a,r)};t.resetTransform=function ctxResetTransform(){e.resetTransform();this.__originalResetTransform()};t.rotate=function ctxRotate(t){e.rotate(t);this.__originalRotate(t)};t.clip=function ctxRotate(t){e.clip(t);this.__originalClip(t)};t.moveTo=function(t,i){e.moveTo(t,i);this.__originalMoveTo(t,i)};t.lineTo=function(t,i){e.lineTo(t,i);this.__originalLineTo(t,i)};t.bezierCurveTo=function(t,i,s,n,a,r){e.bezierCurveTo(t,i,s,n,a,r);this.__originalBezierCurveTo(t,i,s,n,a,r)};t.rect=function(t,i,s,n){e.rect(t,i,s,n);this.__originalRect(t,i,s,n)};t.closePath=function(){e.closePath();this.__originalClosePath()};t.beginPath=function(){e.beginPath();this.__originalBeginPath()}}(n,this.suspendedCtx);this.setGState([["BM","source-over"],["ca",1],["CA",1]])}endSMaskMode(){if(!this.inSMaskMode)throw new Error("endSMaskMode called while not in smask mode");this.ctx._removeMirroring();copyCtxState(this.ctx,this.suspendedCtx);this.ctx=this.suspendedCtx;this.suspendedCtx=null}compose(t){if(!this.current.activeSMask)return;if(t){t[0]=Math.floor(t[0]);t[1]=Math.floor(t[1]);t[2]=Math.ceil(t[2]);t[3]=Math.ceil(t[3])}else t=[0,0,this.ctx.canvas.width,this.ctx.canvas.height];const e=this.current.activeSMask,i=this.suspendedCtx;this.composeSMask(i,e,this.ctx,t);this.ctx.save();this.ctx.setTransform(1,0,0,1,0,0);this.ctx.clearRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height);this.ctx.restore()}composeSMask(t,e,i,s){const n=s[0],a=s[1],r=s[2]-n,o=s[3]-a;if(0!==r&&0!==o){this.genericComposeSMask(e.context,i,r,o,e.subtype,e.backdrop,e.transferMap,n,a,e.offsetX,e.offsetY);t.save();t.globalAlpha=1;t.globalCompositeOperation="source-over";t.setTransform(1,0,0,1,0,0);t.drawImage(i.canvas,0,0);t.restore()}}genericComposeSMask(t,e,i,s,n,a,r,o,l,h,d){let c=t.canvas,u=o-h,p=l-d;if(a){const e=Util.makeHexColor(...a);if(u<0||p<0||u+i>c.width||p+s>c.height){const t=this.cachedCanvases.getCanvas("maskExtension",i,s),n=t.context;n.drawImage(c,-u,-p);n.globalCompositeOperation="destination-atop";n.fillStyle=e;n.fillRect(0,0,i,s);n.globalCompositeOperation="source-over";c=t.canvas;u=p=0}else{t.save();t.globalAlpha=1;t.setTransform(1,0,0,1,0,0);const n=new Path2D;n.rect(u,p,i,s);t.clip(n);t.globalCompositeOperation="destination-atop";t.fillStyle=e;t.fillRect(u,p,i,s);t.restore()}}e.save();e.globalAlpha=1;e.setTransform(1,0,0,1,0,0);"Alpha"===n&&r?e.filter=this.filterFactory.addAlphaFilter(r):"Luminosity"===n&&(e.filter=this.filterFactory.addLuminosityFilter(r));const g=new Path2D;g.rect(o,l,i,s);e.clip(g);e.globalCompositeOperation="destination-in";e.drawImage(c,u,p,i,s,o,l,i,s);e.restore()}save(){if(this.inSMaskMode){copyCtxState(this.ctx,this.suspendedCtx);this.suspendedCtx.save()}else this.ctx.save();const t=this.current;this.stateStack.push(t);this.current=t.clone()}restore(){0===this.stateStack.length&&this.inSMaskMode&&this.endSMaskMode();if(0!==this.stateStack.length){this.current=this.stateStack.pop();if(this.inSMaskMode){this.suspendedCtx.restore();copyCtxState(this.suspendedCtx,this.ctx)}else this.ctx.restore();this.checkSMaskState();this.pendingClip=null;this._cachedScaleForStroking[0]=-1;this._cachedGetSinglePixelWidth=null}}transform(t,e,i,s,n,a){this.ctx.transform(t,e,i,s,n,a);this._cachedScaleForStroking[0]=-1;this._cachedGetSinglePixelWidth=null}constructPath(t,e,i){const s=this.ctx,n=this.current;let a,r,o=n.x,l=n.y;const h=getCurrentTransform(s),d=0===h[0]&&0===h[3]||0===h[1]&&0===h[2],c=d?i.slice(0):null;for(let i=0,u=0,p=t.length;i100&&(h=100);this.current.fontSizeScale=e/h;this.ctx.font=`${l} ${o} ${h}px ${r}`}setTextRenderingMode(t){this.current.textRenderingMode=t}setTextRise(t){this.current.textRise=t}moveText(t,e){this.current.x=this.current.lineX+=t;this.current.y=this.current.lineY+=e}setLeadingMoveText(t,e){this.setLeading(-e);this.moveText(t,e)}setTextMatrix(t,e,i,s,n,a){this.current.textMatrix=[t,e,i,s,n,a];this.current.textMatrixScale=Math.hypot(t,e);this.current.x=this.current.lineX=0;this.current.y=this.current.lineY=0}nextLine(){this.moveText(0,this.current.leading)}#ci(t,e,i){const s=new Path2D;s.addPath(t,new DOMMatrix(i).invertSelf().multiplySelf(e));return s}paintChar(t,e,i,s,n){const a=this.ctx,r=this.current,o=r.font,l=r.textRenderingMode,h=r.fontSize/r.fontSizeScale,d=l&y,c=!!(l&x),u=r.patternFill&&!o.missingFile,p=r.patternStroke&&!o.missingFile;let g;(o.disableFontFace||c||u||p)&&(g=o.getPathGenerator(this.commonObjs,t));if(o.disableFontFace||u||p){a.save();a.translate(e,i);a.scale(h,-h);if(d===b||d===w)if(s){const t=a.getTransform();a.setTransform(...s);a.fill(this.#ci(g,t,s))}else a.fill(g);if(d===A||d===w)if(n){const t=a.getTransform();a.setTransform(...n);a.stroke(this.#ci(g,t,n))}else{a.lineWidth/=h;a.stroke(g)}a.restore()}else{d!==b&&d!==w||a.fillText(t,e,i);d!==A&&d!==w||a.strokeText(t,e,i)}if(c){(this.pendingTextPaths||=[]).push({transform:getCurrentTransform(a),x:e,y:i,fontSize:h,path:g})}}get isFontSubpixelAAEnabled(){const{context:t}=this.cachedCanvases.getCanvas("isFontSubpixelAAEnabled",10,10);t.scale(1.5,1);t.fillText("I",0,10);const e=t.getImageData(0,0,10,10).data;let i=!1;for(let t=3;t0&&e[t]<255){i=!0;break}return shadow(this,"isFontSubpixelAAEnabled",i)}showText(t){const e=this.current,i=e.font;if(i.isType3Font)return this.showType3Text(t);const s=e.fontSize;if(0===s)return;const n=this.ctx,a=e.fontSizeScale,r=e.charSpacing,o=e.wordSpacing,l=e.fontDirection,h=e.textHScale*l,d=t.length,c=i.vertical,u=c?1:-1,p=i.defaultVMetrics,g=s*e.fontMatrix[0],m=e.textRenderingMode===b&&!i.disableFontFace&&!e.patternFill;n.save();n.transform(...e.textMatrix);n.translate(e.x,e.y+e.textRise);l>0?n.scale(h,-1):n.scale(h,1);let f,v;if(e.patternFill){n.save();const t=e.fillColor.getPattern(n,this,getCurrentTransformInverse(n),bt);f=getCurrentTransform(n);n.restore();n.fillStyle=t}if(e.patternStroke){n.save();const t=e.strokeColor.getPattern(n,this,getCurrentTransformInverse(n),At);v=getCurrentTransform(n);n.restore();n.strokeStyle=t}let x=e.lineWidth;const _=e.textMatrixScale;if(0===_||0===x){const t=e.textRenderingMode&y;t!==A&&t!==w||(x=this.getSinglePixelWidth())}else x/=_;if(1!==a){n.scale(a,a);x/=a}n.lineWidth=x;if(i.isInvalidPDFjsFont){const i=[];let s=0;for(const e of t){i.push(e.unicode);s+=e.width}n.fillText(i.join(""),0,0);e.x+=s*g*h;n.restore();this.compose();return}let E,S=0;for(E=0;E0){const t=1e3*n.measureText(b).width/s*a;if(xnew CanvasGraphics(t,this.commonObjs,this.objs,this.canvasFactory,this.filterFactory,{optionalContentConfig:this.optionalContentConfig,markedContentStack:this.markedContentStack})};e=new TilingPattern(t,i,this.ctx,n,s)}else e=this._getPattern(t[1],t[2]);return e}setStrokeColorN(){this.current.strokeColor=this.getColorN_Pattern(arguments);this.current.patternStroke=!0}setFillColorN(){this.current.fillColor=this.getColorN_Pattern(arguments);this.current.patternFill=!0}setStrokeRGBColor(t,e,i){this.ctx.strokeStyle=this.current.strokeColor=Util.makeHexColor(t,e,i);this.current.patternStroke=!1}setStrokeTransparent(){this.ctx.strokeStyle=this.current.strokeColor="transparent";this.current.patternStroke=!1}setFillRGBColor(t,e,i){this.ctx.fillStyle=this.current.fillColor=Util.makeHexColor(t,e,i);this.current.patternFill=!1}setFillTransparent(){this.ctx.fillStyle=this.current.fillColor="transparent";this.current.patternFill=!1}_getPattern(t,e=null){let i;if(this.cachedPatterns.has(t))i=this.cachedPatterns.get(t);else{i=function getShadingPattern(t){switch(t[0]){case"RadialAxial":return new RadialAxialShadingPattern(t);case"Mesh":return new MeshShadingPattern(t);case"Dummy":return new DummyShadingPattern}throw new Error(`Unknown IR type: ${t[0]}`)}(this.getObject(t));this.cachedPatterns.set(t,i)}e&&(i.matrix=e);return i}shadingFill(t){if(!this.contentVisible)return;const e=this.ctx;this.save();const i=this._getPattern(t);e.fillStyle=i.getPattern(e,this,getCurrentTransformInverse(e),wt);const s=getCurrentTransformInverse(e);if(s){const{width:t,height:i}=e.canvas,[n,a,r,o]=Util.getAxialAlignedBoundingBox([0,0,t,i],s);this.ctx.fillRect(n,a,r-n,o-a)}else this.ctx.fillRect(-1e10,-1e10,2e10,2e10);this.compose(this.current.getClippedPathBoundingBox());this.restore()}beginInlineImage(){unreachable("Should not call beginInlineImage")}beginImageData(){unreachable("Should not call beginImageData")}paintFormXObjectBegin(t,e){if(this.contentVisible){this.save();this.baseTransformStack.push(this.baseTransform);t&&this.transform(...t);this.baseTransform=getCurrentTransform(this.ctx);if(e){const t=e[2]-e[0],i=e[3]-e[1];this.ctx.rect(e[0],e[1],t,i);this.current.updateRectMinMax(getCurrentTransform(this.ctx),e);this.clip();this.endPath()}}}paintFormXObjectEnd(){if(this.contentVisible){this.restore();this.baseTransform=this.baseTransformStack.pop()}}beginGroup(t){if(!this.contentVisible)return;this.save();if(this.inSMaskMode){this.endSMaskMode();this.current.activeSMask=null}const e=this.ctx;t.isolated||info("TODO: Support non-isolated groups.");t.knockout&&warn("Knockout groups not supported.");const i=getCurrentTransform(e);t.matrix&&e.transform(...t.matrix);if(!t.bbox)throw new Error("Bounding box is required.");let s=Util.getAxialAlignedBoundingBox(t.bbox,getCurrentTransform(e));const n=[0,0,e.canvas.width,e.canvas.height];s=Util.intersect(s,n)||[0,0,0,0];const a=Math.floor(s[0]),r=Math.floor(s[1]),o=Math.max(Math.ceil(s[2])-a,1),l=Math.max(Math.ceil(s[3])-r,1);this.current.startNewPathAndClipBox([0,0,o,l]);let h="groupAt"+this.groupLevel;t.smask&&(h+="_smask_"+this.smaskCounter++%2);const d=this.cachedCanvases.getCanvas(h,o,l),c=d.context;c.translate(-a,-r);c.transform(...i);if(t.smask)this.smaskStack.push({canvas:d.canvas,context:c,offsetX:a,offsetY:r,subtype:t.smask.subtype,backdrop:t.smask.backdrop,transferMap:t.smask.transferMap||null,startTransformInverse:null});else{e.setTransform(1,0,0,1,0,0);e.translate(a,r);e.save()}copyCtxState(e,c);this.ctx=c;this.setGState([["BM","source-over"],["ca",1],["CA",1]]);this.groupStack.push(e);this.groupLevel++}endGroup(t){if(!this.contentVisible)return;this.groupLevel--;const e=this.ctx,i=this.groupStack.pop();this.ctx=i;this.ctx.imageSmoothingEnabled=!1;if(t.smask){this.tempSMask=this.smaskStack.pop();this.restore()}else{this.ctx.restore();const t=getCurrentTransform(this.ctx);this.restore();this.ctx.save();this.ctx.setTransform(...t);const i=Util.getAxialAlignedBoundingBox([0,0,e.canvas.width,e.canvas.height],t);this.ctx.drawImage(e.canvas,0,0);this.ctx.restore();this.compose(i)}}beginAnnotation(t,e,i,s,n){this.#hi();resetCtxToDefault(this.ctx);this.ctx.save();this.save();this.baseTransform&&this.ctx.setTransform(...this.baseTransform);if(e){const s=e[2]-e[0],a=e[3]-e[1];if(n&&this.annotationCanvasMap){(i=i.slice())[4]-=e[0];i[5]-=e[1];(e=e.slice())[0]=e[1]=0;e[2]=s;e[3]=a;const[n,r]=Util.singularValueDecompose2dScale(getCurrentTransform(this.ctx)),{viewportScale:o}=this,l=Math.ceil(s*this.outputScaleX*o),h=Math.ceil(a*this.outputScaleY*o);this.annotationCanvas=this.canvasFactory.create(l,h);const{canvas:d,context:c}=this.annotationCanvas;this.annotationCanvasMap.set(t,d);this.annotationCanvas.savedCtx=this.ctx;this.ctx=c;this.ctx.save();this.ctx.setTransform(n,0,0,-r,0,a*r);resetCtxToDefault(this.ctx)}else{resetCtxToDefault(this.ctx);this.endPath();this.ctx.rect(e[0],e[1],s,a);this.ctx.clip();this.ctx.beginPath()}}this.current=new CanvasExtraState(this.ctx.canvas.width,this.ctx.canvas.height);this.transform(...i);this.transform(...s)}endAnnotation(){if(this.annotationCanvas){this.ctx.restore();this.#di();this.ctx=this.annotationCanvas.savedCtx;delete this.annotationCanvas.savedCtx;delete this.annotationCanvas}}paintImageMaskXObject(t){if(!this.contentVisible)return;const e=t.count;(t=this.getObject(t.data,t)).count=e;const i=this.ctx,s=this.processingType3;if(s){void 0===s.compiled&&(s.compiled=function compileType3Glyph(t){const{width:e,height:i}=t;if(e>1e3||i>1e3)return null;const s=new Uint8Array([0,2,4,0,1,0,5,4,8,10,0,8,0,2,1,0]),n=e+1;let a,r,o,l=new Uint8Array(n*(i+1));const h=e+7&-8;let d=new Uint8Array(h*i),c=0;for(const e of t.data){let t=128;for(;t>0;){d[c++]=e&t?0:255;t>>=1}}let u=0;c=0;if(0!==d[c]){l[0]=1;++u}for(r=1;r>2)+(d[c+1]?4:0)+(d[c-h+1]?8:0);if(s[t]){l[o+r]=s[t];++u}c++}if(d[c-h]!==d[c]){l[o+r]=d[c]?2:4;++u}if(u>1e3)return null}c=h*(i-1);o=a*n;if(0!==d[c]){l[o]=8;++u}for(r=1;r1e3)return null;const p=new Int32Array([0,n,-1,0,-n,0,0,0,1]),g=new Path2D;for(a=0;u&&a<=i;a++){let t=a*n;const i=t+e;for(;t>4;l[t]&=r>>2|r<<2}g.lineTo(t%n,t/n|0);l[t]||--u}while(s!==t);--a}d=null;l=null;return function(t){t.save();t.scale(1/e,-1/i);t.translate(0,-i);t.fill(g);t.beginPath();t.restore()}}(t));if(s.compiled){s.compiled(i);return}}const n=this._createMaskCanvas(t),a=n.canvas;i.save();i.setTransform(1,0,0,1,0,0);i.drawImage(a,n.offsetX,n.offsetY);i.restore();this.compose()}paintImageMaskXObjectRepeat(t,e,i=0,s=0,n,a){if(!this.contentVisible)return;t=this.getObject(t.data,t);const r=this.ctx;r.save();const o=getCurrentTransform(r);r.transform(e,i,s,n,0,0);const l=this._createMaskCanvas(t);r.setTransform(1,0,0,1,l.offsetX-o[4],l.offsetY-o[5]);for(let t=0,h=a.length;te?h/e:1;r=l>e?l/e:1}}this._cachedScaleForStroking[0]=a;this._cachedScaleForStroking[1]=r}return this._cachedScaleForStroking}rescaleAndStroke(t){const{ctx:e}=this,{lineWidth:i}=this.current,[s,n]=this.getScaleForStroking();e.lineWidth=i||1;if(1===s&&1===n){e.stroke();return}const a=e.getLineDash();t&&e.save();e.scale(s,n);if(a.length>0){const t=Math.max(s,n);e.setLineDash(a.map((e=>e/t)));e.lineDashOffset/=t}e.stroke();t&&e.restore()}isContentVisible(){for(let t=this.markedContentStack.length-1;t>=0;t--)if(!this.markedContentStack[t].visible)return!1;return!0}}for(const t in X)void 0!==CanvasGraphics.prototype[t]&&(CanvasGraphics.prototype[X[t]]=CanvasGraphics.prototype[t]);class GlobalWorkerOptions{static#ui=null;static#pi="";static get workerPort(){return this.#ui}static set workerPort(t){if(!("undefined"!=typeof Worker&&t instanceof Worker)&&null!==t)throw new Error("Invalid `workerPort` type.");this.#ui=t}static get workerSrc(){return this.#pi}static set workerSrc(t){if("string"!=typeof t)throw new Error("Invalid `workerSrc` type.");this.#pi=t}}class Metadata{#gi;#mi;constructor({parsedData:t,rawData:e}){this.#gi=t;this.#mi=e}getRaw(){return this.#mi}get(t){return this.#gi.get(t)??null}getAll(){return objectFromMap(this.#gi)}has(t){return this.#gi.has(t)}}const Tt=Symbol("INTERNAL");class OptionalContentGroup{#fi=!1;#bi=!1;#Ai=!1;#wi=!0;constructor(t,{name:e,intent:i,usage:s,rbGroups:n}){this.#fi=!!(t&r);this.#bi=!!(t&o);this.name=e;this.intent=i;this.usage=s;this.rbGroups=n}get visible(){if(this.#Ai)return this.#wi;if(!this.#wi)return!1;const{print:t,view:e}=this.usage;return this.#fi?"OFF"!==e?.viewState:!this.#bi||"OFF"!==t?.printState}_setVisible(t,e,i=!1){t!==Tt&&unreachable("Internal method `_setVisible` called.");this.#Ai=i;this.#wi=e}}class OptionalContentConfig{#vi=null;#yi=new Map;#xi=null;#_i=null;constructor(t,e=r){this.renderingIntent=e;this.name=null;this.creator=null;if(null!==t){this.name=t.name;this.creator=t.creator;this.#_i=t.order;for(const i of t.groups)this.#yi.set(i.id,new OptionalContentGroup(e,i));if("OFF"===t.baseState)for(const t of this.#yi.values())t._setVisible(Tt,!1);for(const e of t.on)this.#yi.get(e)._setVisible(Tt,!0);for(const e of t.off)this.#yi.get(e)._setVisible(Tt,!1);this.#xi=this.getHash()}}#Ei(t){const e=t.length;if(e<2)return!0;const i=t[0];for(let s=1;s0?objectFromMap(this.#yi):null}getGroup(t){return this.#yi.get(t)||null}getHash(){if(null!==this.#vi)return this.#vi;const t=new MurmurHash3_64;for(const[e,i]of this.#yi)t.update(`${e}:${i.visible}`);return this.#vi=t.hexdigest()}}class PDFDataTransportStream{constructor(t,{disableRange:e=!1,disableStream:i=!1}){assert(t,'PDFDataTransportStream - missing required "pdfDataRangeTransport" argument.');const{length:s,initialData:n,progressiveDone:a,contentDispositionFilename:r}=t;this._queuedChunks=[];this._progressiveDone=a;this._contentDispositionFilename=r;if(n?.length>0){const t=n instanceof Uint8Array&&n.byteLength===n.buffer.byteLength?n.buffer:new Uint8Array(n).buffer;this._queuedChunks.push(t)}this._pdfDataRangeTransport=t;this._isStreamingSupported=!i;this._isRangeSupported=!e;this._contentLength=s;this._fullRequestReader=null;this._rangeReaders=[];t.addRangeListener(((t,e)=>{this._onReceiveData({begin:t,chunk:e})}));t.addProgressListener(((t,e)=>{this._onProgress({loaded:t,total:e})}));t.addProgressiveReadListener((t=>{this._onReceiveData({chunk:t})}));t.addProgressiveDoneListener((()=>{this._onProgressiveDone()}));t.transportReady()}_onReceiveData({begin:t,chunk:e}){const i=e instanceof Uint8Array&&e.byteLength===e.buffer.byteLength?e.buffer:new Uint8Array(e).buffer;if(void 0===t)this._fullRequestReader?this._fullRequestReader._enqueue(i):this._queuedChunks.push(i);else{assert(this._rangeReaders.some((function(e){if(e._begin!==t)return!1;e._enqueue(i);return!0})),"_onReceiveData - no `PDFDataTransportStreamRangeReader` instance found.")}}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}_onProgress(t){void 0===t.total?this._rangeReaders[0]?.onProgress?.({loaded:t.loaded}):this._fullRequestReader?.onProgress?.({loaded:t.loaded,total:t.total})}_onProgressiveDone(){this._fullRequestReader?.progressiveDone();this._progressiveDone=!0}_removeRangeReader(t){const e=this._rangeReaders.indexOf(t);e>=0&&this._rangeReaders.splice(e,1)}getFullReader(){assert(!this._fullRequestReader,"PDFDataTransportStream.getFullReader can only be called once.");const t=this._queuedChunks;this._queuedChunks=null;return new PDFDataTransportStreamReader(this,t,this._progressiveDone,this._contentDispositionFilename)}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const i=new PDFDataTransportStreamRangeReader(this,t,e);this._pdfDataRangeTransport.requestDataRange(t,e);this._rangeReaders.push(i);return i}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeReaders.slice(0))e.cancel(t);this._pdfDataRangeTransport.abort()}}class PDFDataTransportStreamReader{constructor(t,e,i=!1,s=null){this._stream=t;this._done=i||!1;this._filename=isPdfFile(s)?s:null;this._queuedChunks=e||[];this._loaded=0;for(const t of this._queuedChunks)this._loaded+=t.byteLength;this._requests=[];this._headersReady=Promise.resolve();t._fullRequestReader=this;this.onProgress=null}_enqueue(t){if(!this._done){if(this._requests.length>0){this._requests.shift().resolve({value:t,done:!1})}else this._queuedChunks.push(t);this._loaded+=t.byteLength}}get headersReady(){return this._headersReady}get filename(){return this._filename}get isRangeSupported(){return this._stream._isRangeSupported}get isStreamingSupported(){return this._stream._isStreamingSupported}get contentLength(){return this._stream._contentLength}async read(){if(this._queuedChunks.length>0){return{value:this._queuedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}progressiveDone(){this._done||(this._done=!0)}}class PDFDataTransportStreamRangeReader{constructor(t,e,i){this._stream=t;this._begin=e;this._end=i;this._queuedChunk=null;this._requests=[];this._done=!1;this.onProgress=null}_enqueue(t){if(!this._done){if(0===this._requests.length)this._queuedChunk=t;else{this._requests.shift().resolve({value:t,done:!1});for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}this._done=!0;this._stream._removeRangeReader(this)}}get isStreamingSupported(){return!1}async read(){if(this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._stream._removeRangeReader(this)}}function createHeaders(t,e){const i=new Headers;if(!t||!e||"object"!=typeof e)return i;for(const t in e){const s=e[t];void 0!==s&&i.append(t,s)}return i}function getResponseOrigin(t){try{return new URL(t).origin}catch{}return null}function validateRangeRequestCapabilities({responseHeaders:t,isHttp:e,rangeChunkSize:i,disableRange:s}){const n={allowRangeRequests:!1,suggestedLength:void 0},a=parseInt(t.get("Content-Length"),10);if(!Number.isInteger(a))return n;n.suggestedLength=a;if(a<=2*i)return n;if(s||!e)return n;if("bytes"!==t.get("Accept-Ranges"))return n;if("identity"!==(t.get("Content-Encoding")||"identity"))return n;n.allowRangeRequests=!0;return n}function extractFilenameFromHeader(t){const e=t.get("Content-Disposition");if(e){let t=function getFilenameFromContentDispositionHeader(t){let e=!0,i=toParamRegExp("filename\\*","i").exec(t);if(i){i=i[1];let t=rfc2616unquote(i);t=unescape(t);t=rfc5987decode(t);t=rfc2047decode(t);return fixupEncoding(t)}i=function rfc2231getparam(t){const e=[];let i;const s=toParamRegExp("filename\\*((?!0\\d)\\d+)(\\*?)","ig");for(;null!==(i=s.exec(t));){let[,t,s,n]=i;t=parseInt(t,10);if(t in e){if(0===t)break}else e[t]=[s,n]}const n=[];for(let t=0;t{t._responseOrigin=getResponseOrigin(e.url);if(!validateResponseStatus(e.status))throw createResponseStatusError(e.status,s);this._reader=e.body.getReader();this._headersCapability.resolve();const i=e.headers,{allowRangeRequests:n,suggestedLength:a}=validateRangeRequestCapabilities({responseHeaders:i,isHttp:t.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});this._isRangeSupported=n;this._contentLength=a||this._contentLength;this._filename=extractFilenameFromHeader(i);!this._isStreamingSupported&&this._isRangeSupported&&this.cancel(new AbortException("Streaming is disabled."))})).catch(this._headersCapability.reject);this.onProgress=null}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._headersCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:getArrayBuffer(t),done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}class PDFFetchStreamRangeReader{constructor(t,e,i){this._stream=t;this._reader=null;this._loaded=0;const s=t.source;this._withCredentials=s.withCredentials||!1;this._readCapability=Promise.withResolvers();this._isStreamingSupported=!s.disableStream;this._abortController=new AbortController;const n=new Headers(t.headers);n.append("Range",`bytes=${e}-${i-1}`);const a=s.url;fetch(a,createFetchOptions(n,this._withCredentials,this._abortController)).then((e=>{const i=getResponseOrigin(e.url);if(i!==t._responseOrigin)throw new Error(`Expected range response-origin "${i}" to match "${t._responseOrigin}".`);if(!validateResponseStatus(e.status))throw createResponseStatusError(e.status,a);this._readCapability.resolve();this._reader=e.body.getReader()})).catch(this._readCapability.reject);this.onProgress=null}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;const{value:t,done:e}=await this._reader.read();if(e)return{value:t,done:e};this._loaded+=t.byteLength;this.onProgress?.({loaded:this._loaded});return{value:getArrayBuffer(t),done:!1}}cancel(t){this._reader?.cancel(t);this._abortController.abort()}}class NetworkManager{_responseOrigin=null;constructor({url:t,httpHeaders:e,withCredentials:i}){this.url=t;this.isHttp=/^https?:/i.test(t);this.headers=createHeaders(this.isHttp,e);this.withCredentials=i||!1;this.currXhrId=0;this.pendingRequests=Object.create(null)}request(t){const e=new XMLHttpRequest,i=this.currXhrId++,s=this.pendingRequests[i]={xhr:e};e.open("GET",this.url);e.withCredentials=this.withCredentials;for(const[t,i]of this.headers)e.setRequestHeader(t,i);if(this.isHttp&&"begin"in t&&"end"in t){e.setRequestHeader("Range",`bytes=${t.begin}-${t.end-1}`);s.expectedStatus=206}else s.expectedStatus=200;e.responseType="arraybuffer";assert(t.onError,"Expected `onError` callback to be provided.");e.onerror=()=>{t.onError(e.status)};e.onreadystatechange=this.onStateChange.bind(this,i);e.onprogress=this.onProgress.bind(this,i);s.onHeadersReceived=t.onHeadersReceived;s.onDone=t.onDone;s.onError=t.onError;s.onProgress=t.onProgress;e.send(null);return i}onProgress(t,e){const i=this.pendingRequests[t];i&&i.onProgress?.(e)}onStateChange(t,e){const i=this.pendingRequests[t];if(!i)return;const s=i.xhr;if(s.readyState>=2&&i.onHeadersReceived){i.onHeadersReceived();delete i.onHeadersReceived}if(4!==s.readyState)return;if(!(t in this.pendingRequests))return;delete this.pendingRequests[t];if(0===s.status&&this.isHttp){i.onError(s.status);return}const n=s.status||200;if(!(200===n&&206===i.expectedStatus)&&n!==i.expectedStatus){i.onError(s.status);return}const a=function network_getArrayBuffer(t){const e=t.response;return"string"!=typeof e?e:stringToBytes(e).buffer}(s);if(206===n){const t=s.getResponseHeader("Content-Range"),e=/bytes (\d+)-(\d+)\/(\d+)/.exec(t);if(e)i.onDone({begin:parseInt(e[1],10),chunk:a});else{warn('Missing or invalid "Content-Range" header.');i.onError(0)}}else a?i.onDone({begin:0,chunk:a}):i.onError(s.status)}getRequestXhr(t){return this.pendingRequests[t].xhr}isPendingRequest(t){return t in this.pendingRequests}abortRequest(t){const e=this.pendingRequests[t].xhr;delete this.pendingRequests[t];e.abort()}}class PDFNetworkStream{constructor(t){this._source=t;this._manager=new NetworkManager(t);this._rangeChunkSize=t.rangeChunkSize;this._fullRequestReader=null;this._rangeRequestReaders=[]}_onRangeRequestReaderClosed(t){const e=this._rangeRequestReaders.indexOf(t);e>=0&&this._rangeRequestReaders.splice(e,1)}getFullReader(){assert(!this._fullRequestReader,"PDFNetworkStream.getFullReader can only be called once.");this._fullRequestReader=new PDFNetworkStreamFullRequestReader(this._manager,this._source);return this._fullRequestReader}getRangeReader(t,e){const i=new PDFNetworkStreamRangeRequestReader(this._manager,t,e);i.onClosed=this._onRangeRequestReaderClosed.bind(this);this._rangeRequestReaders.push(i);return i}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}}class PDFNetworkStreamFullRequestReader{constructor(t,e){this._manager=t;this._url=e.url;this._fullRequestId=t.request({onHeadersReceived:this._onHeadersReceived.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)});this._headersCapability=Promise.withResolvers();this._disableRange=e.disableRange||!1;this._contentLength=e.length;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!1;this._isRangeSupported=!1;this._cachedChunks=[];this._requests=[];this._done=!1;this._storedError=void 0;this._filename=null;this.onProgress=null}_onHeadersReceived(){const t=this._fullRequestId,e=this._manager.getRequestXhr(t);this._manager._responseOrigin=getResponseOrigin(e.responseURL);const i=e.getAllResponseHeaders(),s=new Headers(i?i.trimStart().replace(/[^\S ]+$/,"").split(/[\r\n]+/).map((t=>{const[e,...i]=t.split(": ");return[e,i.join(": ")]})):[]),{allowRangeRequests:n,suggestedLength:a}=validateRangeRequestCapabilities({responseHeaders:s,isHttp:this._manager.isHttp,rangeChunkSize:this._rangeChunkSize,disableRange:this._disableRange});n&&(this._isRangeSupported=!0);this._contentLength=a||this._contentLength;this._filename=extractFilenameFromHeader(s);this._isRangeSupported&&this._manager.abortRequest(t);this._headersCapability.resolve()}_onDone(t){if(t)if(this._requests.length>0){this._requests.shift().resolve({value:t.chunk,done:!1})}else this._cachedChunks.push(t.chunk);this._done=!0;if(!(this._cachedChunks.length>0)){for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0}}_onError(t){this._storedError=createResponseStatusError(t,this._url);this._headersCapability.reject(this._storedError);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._cachedChunks.length=0}_onProgress(t){this.onProgress?.({loaded:t.loaded,total:t.lengthComputable?t.total:this._contentLength})}get filename(){return this._filename}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}get contentLength(){return this._contentLength}get headersReady(){return this._headersCapability.promise}async read(){await this._headersCapability.promise;if(this._storedError)throw this._storedError;if(this._cachedChunks.length>0){return{value:this._cachedChunks.shift(),done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;this._headersCapability.reject(t);for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._fullRequestId)&&this._manager.abortRequest(this._fullRequestId);this._fullRequestReader=null}}class PDFNetworkStreamRangeRequestReader{constructor(t,e,i){this._manager=t;this._url=t.url;this._requestId=t.request({begin:e,end:i,onHeadersReceived:this._onHeadersReceived.bind(this),onDone:this._onDone.bind(this),onError:this._onError.bind(this),onProgress:this._onProgress.bind(this)});this._requests=[];this._queuedChunk=null;this._done=!1;this._storedError=void 0;this.onProgress=null;this.onClosed=null}_onHeadersReceived(){const t=getResponseOrigin(this._manager.getRequestXhr(this._requestId)?.responseURL);if(t!==this._manager._responseOrigin){this._storedError=new Error(`Expected range response-origin "${t}" to match "${this._manager._responseOrigin}".`);this._onError(0)}}_close(){this.onClosed?.(this)}_onDone(t){const e=t.chunk;if(this._requests.length>0){this._requests.shift().resolve({value:e,done:!1})}else this._queuedChunk=e;this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._close()}_onError(t){this._storedError??=createResponseStatusError(t,this._url);for(const t of this._requests)t.reject(this._storedError);this._requests.length=0;this._queuedChunk=null}_onProgress(t){this.isStreamingSupported||this.onProgress?.({loaded:t.loaded})}get isStreamingSupported(){return!1}async read(){if(this._storedError)throw this._storedError;if(null!==this._queuedChunk){const t=this._queuedChunk;this._queuedChunk=null;return{value:t,done:!1}}if(this._done)return{value:void 0,done:!0};const t=Promise.withResolvers();this._requests.push(t);return t.promise}cancel(t){this._done=!0;for(const t of this._requests)t.resolve({value:void 0,done:!0});this._requests.length=0;this._manager.isPendingRequest(this._requestId)&&this._manager.abortRequest(this._requestId);this._close()}}const Mt=/^[a-z][a-z0-9\-+.]+:/i;class PDFNodeStream{constructor(t){this.source=t;this.url=function parseUrlOrPath(t){if(Mt.test(t))return new URL(t);const e=process.getBuiltinModule("url");return new URL(e.pathToFileURL(t))}(t.url);assert("file:"===this.url.protocol,"PDFNodeStream only supports file:// URLs.");this._fullRequestReader=null;this._rangeRequestReaders=[]}get _progressiveDataLength(){return this._fullRequestReader?._loaded??0}getFullReader(){assert(!this._fullRequestReader,"PDFNodeStream.getFullReader can only be called once.");this._fullRequestReader=new PDFNodeStreamFsFullReader(this);return this._fullRequestReader}getRangeReader(t,e){if(e<=this._progressiveDataLength)return null;const i=new PDFNodeStreamFsRangeReader(this,t,e);this._rangeRequestReaders.push(i);return i}cancelAllRequests(t){this._fullRequestReader?.cancel(t);for(const e of this._rangeRequestReaders.slice(0))e.cancel(t)}}class PDFNodeStreamFsFullReader{constructor(t){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;const e=t.source;this._contentLength=e.length;this._loaded=0;this._filename=null;this._disableRange=e.disableRange||!1;this._rangeChunkSize=e.rangeChunkSize;this._rangeChunkSize||this._disableRange||(this._disableRange=!0);this._isStreamingSupported=!e.disableStream;this._isRangeSupported=!e.disableRange;this._readableStream=null;this._readCapability=Promise.withResolvers();this._headersCapability=Promise.withResolvers();const i=process.getBuiltinModule("fs");i.promises.lstat(this._url).then((t=>{this._contentLength=t.size;this._setReadableStream(i.createReadStream(this._url));this._headersCapability.resolve()}),(t=>{"ENOENT"===t.code&&(t=new MissingPDFException(`Missing PDF "${this._url}".`));this._storedError=t;this._headersCapability.reject(t)}))}get headersReady(){return this._headersCapability.promise}get filename(){return this._filename}get contentLength(){return this._contentLength}get isRangeSupported(){return this._isRangeSupported}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=Promise.withResolvers();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded,total:this._contentLength});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));!this._isStreamingSupported&&this._isRangeSupported&&this._error(new AbortException("streaming is disabled"));this._storedError&&this._readableStream.destroy(this._storedError)}}class PDFNodeStreamFsRangeReader{constructor(t,e,i){this._url=t.url;this._done=!1;this._storedError=null;this.onProgress=null;this._loaded=0;this._readableStream=null;this._readCapability=Promise.withResolvers();const s=t.source;this._isStreamingSupported=!s.disableStream;const n=process.getBuiltinModule("fs");this._setReadableStream(n.createReadStream(this._url,{start:e,end:i-1}))}get isStreamingSupported(){return this._isStreamingSupported}async read(){await this._readCapability.promise;if(this._done)return{value:void 0,done:!0};if(this._storedError)throw this._storedError;const t=this._readableStream.read();if(null===t){this._readCapability=Promise.withResolvers();return this.read()}this._loaded+=t.length;this.onProgress?.({loaded:this._loaded});return{value:new Uint8Array(t).buffer,done:!1}}cancel(t){this._readableStream?this._readableStream.destroy(t):this._error(t)}_error(t){this._storedError=t;this._readCapability.resolve()}_setReadableStream(t){this._readableStream=t;t.on("readable",(()=>{this._readCapability.resolve()}));t.on("end",(()=>{t.destroy();this._done=!0;this._readCapability.resolve()}));t.on("error",(t=>{this._error(t)}));this._storedError&&this._readableStream.destroy(this._storedError)}}const Pt=30;class TextLayer{#Si=Promise.withResolvers();#pt=null;#Ci=!1;#Ti=!!globalThis.FontInspector?.enabled;#Mi=null;#Pi=null;#Di=0;#ki=0;#Ri=null;#Ii=null;#Fi=0;#Li=0;#Oi=Object.create(null);#Ni=[];#Bi=null;#Hi=[];#zi=new WeakMap;#Ui=null;static#Gi=new Map;static#$i=new Map;static#Vi=new WeakMap;static#ji=null;static#Wi=new Set;constructor({textContentSource:t,container:e,viewport:i}){if(t instanceof ReadableStream)this.#Bi=t;else{if("object"!=typeof t)throw new Error('No "textContentSource" parameter specified.');this.#Bi=new ReadableStream({start(e){e.enqueue(t);e.close()}})}this.#pt=this.#Ii=e;this.#Li=i.scale*(globalThis.devicePixelRatio||1);this.#Fi=i.rotation;this.#Pi={div:null,properties:null,ctx:null};const{pageWidth:s,pageHeight:n,pageX:a,pageY:r}=i.rawDims;this.#Ui=[1,0,0,-1,-a,r+n];this.#ki=s;this.#Di=n;TextLayer.#qi();setLayerDimensions(e,i);this.#Si.promise.finally((()=>{TextLayer.#Wi.delete(this);this.#Pi=null;this.#Oi=null})).catch((()=>{}))}static get fontFamilyMap(){const{isWindows:t,isFirefox:e}=util_FeatureTest.platform;return shadow(this,"fontFamilyMap",new Map([["sans-serif",(t&&e?"Calibri, ":"")+"sans-serif"],["monospace",(t&&e?"Lucida Console, ":"")+"monospace"]]))}render(){const pump=()=>{this.#Ri.read().then((({value:t,done:e})=>{if(e)this.#Si.resolve();else{this.#Mi??=t.lang;Object.assign(this.#Oi,t.styles);this.#Xi(t.items);pump()}}),this.#Si.reject)};this.#Ri=this.#Bi.getReader();TextLayer.#Wi.add(this);pump();return this.#Si.promise}update({viewport:t,onBefore:e=null}){const i=t.scale*(globalThis.devicePixelRatio||1),s=t.rotation;if(s!==this.#Fi){e?.();this.#Fi=s;setLayerDimensions(this.#Ii,{rotation:s})}if(i!==this.#Li){e?.();this.#Li=i;const t={div:null,properties:null,ctx:TextLayer.#Ki(this.#Mi)};for(const e of this.#Hi){t.properties=this.#zi.get(e);t.div=e;this.#Yi(t)}}}cancel(){const t=new AbortException("TextLayer task cancelled.");this.#Ri?.cancel(t).catch((()=>{}));this.#Ri=null;this.#Si.reject(t)}get textDivs(){return this.#Hi}get textContentItemsStr(){return this.#Ni}#Xi(t){if(this.#Ci)return;this.#Pi.ctx??=TextLayer.#Ki(this.#Mi);const e=this.#Hi,i=this.#Ni;for(const s of t){if(e.length>1e5){warn("Ignoring additional textDivs for performance reasons.");this.#Ci=!0;return}if(void 0!==s.str){i.push(s.str);this.#Qi(s)}else if("beginMarkedContentProps"===s.type||"beginMarkedContent"===s.type){const t=this.#pt;this.#pt=document.createElement("span");this.#pt.classList.add("markedContent");null!==s.id&&this.#pt.setAttribute("id",`${s.id}`);t.append(this.#pt)}else"endMarkedContent"===s.type&&(this.#pt=this.#pt.parentNode)}}#Qi(t){const e=document.createElement("span"),i={angle:0,canvasWidth:0,hasText:""!==t.str,hasEOL:t.hasEOL,fontSize:0};this.#Hi.push(e);const s=Util.transform(this.#Ui,t.transform);let n=Math.atan2(s[1],s[0]);const a=this.#Oi[t.fontName];a.vertical&&(n+=Math.PI/2);let r=this.#Ti&&a.fontSubstitution||a.fontFamily;r=TextLayer.fontFamilyMap.get(r)||r;const o=Math.hypot(s[2],s[3]),l=o*TextLayer.#Ji(r,this.#Mi);let h,d;if(0===n){h=s[4];d=s[5]-l}else{h=s[4]+l*Math.sin(n);d=s[5]-l*Math.cos(n)}const c="calc(var(--scale-factor)*",u=e.style;if(this.#pt===this.#Ii){u.left=`${(100*h/this.#ki).toFixed(2)}%`;u.top=`${(100*d/this.#Di).toFixed(2)}%`}else{u.left=`${c}${h.toFixed(2)}px)`;u.top=`${c}${d.toFixed(2)}px)`}u.fontSize=`${c}${(TextLayer.#ji*o).toFixed(2)}px)`;u.fontFamily=r;i.fontSize=o;e.setAttribute("role","presentation");e.textContent=t.str;e.dir=t.dir;this.#Ti&&(e.dataset.fontName=a.fontSubstitutionLoadedName||t.fontName);0!==n&&(i.angle=n*(180/Math.PI));let p=!1;if(t.str.length>1)p=!0;else if(" "!==t.str&&t.transform[0]!==t.transform[3]){const e=Math.abs(t.transform[0]),i=Math.abs(t.transform[3]);e!==i&&Math.max(e,i)/Math.min(e,i)>1.5&&(p=!0)}p&&(i.canvasWidth=a.vertical?t.height:t.width);this.#zi.set(e,i);this.#Pi.div=e;this.#Pi.properties=i;this.#Yi(this.#Pi);i.hasText&&this.#pt.append(e);if(i.hasEOL){const t=document.createElement("br");t.setAttribute("role","presentation");this.#pt.append(t)}}#Yi(t){const{div:e,properties:i,ctx:s}=t,{style:n}=e;let a="";TextLayer.#ji>1&&(a=`scale(${1/TextLayer.#ji})`);if(0!==i.canvasWidth&&i.hasText){const{fontFamily:t}=n,{canvasWidth:r,fontSize:o}=i;TextLayer.#Zi(s,o*this.#Li,t);const{width:l}=s.measureText(e.textContent);l>0&&(a=`scaleX(${r*this.#Li/l}) ${a}`)}0!==i.angle&&(a=`rotate(${i.angle}deg) ${a}`);a.length>0&&(n.transform=a)}static cleanup(){if(!(this.#Wi.size>0)){this.#Gi.clear();for(const{canvas:t}of this.#$i.values())t.remove();this.#$i.clear()}}static#Ki(t=null){let e=this.#$i.get(t||="");if(!e){const i=document.createElement("canvas");i.className="hiddenCanvasElement";i.lang=t;document.body.append(i);e=i.getContext("2d",{alpha:!1,willReadFrequently:!0});this.#$i.set(t,e);this.#Vi.set(e,{size:0,family:""})}return e}static#Zi(t,e,i){const s=this.#Vi.get(t);if(e!==s.size||i!==s.family){t.font=`${e}px ${i}`;s.size=e;s.family=i}}static#qi(){if(null!==this.#ji)return;const t=document.createElement("div");t.style.opacity=0;t.style.lineHeight=1;t.style.fontSize="1px";t.style.position="absolute";t.textContent="X";document.body.append(t);this.#ji=t.getBoundingClientRect().height;t.remove()}static#Ji(t,e){const i=this.#Gi.get(t);if(i)return i;const s=this.#Ki(e);s.canvas.width=s.canvas.height=Pt;this.#Zi(s,Pt,t);const n=s.measureText("");let a=n.fontBoundingBoxAscent,r=Math.abs(n.fontBoundingBoxDescent);if(a){const e=a/(a+r);this.#Gi.set(t,e);s.canvas.width=s.canvas.height=0;return e}s.strokeStyle="red";s.clearRect(0,0,Pt,Pt);s.strokeText("g",0,0);let o=s.getImageData(0,0,Pt,Pt).data;r=0;for(let t=o.length-1-3;t>=0;t-=4)if(o[t]>0){r=Math.ceil(t/4/Pt);break}s.clearRect(0,0,Pt,Pt);s.strokeText("A",0,Pt);o=s.getImageData(0,0,Pt,Pt).data;a=0;for(let t=0,e=o.length;t0){a=Pt-Math.floor(t/4/Pt);break}s.canvas.width=s.canvas.height=0;const l=a?a/(a+r):.8;this.#Gi.set(t,l);return l}}class XfaText{static textContent(t){const e=[],i={items:e,styles:Object.create(null)};!function walk(t){if(!t)return;let i=null;const s=t.name;if("#text"===s)i=t.value;else{if(!XfaText.shouldBuildText(s))return;t?.attributes?.textContent?i=t.attributes.textContent:t.value&&(i=t.value)}null!==i&&e.push({str:i});if(t.children)for(const e of t.children)walk(e)}(t);return i}static shouldBuildText(t){return!("textarea"===t||"input"===t||"option"===t||"select"===t)}}const Dt=65536,kt=e?class NodeCanvasFactory extends BaseCanvasFactory{_createCanvas(t,e){return process.getBuiltinModule("module").createRequire(import.meta.url)("@napi-rs/canvas").createCanvas(t,e)}}:class DOMCanvasFactory extends BaseCanvasFactory{constructor({ownerDocument:t=globalThis.document,enableHWA:e=!1}){super({enableHWA:e});this._document=t}_createCanvas(t,e){const i=this._document.createElement("canvas");i.width=t;i.height=e;return i}},Rt=e?class NodeCMapReaderFactory extends BaseCMapReaderFactory{async _fetch(t){return node_utils_fetchData(t)}}:DOMCMapReaderFactory,It=e?class NodeFilterFactory extends BaseFilterFactory{}:class DOMFilterFactory extends BaseFilterFactory{#ts;#es;#is;#ss;#ns;#as;#w=0;constructor({docId:t,ownerDocument:e=globalThis.document}){super();this.#ss=t;this.#ns=e}get#y(){return this.#es||=new Map}get#rs(){return this.#as||=new Map}get#os(){if(!this.#is){const t=this.#ns.createElement("div"),{style:e}=t;e.visibility="hidden";e.contain="strict";e.width=e.height=0;e.position="absolute";e.top=e.left=0;e.zIndex=-1;const i=this.#ns.createElementNS(it,"svg");i.setAttribute("width",0);i.setAttribute("height",0);this.#is=this.#ns.createElementNS(it,"defs");t.append(i);i.append(this.#is);this.#ns.body.append(t)}return this.#is}#ls(t){if(1===t.length){const e=t[0],i=new Array(256);for(let t=0;t<256;t++)i[t]=e[t]/255;const s=i.join(",");return[s,s,s]}const[e,i,s]=t,n=new Array(256),a=new Array(256),r=new Array(256);for(let t=0;t<256;t++){n[t]=e[t]/255;a[t]=i[t]/255;r[t]=s[t]/255}return[n.join(","),a.join(","),r.join(",")]}#hs(t){if(void 0===this.#ts){this.#ts="";const t=this.#ns.URL;t!==this.#ns.baseURI&&(isDataScheme(t)?warn('#createUrl: ignore "data:"-URL for performance reasons.'):this.#ts=t.split("#",1)[0])}return`url(${this.#ts}#${t})`}addFilter(t){if(!t)return"none";let e=this.#y.get(t);if(e)return e;const[i,s,n]=this.#ls(t),a=1===t.length?i:`${i}${s}${n}`;e=this.#y.get(a);if(e){this.#y.set(t,e);return e}const r=`g_${this.#ss}_transfer_map_${this.#w++}`,o=this.#hs(r);this.#y.set(t,o);this.#y.set(a,o);const l=this.#ds(r);this.#cs(i,s,n,l);return o}addHCMFilter(t,e){const i=`${t}-${e}`,s="base";let n=this.#rs.get(s);if(n?.key===i)return n.url;if(n){n.filter?.remove();n.key=i;n.url="none";n.filter=null}else{n={key:i,url:"none",filter:null};this.#rs.set(s,n)}if(!t||!e)return n.url;const a=this.#us(t);t=Util.makeHexColor(...a);const r=this.#us(e);e=Util.makeHexColor(...r);this.#os.style.color="";if("#000000"===t&&"#ffffff"===e||t===e)return n.url;const o=new Array(256);for(let t=0;t<=255;t++){const e=t/255;o[t]=e<=.03928?e/12.92:((e+.055)/1.055)**2.4}const l=o.join(","),h=`g_${this.#ss}_hcm_filter`,d=n.filter=this.#ds(h);this.#cs(l,l,l,d);this.#ps(d);const getSteps=(t,e)=>{const i=a[t]/255,s=r[t]/255,n=new Array(e+1);for(let t=0;t<=e;t++)n[t]=i+t/e*(s-i);return n.join(",")};this.#cs(getSteps(0,5),getSteps(1,5),getSteps(2,5),d);n.url=this.#hs(h);return n.url}addAlphaFilter(t){let e=this.#y.get(t);if(e)return e;const[i]=this.#ls([t]),s=`alpha_${i}`;e=this.#y.get(s);if(e){this.#y.set(t,e);return e}const n=`g_${this.#ss}_alpha_map_${this.#w++}`,a=this.#hs(n);this.#y.set(t,a);this.#y.set(s,a);const r=this.#ds(n);this.#gs(i,r);return a}addLuminosityFilter(t){let e,i,s=this.#y.get(t||"luminosity");if(s)return s;if(t){[e]=this.#ls([t]);i=`luminosity_${e}`}else i="luminosity";s=this.#y.get(i);if(s){this.#y.set(t,s);return s}const n=`g_${this.#ss}_luminosity_map_${this.#w++}`,a=this.#hs(n);this.#y.set(t,a);this.#y.set(i,a);const r=this.#ds(n);this.#ms(r);t&&this.#gs(e,r);return a}addHighlightHCMFilter(t,e,i,s,n){const a=`${e}-${i}-${s}-${n}`;let r=this.#rs.get(t);if(r?.key===a)return r.url;if(r){r.filter?.remove();r.key=a;r.url="none";r.filter=null}else{r={key:a,url:"none",filter:null};this.#rs.set(t,r)}if(!e||!i)return r.url;const[o,l]=[e,i].map(this.#us.bind(this));let h=Math.round(.2126*o[0]+.7152*o[1]+.0722*o[2]),d=Math.round(.2126*l[0]+.7152*l[1]+.0722*l[2]),[c,u]=[s,n].map(this.#us.bind(this));d{const s=new Array(256),n=(d-h)/i,a=t/255,r=(e-t)/(255*i);let o=0;for(let t=0;t<=i;t++){const e=Math.round(h+t*n),i=a+t*r;for(let t=o;t<=e;t++)s[t]=i;o=e+1}for(let t=o;t<256;t++)s[t]=s[o-1];return s.join(",")},p=`g_${this.#ss}_hcm_${t}_filter`,g=r.filter=this.#ds(p);this.#ps(g);this.#cs(getSteps(c[0],u[0],5),getSteps(c[1],u[1],5),getSteps(c[2],u[2],5),g);r.url=this.#hs(p);return r.url}destroy(t=!1){if(!t||!this.#as?.size){this.#is?.parentNode.parentNode.remove();this.#is=null;this.#es?.clear();this.#es=null;this.#as?.clear();this.#as=null;this.#w=0}}#ms(t){const e=this.#ns.createElementNS(it,"feColorMatrix");e.setAttribute("type","matrix");e.setAttribute("values","0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.59 0.11 0 0");t.append(e)}#ps(t){const e=this.#ns.createElementNS(it,"feColorMatrix");e.setAttribute("type","matrix");e.setAttribute("values","0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0");t.append(e)}#ds(t){const e=this.#ns.createElementNS(it,"filter");e.setAttribute("color-interpolation-filters","sRGB");e.setAttribute("id",t);this.#os.append(e);return e}#fs(t,e,i){const s=this.#ns.createElementNS(it,e);s.setAttribute("type","discrete");s.setAttribute("tableValues",i);t.append(s)}#cs(t,e,i,s){const n=this.#ns.createElementNS(it,"feComponentTransfer");s.append(n);this.#fs(n,"feFuncR",t);this.#fs(n,"feFuncG",e);this.#fs(n,"feFuncB",i)}#gs(t,e){const i=this.#ns.createElementNS(it,"feComponentTransfer");e.append(i);this.#fs(i,"feFuncA",t)}#us(t){this.#os.style.color=t;return getRGB(getComputedStyle(this.#os).getPropertyValue("color"))}},Ft=e?class NodeStandardFontDataFactory extends BaseStandardFontDataFactory{async _fetch(t){return node_utils_fetchData(t)}}:DOMStandardFontDataFactory;function getDocument(t={}){"string"==typeof t||t instanceof URL?t={url:t}:(t instanceof ArrayBuffer||ArrayBuffer.isView(t))&&(t={data:t});const i=new PDFDocumentLoadingTask,{docId:s}=i,n=t.url?function getUrlProp(t){if(t instanceof URL)return t.href;try{return new URL(t,window.location).href}catch{if(e&&"string"==typeof t)return t}throw new Error("Invalid PDF url data: either string or URL-object is expected in the url property.")}(t.url):null,a=t.data?function getDataProp(t){if(e&&"undefined"!=typeof Buffer&&t instanceof Buffer)throw new Error("Please provide binary data as `Uint8Array`, rather than `Buffer`.");if(t instanceof Uint8Array&&t.byteLength===t.buffer.byteLength)return t;if("string"==typeof t)return stringToBytes(t);if(t instanceof ArrayBuffer||ArrayBuffer.isView(t)||"object"==typeof t&&!isNaN(t?.length))return new Uint8Array(t);throw new Error("Invalid PDF binary data: either TypedArray, string, or array-like object is expected in the data property.")}(t.data):null,r=t.httpHeaders||null,o=!0===t.withCredentials,l=t.password??null,h=t.range instanceof PDFDataRangeTransport?t.range:null,d=Number.isInteger(t.rangeChunkSize)&&t.rangeChunkSize>0?t.rangeChunkSize:Dt;let c=t.worker instanceof PDFWorker?t.worker:null;const u=t.verbosity,p="string"!=typeof t.docBaseUrl||isDataScheme(t.docBaseUrl)?null:t.docBaseUrl,g="string"==typeof t.cMapUrl?t.cMapUrl:null,m=!1!==t.cMapPacked,f=t.CMapReaderFactory||Rt,b="string"==typeof t.standardFontDataUrl?t.standardFontDataUrl:null,A=t.StandardFontDataFactory||Ft,w=!0!==t.stopAtErrors,v=Number.isInteger(t.maxImageSize)&&t.maxImageSize>-1?t.maxImageSize:-1,y=!1!==t.isEvalSupported,x="boolean"==typeof t.isOffscreenCanvasSupported?t.isOffscreenCanvasSupported:!e,_="boolean"==typeof t.isImageDecoderSupported?t.isImageDecoderSupported:!e&&(util_FeatureTest.platform.isFirefox||!globalThis.chrome),E=Number.isInteger(t.canvasMaxAreaInBytes)?t.canvasMaxAreaInBytes:-1,S="boolean"==typeof t.disableFontFace?t.disableFontFace:e,C=!0===t.fontExtraProperties,T=!0===t.enableXfa,M=t.ownerDocument||globalThis.document,P=!0===t.disableRange,D=!0===t.disableStream,k=!0===t.disableAutoFetch,R=!0===t.pdfBug,I=t.CanvasFactory||kt,F=t.FilterFactory||It,L=!0===t.enableHWA,O=h?h.length:t.length??NaN,N="boolean"==typeof t.useSystemFonts?t.useSystemFonts:!e&&!S,B="boolean"==typeof t.useWorkerFetch?t.useWorkerFetch:f===DOMCMapReaderFactory&&A===DOMStandardFontDataFactory&&g&&b&&isValidFetchUrl(g,document.baseURI)&&isValidFetchUrl(b,document.baseURI);setVerbosityLevel(u);const H={canvasFactory:new I({ownerDocument:M,enableHWA:L}),filterFactory:new F({docId:s,ownerDocument:M}),cMapReaderFactory:B?null:new f({baseUrl:g,isCompressed:m}),standardFontDataFactory:B?null:new A({baseUrl:b})};if(!c){const t={verbosity:u,port:GlobalWorkerOptions.workerPort};c=t.port?PDFWorker.fromPort(t):new PDFWorker(t);i._worker=c}const z={docId:s,apiVersion:"4.10.38",data:a,password:l,disableAutoFetch:k,rangeChunkSize:d,length:O,docBaseUrl:p,enableXfa:T,evaluatorOptions:{maxImageSize:v,disableFontFace:S,ignoreErrors:w,isEvalSupported:y,isOffscreenCanvasSupported:x,isImageDecoderSupported:_,canvasMaxAreaInBytes:E,fontExtraProperties:C,useSystemFonts:N,cMapUrl:B?g:null,standardFontDataUrl:B?b:null}},U={disableFontFace:S,fontExtraProperties:C,ownerDocument:M,pdfBug:R,styleElement:null,loadingParams:{disableAutoFetch:k,enableXfa:T}};c.promise.then((function(){if(i.destroyed)throw new Error("Loading aborted");if(c.destroyed)throw new Error("Worker was destroyed");const t=c.messageHandler.sendWithPromise("GetDocRequest",z,a?[a.buffer]:null);let l;if(h)l=new PDFDataTransportStream(h,{disableRange:P,disableStream:D});else if(!a){if(!n)throw new Error("getDocument - no `url` parameter provided.");let t;if(e)if(isValidFetchUrl(n)){if("undefined"==typeof fetch||"undefined"==typeof Response||!("body"in Response.prototype))throw new Error("getDocument - the Fetch API was disabled in Node.js, see `--no-experimental-fetch`.");t=PDFFetchStream}else t=PDFNodeStream;else t=isValidFetchUrl(n)?PDFFetchStream:PDFNetworkStream;l=new t({url:n,length:O,httpHeaders:r,withCredentials:o,rangeChunkSize:d,disableRange:P,disableStream:D})}return t.then((t=>{if(i.destroyed)throw new Error("Loading aborted");if(c.destroyed)throw new Error("Worker was destroyed");const e=new MessageHandler(s,t,c.port),n=new WorkerTransport(e,i,l,U,H);i._transport=n;e.send("Ready",null)}))})).catch(i._capability.reject);return i}function isRefProxy(t){return"object"==typeof t&&Number.isInteger(t?.num)&&t.num>=0&&Number.isInteger(t?.gen)&&t.gen>=0}class PDFDocumentLoadingTask{static#ss=0;constructor(){this._capability=Promise.withResolvers();this._transport=null;this._worker=null;this.docId="d"+PDFDocumentLoadingTask.#ss++;this.destroyed=!1;this.onPassword=null;this.onProgress=null}get promise(){return this._capability.promise}async destroy(){this.destroyed=!0;try{this._worker?.port&&(this._worker._pendingDestroy=!0);await(this._transport?.destroy())}catch(t){this._worker?.port&&delete this._worker._pendingDestroy;throw t}this._transport=null;this._worker?.destroy();this._worker=null}}class PDFDataRangeTransport{constructor(t,e,i=!1,s=null){this.length=t;this.initialData=e;this.progressiveDone=i;this.contentDispositionFilename=s;this._rangeListeners=[];this._progressListeners=[];this._progressiveReadListeners=[];this._progressiveDoneListeners=[];this._readyCapability=Promise.withResolvers()}addRangeListener(t){this._rangeListeners.push(t)}addProgressListener(t){this._progressListeners.push(t)}addProgressiveReadListener(t){this._progressiveReadListeners.push(t)}addProgressiveDoneListener(t){this._progressiveDoneListeners.push(t)}onDataRange(t,e){for(const i of this._rangeListeners)i(t,e)}onDataProgress(t,e){this._readyCapability.promise.then((()=>{for(const i of this._progressListeners)i(t,e)}))}onDataProgressiveRead(t){this._readyCapability.promise.then((()=>{for(const e of this._progressiveReadListeners)e(t)}))}onDataProgressiveDone(){this._readyCapability.promise.then((()=>{for(const t of this._progressiveDoneListeners)t()}))}transportReady(){this._readyCapability.resolve()}requestDataRange(t,e){unreachable("Abstract method PDFDataRangeTransport.requestDataRange")}abort(){}}class PDFDocumentProxy{constructor(t,e){this._pdfInfo=t;this._transport=e}get annotationStorage(){return this._transport.annotationStorage}get canvasFactory(){return this._transport.canvasFactory}get filterFactory(){return this._transport.filterFactory}get numPages(){return this._pdfInfo.numPages}get fingerprints(){return this._pdfInfo.fingerprints}get isPureXfa(){return shadow(this,"isPureXfa",!!this._transport._htmlForXfa)}get allXfaHtml(){return this._transport._htmlForXfa}getPage(t){return this._transport.getPage(t)}getPageIndex(t){return this._transport.getPageIndex(t)}getDestinations(){return this._transport.getDestinations()}getDestination(t){return this._transport.getDestination(t)}getPageLabels(){return this._transport.getPageLabels()}getPageLayout(){return this._transport.getPageLayout()}getPageMode(){return this._transport.getPageMode()}getViewerPreferences(){return this._transport.getViewerPreferences()}getOpenAction(){return this._transport.getOpenAction()}getAttachments(){return this._transport.getAttachments()}getJSActions(){return this._transport.getDocJSActions()}getOutline(){return this._transport.getOutline()}getOptionalContentConfig({intent:t="display"}={}){const{renderingIntent:e}=this._transport.getRenderingIntent(t);return this._transport.getOptionalContentConfig(e)}getPermissions(){return this._transport.getPermissions()}getMetadata(){return this._transport.getMetadata()}getMarkInfo(){return this._transport.getMarkInfo()}getData(){return this._transport.getData()}saveDocument(){return this._transport.saveDocument()}getDownloadInfo(){return this._transport.downloadInfoCapability.promise}cleanup(t=!1){return this._transport.startCleanup(t||this.isPureXfa)}destroy(){return this.loadingTask.destroy()}cachedPageNumber(t){return this._transport.cachedPageNumber(t)}get loadingParams(){return this._transport.loadingParams}get loadingTask(){return this._transport.loadingTask}getFieldObjects(){return this._transport.getFieldObjects()}hasJSActions(){return this._transport.hasJSActions()}getCalculationOrderIds(){return this._transport.getCalculationOrderIds()}}class PDFPageProxy{#bs=null;#As=!1;constructor(t,e,i,s=!1){this._pageIndex=t;this._pageInfo=e;this._transport=i;this._stats=s?new StatTimer:null;this._pdfBug=s;this.commonObjs=i.commonObjs;this.objs=new PDFObjects;this._maybeCleanupAfterRender=!1;this._intentStates=new Map;this.destroyed=!1}get pageNumber(){return this._pageIndex+1}get rotate(){return this._pageInfo.rotate}get ref(){return this._pageInfo.ref}get userUnit(){return this._pageInfo.userUnit}get view(){return this._pageInfo.view}getViewport({scale:t,rotation:e=this.rotate,offsetX:i=0,offsetY:s=0,dontFlip:n=!1}={}){return new PageViewport({viewBox:this.view,userUnit:this.userUnit,scale:t,rotation:e,offsetX:i,offsetY:s,dontFlip:n})}getAnnotations({intent:t="display"}={}){const{renderingIntent:e}=this._transport.getRenderingIntent(t);return this._transport.getAnnotations(this._pageIndex,e)}getJSActions(){return this._transport.getPageJSActions(this._pageIndex)}get filterFactory(){return this._transport.filterFactory}get isPureXfa(){return shadow(this,"isPureXfa",!!this._transport._htmlForXfa)}async getXfa(){return this._transport._htmlForXfa?.children[this._pageIndex]||null}render({canvasContext:t,viewport:e,intent:i="display",annotationMode:s=p.ENABLE,transform:n=null,background:a=null,optionalContentConfigPromise:r=null,annotationCanvasMap:l=null,pageColors:h=null,printAnnotationStorage:d=null,isEditing:c=!1}){this._stats?.time("Overall");const u=this._transport.getRenderingIntent(i,s,d,c),{renderingIntent:g,cacheKey:m}=u;this.#As=!1;this.#ws();r||=this._transport.getOptionalContentConfig(g);let f=this._intentStates.get(m);if(!f){f=Object.create(null);this._intentStates.set(m,f)}if(f.streamReaderCancelTimeout){clearTimeout(f.streamReaderCancelTimeout);f.streamReaderCancelTimeout=null}const b=!!(g&o);if(!f.displayReadyCapability){f.displayReadyCapability=Promise.withResolvers();f.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(u)}const complete=t=>{f.renderTasks.delete(A);(this._maybeCleanupAfterRender||b)&&(this.#As=!0);this.#vs(!b);if(t){A.capability.reject(t);this._abortOperatorList({intentState:f,reason:t instanceof Error?t:new Error(t)})}else A.capability.resolve();if(this._stats){this._stats.timeEnd("Rendering");this._stats.timeEnd("Overall");globalThis.Stats?.enabled&&globalThis.Stats.add(this.pageNumber,this._stats)}},A=new InternalRenderTask({callback:complete,params:{canvasContext:t,viewport:e,transform:n,background:a},objs:this.objs,commonObjs:this.commonObjs,annotationCanvasMap:l,operatorList:f.operatorList,pageIndex:this._pageIndex,canvasFactory:this._transport.canvasFactory,filterFactory:this._transport.filterFactory,useRequestAnimationFrame:!b,pdfBug:this._pdfBug,pageColors:h});(f.renderTasks||=new Set).add(A);const w=A.task;Promise.all([f.displayReadyCapability.promise,r]).then((([t,e])=>{if(this.destroyed)complete();else{this._stats?.time("Rendering");if(!(e.renderingIntent&g))throw new Error("Must use the same `intent`-argument when calling the `PDFPageProxy.render` and `PDFDocumentProxy.getOptionalContentConfig` methods.");A.initializeGraphics({transparency:t,optionalContentConfig:e});A.operatorListChanged()}})).catch(complete);return w}getOperatorList({intent:t="display",annotationMode:e=p.ENABLE,printAnnotationStorage:i=null,isEditing:s=!1}={}){const n=this._transport.getRenderingIntent(t,e,i,s,!0);let a,r=this._intentStates.get(n.cacheKey);if(!r){r=Object.create(null);this._intentStates.set(n.cacheKey,r)}if(!r.opListReadCapability){a=Object.create(null);a.operatorListChanged=function operatorListChanged(){if(r.operatorList.lastChunk){r.opListReadCapability.resolve(r.operatorList);r.renderTasks.delete(a)}};r.opListReadCapability=Promise.withResolvers();(r.renderTasks||=new Set).add(a);r.operatorList={fnArray:[],argsArray:[],lastChunk:!1,separateAnnots:null};this._stats?.time("Page Request");this._pumpOperatorList(n)}return r.opListReadCapability.promise}streamTextContent({includeMarkedContent:t=!1,disableNormalization:e=!1}={}){return this._transport.messageHandler.sendWithStream("GetTextContent",{pageIndex:this._pageIndex,includeMarkedContent:!0===t,disableNormalization:!0===e},{highWaterMark:100,size:t=>t.items.length})}getTextContent(t={}){if(this._transport._htmlForXfa)return this.getXfa().then((t=>XfaText.textContent(t)));const e=this.streamTextContent(t);return new Promise((function(t,i){const s=e.getReader(),n={items:[],styles:Object.create(null),lang:null};!function pump(){s.read().then((function({value:e,done:i}){if(i)t(n);else{n.lang??=e.lang;Object.assign(n.styles,e.styles);n.items.push(...e.items);pump()}}),i)}()}))}getStructTree(){return this._transport.getStructTree(this._pageIndex)}_destroy(){this.destroyed=!0;const t=[];for(const e of this._intentStates.values()){this._abortOperatorList({intentState:e,reason:new Error("Page was destroyed."),force:!0});if(!e.opListReadCapability)for(const i of e.renderTasks){t.push(i.completed);i.cancel()}}this.objs.clear();this.#As=!1;this.#ws();return Promise.all(t)}cleanup(t=!1){this.#As=!0;const e=this.#vs(!1);t&&e&&(this._stats&&=new StatTimer);return e}#vs(t=!1){this.#ws();if(!this.#As||this.destroyed)return!1;if(t){this.#bs=setTimeout((()=>{this.#bs=null;this.#vs(!1)}),5e3);return!1}for(const{renderTasks:t,operatorList:e}of this._intentStates.values())if(t.size>0||!e.lastChunk)return!1;this._intentStates.clear();this.objs.clear();this.#As=!1;return!0}#ws(){if(this.#bs){clearTimeout(this.#bs);this.#bs=null}}_startRenderPage(t,e){const i=this._intentStates.get(e);if(i){this._stats?.timeEnd("Page Request");i.displayReadyCapability?.resolve(t)}}_renderPageChunk(t,e){for(let i=0,s=t.length;i{r.read().then((({value:t,done:e})=>{if(e)o.streamReader=null;else if(!this._transport.destroyed){this._renderPageChunk(t,o);pump()}}),(t=>{o.streamReader=null;if(!this._transport.destroyed){if(o.operatorList){o.operatorList.lastChunk=!0;for(const t of o.renderTasks)t.operatorListChanged();this.#vs(!0)}if(o.displayReadyCapability)o.displayReadyCapability.reject(t);else{if(!o.opListReadCapability)throw t;o.opListReadCapability.reject(t)}}}))};pump()}_abortOperatorList({intentState:t,reason:e,force:i=!1}){if(t.streamReader){if(t.streamReaderCancelTimeout){clearTimeout(t.streamReaderCancelTimeout);t.streamReaderCancelTimeout=null}if(!i){if(t.renderTasks.size>0)return;if(e instanceof RenderingCancelledException){let i=100;e.extraDelay>0&&e.extraDelay<1e3&&(i+=e.extraDelay);t.streamReaderCancelTimeout=setTimeout((()=>{t.streamReaderCancelTimeout=null;this._abortOperatorList({intentState:t,reason:e,force:!0})}),i);return}}t.streamReader.cancel(new AbortException(e.message)).catch((()=>{}));t.streamReader=null;if(!this._transport.destroyed){for(const[e,i]of this._intentStates)if(i===t){this._intentStates.delete(e);break}this.cleanup()}}}get stats(){return this._stats}}class LoopbackPort{#ys=new Map;#xs=Promise.resolve();postMessage(t,e){const i={data:structuredClone(t,e?{transfer:e}:null)};this.#xs.then((()=>{for(const[t]of this.#ys)t.call(this,i)}))}addEventListener(t,e,i=null){let s=null;if(i?.signal instanceof AbortSignal){const{signal:n}=i;if(n.aborted){warn("LoopbackPort - cannot use an `aborted` signal.");return}const onAbort=()=>this.removeEventListener(t,e);s=()=>n.removeEventListener("abort",onAbort);n.addEventListener("abort",onAbort)}this.#ys.set(e,s)}removeEventListener(t,e){const i=this.#ys.get(e);i?.();this.#ys.delete(e)}terminate(){for(const[,t]of this.#ys)t?.();this.#ys.clear()}}class PDFWorker{static#_s=0;static#Es=!1;static#Ss;static{if(e){this.#Es=!0;GlobalWorkerOptions.workerSrc||="./pdf.worker.mjs"}this._isSameOrigin=(t,e)=>{let i;try{i=new URL(t);if(!i.origin||"null"===i.origin)return!1}catch{return!1}const s=new URL(e,i);return i.origin===s.origin};this._createCDNWrapper=t=>{const e=`await import("${t}");`;return URL.createObjectURL(new Blob([e],{type:"text/javascript"}))}}constructor({name:t=null,port:e=null,verbosity:i=getVerbosityLevel()}={}){this.name=t;this.destroyed=!1;this.verbosity=i;this._readyCapability=Promise.withResolvers();this._port=null;this._webWorker=null;this._messageHandler=null;if(e){if(PDFWorker.#Ss?.has(e))throw new Error("Cannot use more than one PDFWorker per port.");(PDFWorker.#Ss||=new WeakMap).set(e,this);this._initializeFromPort(e)}else this._initialize()}get promise(){return this._readyCapability.promise}#Cs(){this._readyCapability.resolve();this._messageHandler.send("configure",{verbosity:this.verbosity})}get port(){return this._port}get messageHandler(){return this._messageHandler}_initializeFromPort(t){this._port=t;this._messageHandler=new MessageHandler("main","worker",t);this._messageHandler.on("ready",(function(){}));this.#Cs()}_initialize(){if(PDFWorker.#Es||PDFWorker.#Ts){this._setupFakeWorker();return}let{workerSrc:t}=PDFWorker;try{PDFWorker._isSameOrigin(window.location.href,t)||(t=PDFWorker._createCDNWrapper(new URL(t,window.location).href));const e=new Worker(t,{type:"module"}),i=new MessageHandler("main","worker",e),terminateEarly=()=>{s.abort();i.destroy();e.terminate();this.destroyed?this._readyCapability.reject(new Error("Worker was destroyed")):this._setupFakeWorker()},s=new AbortController;e.addEventListener("error",(()=>{this._webWorker||terminateEarly()}),{signal:s.signal});i.on("test",(t=>{s.abort();if(!this.destroyed&&t){this._messageHandler=i;this._port=e;this._webWorker=e;this.#Cs()}else terminateEarly()}));i.on("ready",(t=>{s.abort();if(this.destroyed)terminateEarly();else try{sendTest()}catch{this._setupFakeWorker()}}));const sendTest=()=>{const t=new Uint8Array;i.send("test",t,[t.buffer])};sendTest();return}catch{info("The worker has been disabled.")}this._setupFakeWorker()}_setupFakeWorker(){if(!PDFWorker.#Es){warn("Setting up fake worker.");PDFWorker.#Es=!0}PDFWorker._setupFakeWorkerGlobal.then((t=>{if(this.destroyed){this._readyCapability.reject(new Error("Worker was destroyed"));return}const e=new LoopbackPort;this._port=e;const i="fake"+PDFWorker.#_s++,s=new MessageHandler(i+"_worker",i,e);t.setup(s,e);this._messageHandler=new MessageHandler(i,i+"_worker",e);this.#Cs()})).catch((t=>{this._readyCapability.reject(new Error(`Setting up fake worker failed: "${t.message}".`))}))}destroy(){this.destroyed=!0;this._webWorker?.terminate();this._webWorker=null;PDFWorker.#Ss?.delete(this._port);this._port=null;this._messageHandler?.destroy();this._messageHandler=null}static fromPort(t){if(!t?.port)throw new Error("PDFWorker.fromPort - invalid method signature.");const e=this.#Ss?.get(t.port);if(e){if(e._pendingDestroy)throw new Error("PDFWorker.fromPort - the worker is being destroyed.\nPlease remember to await `PDFDocumentLoadingTask.destroy()`-calls.");return e}return new PDFWorker(t)}static get workerSrc(){if(GlobalWorkerOptions.workerSrc)return GlobalWorkerOptions.workerSrc;throw new Error('No "GlobalWorkerOptions.workerSrc" specified.')}static get#Ts(){try{return globalThis.pdfjsWorker?.WorkerMessageHandler||null}catch{return null}}static get _setupFakeWorkerGlobal(){return shadow(this,"_setupFakeWorkerGlobal",(async()=>{if(this.#Ts)return this.#Ts;return(await import(this.workerSrc)).WorkerMessageHandler})())}}class WorkerTransport{#Ms=new Map;#Ps=new Map;#Ds=new Map;#ks=new Map;#Rs=null;constructor(t,e,i,s,n){this.messageHandler=t;this.loadingTask=e;this.commonObjs=new PDFObjects;this.fontLoader=new FontLoader({ownerDocument:s.ownerDocument,styleElement:s.styleElement});this.loadingParams=s.loadingParams;this._params=s;this.canvasFactory=n.canvasFactory;this.filterFactory=n.filterFactory;this.cMapReaderFactory=n.cMapReaderFactory;this.standardFontDataFactory=n.standardFontDataFactory;this.destroyed=!1;this.destroyCapability=null;this._networkStream=i;this._fullReader=null;this._lastProgress=null;this.downloadInfoCapability=Promise.withResolvers();this.setupMessageHandler()}#Is(t,e=null){const i=this.#Ms.get(t);if(i)return i;const s=this.messageHandler.sendWithPromise(t,e);this.#Ms.set(t,s);return s}get annotationStorage(){return shadow(this,"annotationStorage",new AnnotationStorage)}getRenderingIntent(t,e=p.ENABLE,i=null,s=!1,n=!1){let g=r,m=rt;switch(t){case"any":g=a;break;case"display":break;case"print":g=o;break;default:warn(`getRenderingIntent - invalid intent: ${t}`)}const f=g&o&&i instanceof PrintAnnotationStorage?i:this.annotationStorage;switch(e){case p.DISABLE:g+=d;break;case p.ENABLE:break;case p.ENABLE_FORMS:g+=l;break;case p.ENABLE_STORAGE:g+=h;m=f.serializable;break;default:warn(`getRenderingIntent - invalid annotationMode: ${e}`)}s&&(g+=c);n&&(g+=u);const{ids:b,hash:A}=f.modifiedIds;return{renderingIntent:g,cacheKey:[g,m.hash,A].join("_"),annotationStorageSerializable:m,modifiedIds:b}}destroy(){if(this.destroyCapability)return this.destroyCapability.promise;this.destroyed=!0;this.destroyCapability=Promise.withResolvers();this.#Rs?.reject(new Error("Worker was destroyed during onPassword callback"));const t=[];for(const e of this.#Ps.values())t.push(e._destroy());this.#Ps.clear();this.#Ds.clear();this.#ks.clear();this.hasOwnProperty("annotationStorage")&&this.annotationStorage.resetModified();const e=this.messageHandler.sendWithPromise("Terminate",null);t.push(e);Promise.all(t).then((()=>{this.commonObjs.clear();this.fontLoader.clear();this.#Ms.clear();this.filterFactory.destroy();TextLayer.cleanup();this._networkStream?.cancelAllRequests(new AbortException("Worker was terminated."));this.messageHandler?.destroy();this.messageHandler=null;this.destroyCapability.resolve()}),this.destroyCapability.reject);return this.destroyCapability.promise}setupMessageHandler(){const{messageHandler:t,loadingTask:e}=this;t.on("GetReader",((t,e)=>{assert(this._networkStream,"GetReader - no `IPDFStream` instance available.");this._fullReader=this._networkStream.getFullReader();this._fullReader.onProgress=t=>{this._lastProgress={loaded:t.loaded,total:t.total}};e.onPull=()=>{this._fullReader.read().then((function({value:t,done:i}){if(i)e.close();else{assert(t instanceof ArrayBuffer,"GetReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{this._fullReader.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}));t.on("ReaderHeadersReady",(async t=>{await this._fullReader.headersReady;const{isStreamingSupported:i,isRangeSupported:s,contentLength:n}=this._fullReader;if(!i||!s){this._lastProgress&&e.onProgress?.(this._lastProgress);this._fullReader.onProgress=t=>{e.onProgress?.({loaded:t.loaded,total:t.total})}}return{isStreamingSupported:i,isRangeSupported:s,contentLength:n}}));t.on("GetRangeReader",((t,e)=>{assert(this._networkStream,"GetRangeReader - no `IPDFStream` instance available.");const i=this._networkStream.getRangeReader(t.begin,t.end);if(i){e.onPull=()=>{i.read().then((function({value:t,done:i}){if(i)e.close();else{assert(t instanceof ArrayBuffer,"GetRangeReader - expected an ArrayBuffer.");e.enqueue(new Uint8Array(t),1,[t])}})).catch((t=>{e.error(t)}))};e.onCancel=t=>{i.cancel(t);e.ready.catch((t=>{if(!this.destroyed)throw t}))}}else e.close()}));t.on("GetDoc",(({pdfInfo:t})=>{this._numPages=t.numPages;this._htmlForXfa=t.htmlForXfa;delete t.htmlForXfa;e._capability.resolve(new PDFDocumentProxy(t,this))}));t.on("DocException",(t=>{e._capability.reject(wrapReason(t))}));t.on("PasswordRequest",(t=>{this.#Rs=Promise.withResolvers();try{if(!e.onPassword)throw wrapReason(t);const updatePassword=t=>{t instanceof Error?this.#Rs.reject(t):this.#Rs.resolve({password:t})};e.onPassword(updatePassword,t.code)}catch(t){this.#Rs.reject(t)}return this.#Rs.promise}));t.on("DataLoaded",(t=>{e.onProgress?.({loaded:t.length,total:t.length});this.downloadInfoCapability.resolve(t)}));t.on("StartRenderPage",(t=>{if(this.destroyed)return;this.#Ps.get(t.pageIndex)._startRenderPage(t.transparency,t.cacheKey)}));t.on("commonobj",(([e,i,s])=>{if(this.destroyed)return null;if(this.commonObjs.has(e))return null;switch(i){case"Font":const{disableFontFace:n,fontExtraProperties:a,pdfBug:r}=this._params;if("error"in s){const t=s.error;warn(`Error during font loading: ${t}`);this.commonObjs.resolve(e,t);break}const o=r&&globalThis.FontInspector?.enabled?(t,e)=>globalThis.FontInspector.fontAdded(t,e):null,l=new FontFaceObject(s,{disableFontFace:n,fontExtraProperties:a,inspectFont:o});this.fontLoader.bind(l).catch((()=>t.sendWithPromise("FontFallback",{id:e}))).finally((()=>{!a&&l.data&&(l.data=null);this.commonObjs.resolve(e,l)}));break;case"CopyLocalImage":const{imageRef:h}=s;assert(h,"The imageRef must be defined.");for(const t of this.#Ps.values())for(const[,i]of t.objs)if(i?.ref===h){if(!i.dataLen)return null;this.commonObjs.resolve(e,structuredClone(i));return i.dataLen}break;case"FontPath":case"Image":case"Pattern":this.commonObjs.resolve(e,s);break;default:throw new Error(`Got unknown common object type ${i}`)}return null}));t.on("obj",(([t,e,i,s])=>{if(this.destroyed)return;const n=this.#Ps.get(e);if(!n.objs.has(t))if(0!==n._intentStates.size)switch(i){case"Image":n.objs.resolve(t,s);s?.dataLen>1e7&&(n._maybeCleanupAfterRender=!0);break;case"Pattern":n.objs.resolve(t,s);break;default:throw new Error(`Got unknown object type ${i}`)}else s?.bitmap?.close()}));t.on("DocProgress",(t=>{this.destroyed||e.onProgress?.({loaded:t.loaded,total:t.total})}));t.on("FetchBuiltInCMap",(async t=>{if(this.destroyed)throw new Error("Worker was destroyed.");if(!this.cMapReaderFactory)throw new Error("CMapReaderFactory not initialized, see the `useWorkerFetch` parameter.");return this.cMapReaderFactory.fetch(t)}));t.on("FetchStandardFontData",(async t=>{if(this.destroyed)throw new Error("Worker was destroyed.");if(!this.standardFontDataFactory)throw new Error("StandardFontDataFactory not initialized, see the `useWorkerFetch` parameter.");return this.standardFontDataFactory.fetch(t)}))}getData(){return this.messageHandler.sendWithPromise("GetData",null)}saveDocument(){this.annotationStorage.size<=0&&warn("saveDocument called while `annotationStorage` is empty, please use the getData-method instead.");const{map:t,transfer:e}=this.annotationStorage.serializable;return this.messageHandler.sendWithPromise("SaveDocument",{isPureXfa:!!this._htmlForXfa,numPages:this._numPages,annotationStorage:t,filename:this._fullReader?.filename??null},e).finally((()=>{this.annotationStorage.resetModified()}))}getPage(t){if(!Number.isInteger(t)||t<=0||t>this._numPages)return Promise.reject(new Error("Invalid page request."));const e=t-1,i=this.#Ds.get(e);if(i)return i;const s=this.messageHandler.sendWithPromise("GetPage",{pageIndex:e}).then((i=>{if(this.destroyed)throw new Error("Transport destroyed");i.refStr&&this.#ks.set(i.refStr,t);const s=new PDFPageProxy(e,i,this,this._params.pdfBug);this.#Ps.set(e,s);return s}));this.#Ds.set(e,s);return s}getPageIndex(t){return isRefProxy(t)?this.messageHandler.sendWithPromise("GetPageIndex",{num:t.num,gen:t.gen}):Promise.reject(new Error("Invalid pageIndex request."))}getAnnotations(t,e){return this.messageHandler.sendWithPromise("GetAnnotations",{pageIndex:t,intent:e})}getFieldObjects(){return this.#Is("GetFieldObjects")}hasJSActions(){return this.#Is("HasJSActions")}getCalculationOrderIds(){return this.messageHandler.sendWithPromise("GetCalculationOrderIds",null)}getDestinations(){return this.messageHandler.sendWithPromise("GetDestinations",null)}getDestination(t){return"string"!=typeof t?Promise.reject(new Error("Invalid destination request.")):this.messageHandler.sendWithPromise("GetDestination",{id:t})}getPageLabels(){return this.messageHandler.sendWithPromise("GetPageLabels",null)}getPageLayout(){return this.messageHandler.sendWithPromise("GetPageLayout",null)}getPageMode(){return this.messageHandler.sendWithPromise("GetPageMode",null)}getViewerPreferences(){return this.messageHandler.sendWithPromise("GetViewerPreferences",null)}getOpenAction(){return this.messageHandler.sendWithPromise("GetOpenAction",null)}getAttachments(){return this.messageHandler.sendWithPromise("GetAttachments",null)}getDocJSActions(){return this.#Is("GetDocJSActions")}getPageJSActions(t){return this.messageHandler.sendWithPromise("GetPageJSActions",{pageIndex:t})}getStructTree(t){return this.messageHandler.sendWithPromise("GetStructTree",{pageIndex:t})}getOutline(){return this.messageHandler.sendWithPromise("GetOutline",null)}getOptionalContentConfig(t){return this.#Is("GetOptionalContentConfig").then((e=>new OptionalContentConfig(e,t)))}getPermissions(){return this.messageHandler.sendWithPromise("GetPermissions",null)}getMetadata(){const t="GetMetadata",e=this.#Ms.get(t);if(e)return e;const i=this.messageHandler.sendWithPromise(t,null).then((t=>({info:t[0],metadata:t[1]?new Metadata(t[1]):null,contentDispositionFilename:this._fullReader?.filename??null,contentLength:this._fullReader?.contentLength??null})));this.#Ms.set(t,i);return i}getMarkInfo(){return this.messageHandler.sendWithPromise("GetMarkInfo",null)}async startCleanup(t=!1){if(!this.destroyed){await this.messageHandler.sendWithPromise("Cleanup",null);for(const t of this.#Ps.values()){if(!t.cleanup())throw new Error(`startCleanup: Page ${t.pageNumber} is currently rendering.`)}this.commonObjs.clear();t||this.fontLoader.clear();this.#Ms.clear();this.filterFactory.destroy(!0);TextLayer.cleanup()}}cachedPageNumber(t){if(!isRefProxy(t))return null;const e=0===t.gen?`${t.num}R`:`${t.num}R${t.gen}`;return this.#ks.get(e)??null}}const Lt=Symbol("INITIAL_DATA");class PDFObjects{#Fs=Object.create(null);#Ls(t){return this.#Fs[t]||={...Promise.withResolvers(),data:Lt}}get(t,e=null){if(e){const i=this.#Ls(t);i.promise.then((()=>e(i.data)));return null}const i=this.#Fs[t];if(!i||i.data===Lt)throw new Error(`Requesting object that isn't resolved yet ${t}.`);return i.data}has(t){const e=this.#Fs[t];return!!e&&e.data!==Lt}delete(t){const e=this.#Fs[t];if(!e||e.data===Lt)return!1;delete this.#Fs[t];return!0}resolve(t,e=null){const i=this.#Ls(t);i.data=e;i.resolve()}clear(){for(const t in this.#Fs){const{data:e}=this.#Fs[t];e?.bitmap?.close()}this.#Fs=Object.create(null)}*[Symbol.iterator](){for(const t in this.#Fs){const{data:e}=this.#Fs[t];e!==Lt&&(yield[t,e])}}}class RenderTask{#Os=null;constructor(t){this.#Os=t;this.onContinue=null}get promise(){return this.#Os.capability.promise}cancel(t=0){this.#Os.cancel(null,t)}get separateAnnots(){const{separateAnnots:t}=this.#Os.operatorList;if(!t)return!1;const{annotationCanvasMap:e}=this.#Os;return t.form||t.canvas&&e?.size>0}}class InternalRenderTask{#Ns=null;static#Bs=new WeakSet;constructor({callback:t,params:e,objs:i,commonObjs:s,annotationCanvasMap:n,operatorList:a,pageIndex:r,canvasFactory:o,filterFactory:l,useRequestAnimationFrame:h=!1,pdfBug:d=!1,pageColors:c=null}){this.callback=t;this.params=e;this.objs=i;this.commonObjs=s;this.annotationCanvasMap=n;this.operatorListIdx=null;this.operatorList=a;this._pageIndex=r;this.canvasFactory=o;this.filterFactory=l;this._pdfBug=d;this.pageColors=c;this.running=!1;this.graphicsReadyCallback=null;this.graphicsReady=!1;this._useRequestAnimationFrame=!0===h&&"undefined"!=typeof window;this.cancelled=!1;this.capability=Promise.withResolvers();this.task=new RenderTask(this);this._cancelBound=this.cancel.bind(this);this._continueBound=this._continue.bind(this);this._scheduleNextBound=this._scheduleNext.bind(this);this._nextBound=this._next.bind(this);this._canvas=e.canvasContext.canvas}get completed(){return this.capability.promise.catch((function(){}))}initializeGraphics({transparency:t=!1,optionalContentConfig:e}){if(this.cancelled)return;if(this._canvas){if(InternalRenderTask.#Bs.has(this._canvas))throw new Error("Cannot use the same canvas during multiple render() operations. Use different canvas or ensure previous operations were cancelled or completed.");InternalRenderTask.#Bs.add(this._canvas)}if(this._pdfBug&&globalThis.StepperManager?.enabled){this.stepper=globalThis.StepperManager.create(this._pageIndex);this.stepper.init(this.operatorList);this.stepper.nextBreakPoint=this.stepper.getNextBreakPoint()}const{canvasContext:i,viewport:s,transform:n,background:a}=this.params;this.gfx=new CanvasGraphics(i,this.commonObjs,this.objs,this.canvasFactory,this.filterFactory,{optionalContentConfig:e},this.annotationCanvasMap,this.pageColors);this.gfx.beginDrawing({transform:n,viewport:s,transparency:t,background:a});this.operatorListIdx=0;this.graphicsReady=!0;this.graphicsReadyCallback?.()}cancel(t=null,e=0){this.running=!1;this.cancelled=!0;this.gfx?.endDrawing();if(this.#Ns){window.cancelAnimationFrame(this.#Ns);this.#Ns=null}InternalRenderTask.#Bs.delete(this._canvas);this.callback(t||new RenderingCancelledException(`Rendering cancelled, page ${this._pageIndex+1}`,e))}operatorListChanged(){if(this.graphicsReady){this.stepper?.updateOperatorList(this.operatorList);this.running||this._continue()}else this.graphicsReadyCallback||=this._continueBound}_continue(){this.running=!0;this.cancelled||(this.task.onContinue?this.task.onContinue(this._scheduleNextBound):this._scheduleNext())}_scheduleNext(){this._useRequestAnimationFrame?this.#Ns=window.requestAnimationFrame((()=>{this.#Ns=null;this._nextBound().catch(this._cancelBound)})):Promise.resolve().then(this._nextBound).catch(this._cancelBound)}async _next(){if(!this.cancelled){this.operatorListIdx=this.gfx.executeOperatorList(this.operatorList,this.operatorListIdx,this._continueBound,this.stepper);if(this.operatorListIdx===this.operatorList.argsArray.length){this.running=!1;if(this.operatorList.lastChunk){this.gfx.endDrawing();InternalRenderTask.#Bs.delete(this._canvas);this.callback()}}}}}const Ot="4.10.38",Nt="f9bea397f";function makeColorComp(t){return Math.floor(255*Math.max(0,Math.min(1,t))).toString(16).padStart(2,"0")}function scaleAndClamp(t){return Math.max(0,Math.min(255,255*t))}class ColorConverters{static CMYK_G([t,e,i,s]){return["G",1-Math.min(1,.3*t+.59*i+.11*e+s)]}static G_CMYK([t]){return["CMYK",0,0,0,1-t]}static G_RGB([t]){return["RGB",t,t,t]}static G_rgb([t]){return[t=scaleAndClamp(t),t,t]}static G_HTML([t]){const e=makeColorComp(t);return`#${e}${e}${e}`}static RGB_G([t,e,i]){return["G",.3*t+.59*e+.11*i]}static RGB_rgb(t){return t.map(scaleAndClamp)}static RGB_HTML(t){return`#${t.map(makeColorComp).join("")}`}static T_HTML(){return"#00000000"}static T_rgb(){return[null]}static CMYK_RGB([t,e,i,s]){return["RGB",1-Math.min(1,t+s),1-Math.min(1,i+s),1-Math.min(1,e+s)]}static CMYK_rgb([t,e,i,s]){return[scaleAndClamp(1-Math.min(1,t+s)),scaleAndClamp(1-Math.min(1,i+s)),scaleAndClamp(1-Math.min(1,e+s))]}static CMYK_HTML(t){const e=this.CMYK_RGB(t).slice(1);return this.RGB_HTML(e)}static RGB_CMYK([t,e,i]){const s=1-t,n=1-e,a=1-i;return["CMYK",s,n,a,Math.min(s,n,a)]}}class BaseSVGFactory{create(t,e,i=!1){if(t<=0||e<=0)throw new Error("Invalid SVG dimensions");const s=this._createSVG("svg:svg");s.setAttribute("version","1.1");if(!i){s.setAttribute("width",`${t}px`);s.setAttribute("height",`${e}px`)}s.setAttribute("preserveAspectRatio","none");s.setAttribute("viewBox",`0 0 ${t} ${e}`);return s}createElement(t){if("string"!=typeof t)throw new Error("Invalid SVG element type");return this._createSVG(t)}_createSVG(t){unreachable("Abstract method `_createSVG` called.")}}class DOMSVGFactory extends BaseSVGFactory{_createSVG(t){return document.createElementNS(it,t)}}class XfaLayer{static setupStorage(t,e,i,s,n){const a=s.getValue(e,{value:null});switch(i.name){case"textarea":null!==a.value&&(t.textContent=a.value);if("print"===n)break;t.addEventListener("input",(t=>{s.setValue(e,{value:t.target.value})}));break;case"input":if("radio"===i.attributes.type||"checkbox"===i.attributes.type){a.value===i.attributes.xfaOn?t.setAttribute("checked",!0):a.value===i.attributes.xfaOff&&t.removeAttribute("checked");if("print"===n)break;t.addEventListener("change",(t=>{s.setValue(e,{value:t.target.checked?t.target.getAttribute("xfaOn"):t.target.getAttribute("xfaOff")})}))}else{null!==a.value&&t.setAttribute("value",a.value);if("print"===n)break;t.addEventListener("input",(t=>{s.setValue(e,{value:t.target.value})}))}break;case"select":if(null!==a.value){t.setAttribute("value",a.value);for(const t of i.children)t.attributes.value===a.value?t.attributes.selected=!0:t.attributes.hasOwnProperty("selected")&&delete t.attributes.selected}t.addEventListener("input",(t=>{const i=t.target.options,n=-1===i.selectedIndex?"":i[i.selectedIndex].value;s.setValue(e,{value:n})}))}}static setAttributes({html:t,element:e,storage:i=null,intent:s,linkService:n}){const{attributes:a}=e,r=t instanceof HTMLAnchorElement;"radio"===a.type&&(a.name=`${a.name}-${s}`);for(const[e,i]of Object.entries(a))if(null!=i)switch(e){case"class":i.length&&t.setAttribute(e,i.join(" "));break;case"dataId":break;case"id":t.setAttribute("data-element-id",i);break;case"style":Object.assign(t.style,i);break;case"textContent":t.textContent=i;break;default:(!r||"href"!==e&&"newWindow"!==e)&&t.setAttribute(e,i)}r&&n.addLinkAttributes(t,a.href,a.newWindow);i&&a.dataId&&this.setupStorage(t,a.dataId,e,i)}static render(t){const e=t.annotationStorage,i=t.linkService,s=t.xfaHtml,n=t.intent||"display",a=document.createElement(s.name);s.attributes&&this.setAttributes({html:a,element:s,intent:n,linkService:i});const r="richText"!==n,o=t.div;o.append(a);if(t.viewport){const e=`matrix(${t.viewport.transform.join(",")})`;o.style.transform=e}r&&o.setAttribute("class","xfaLayer xfaFont");const l=[];if(0===s.children.length){if(s.value){const t=document.createTextNode(s.value);a.append(t);r&&XfaText.shouldBuildText(s.name)&&l.push(t)}return{textDivs:l}}const h=[[s,-1,a]];for(;h.length>0;){const[t,s,a]=h.at(-1);if(s+1===t.children.length){h.pop();continue}const o=t.children[++h.at(-1)[1]];if(null===o)continue;const{name:d}=o;if("#text"===d){const t=document.createTextNode(o.value);l.push(t);a.append(t);continue}const c=o?.attributes?.xmlns?document.createElementNS(o.attributes.xmlns,d):document.createElement(d);a.append(c);o.attributes&&this.setAttributes({html:c,element:o,storage:e,intent:n,linkService:i});if(o.children?.length>0)h.push([o,-1,c]);else if(o.value){const t=document.createTextNode(o.value);r&&XfaText.shouldBuildText(d)&&l.push(t);c.append(t)}}for(const t of o.querySelectorAll(".xfaNonInteractive input, .xfaNonInteractive textarea"))t.setAttribute("readOnly",!0);return{textDivs:l}}static update(t){const e=`matrix(${t.viewport.transform.join(",")})`;t.div.style.transform=e;t.div.hidden=!1}}const Bt=1e3,Ht=new WeakSet;function getRectDims(t){return{width:t[2]-t[0],height:t[3]-t[1]}}class AnnotationElementFactory{static create(t){switch(t.data.annotationType){case S:return new LinkAnnotationElement(t);case E:return new TextAnnotationElement(t);case U:switch(t.data.fieldType){case"Tx":return new TextWidgetAnnotationElement(t);case"Btn":return t.data.radioButton?new RadioButtonWidgetAnnotationElement(t):t.data.checkBox?new CheckboxWidgetAnnotationElement(t):new PushButtonWidgetAnnotationElement(t);case"Ch":return new ChoiceWidgetAnnotationElement(t);case"Sig":return new SignatureWidgetAnnotationElement(t)}return new WidgetAnnotationElement(t);case H:return new PopupAnnotationElement(t);case C:return new FreeTextAnnotationElement(t);case T:return new LineAnnotationElement(t);case M:return new SquareAnnotationElement(t);case P:return new CircleAnnotationElement(t);case k:return new PolylineAnnotationElement(t);case N:return new CaretAnnotationElement(t);case B:return new InkAnnotationElement(t);case D:return new PolygonAnnotationElement(t);case R:return new HighlightAnnotationElement(t);case I:return new UnderlineAnnotationElement(t);case F:return new SquigglyAnnotationElement(t);case L:return new StrikeOutAnnotationElement(t);case O:return new StampAnnotationElement(t);case z:return new FileAttachmentAnnotationElement(t);default:return new AnnotationElement(t)}}}class AnnotationElement{#Hs=null;#zs=!1;#Us=null;constructor(t,{isRenderable:e=!1,ignoreBorder:i=!1,createQuadrilaterals:s=!1}={}){this.isRenderable=e;this.data=t.data;this.layer=t.layer;this.linkService=t.linkService;this.downloadManager=t.downloadManager;this.imageResourcesPath=t.imageResourcesPath;this.renderForms=t.renderForms;this.svgFactory=t.svgFactory;this.annotationStorage=t.annotationStorage;this.enableScripting=t.enableScripting;this.hasJSActions=t.hasJSActions;this._fieldObjects=t.fieldObjects;this.parent=t.parent;e&&(this.container=this._createContainer(i));s&&this._createQuadrilaterals()}static _hasPopupData({titleObj:t,contentsObj:e,richText:i}){return!!(t?.str||e?.str||i?.str)}get _isEditable(){return this.data.isEditable}get hasPopupData(){return AnnotationElement._hasPopupData(this.data)}updateEdited(t){if(!this.container)return;this.#Hs||={rect:this.data.rect.slice(0)};const{rect:e}=t;e&&this.#Gs(e);this.#Us?.popup.updateEdited(t)}resetEdited(){if(this.#Hs){this.#Gs(this.#Hs.rect);this.#Us?.popup.resetEdited();this.#Hs=null}}#Gs(t){const{container:{style:e},data:{rect:i,rotation:s},parent:{viewport:{rawDims:{pageWidth:n,pageHeight:a,pageX:r,pageY:o}}}}=this;i?.splice(0,4,...t);const{width:l,height:h}=getRectDims(t);e.left=100*(t[0]-r)/n+"%";e.top=100*(a-t[3]+o)/a+"%";if(0===s){e.width=100*l/n+"%";e.height=100*h/a+"%"}else this.setRotation(s)}_createContainer(t){const{data:e,parent:{page:i,viewport:s}}=this,n=document.createElement("section");n.setAttribute("data-annotation-id",e.id);this instanceof WidgetAnnotationElement||(n.tabIndex=Bt);const{style:a}=n;a.zIndex=this.parent.zIndex++;e.alternativeText&&(n.title=e.alternativeText);e.noRotate&&n.classList.add("norotate");if(!e.rect||this instanceof PopupAnnotationElement){const{rotation:t}=e;e.hasOwnCanvas||0===t||this.setRotation(t,n);return n}const{width:r,height:o}=getRectDims(e.rect);if(!t&&e.borderStyle.width>0){a.borderWidth=`${e.borderStyle.width}px`;const t=e.borderStyle.horizontalCornerRadius,i=e.borderStyle.verticalCornerRadius;if(t>0||i>0){const e=`calc(${t}px * var(--scale-factor)) / calc(${i}px * var(--scale-factor))`;a.borderRadius=e}else if(this instanceof RadioButtonWidgetAnnotationElement){const t=`calc(${r}px * var(--scale-factor)) / calc(${o}px * var(--scale-factor))`;a.borderRadius=t}switch(e.borderStyle.style){case G:a.borderStyle="solid";break;case $:a.borderStyle="dashed";break;case V:warn("Unimplemented border style: beveled");break;case j:warn("Unimplemented border style: inset");break;case W:a.borderBottomStyle="solid"}const s=e.borderColor||null;if(s){this.#zs=!0;a.borderColor=Util.makeHexColor(0|s[0],0|s[1],0|s[2])}else a.borderWidth=0}const l=Util.normalizeRect([e.rect[0],i.view[3]-e.rect[1]+i.view[1],e.rect[2],i.view[3]-e.rect[3]+i.view[1]]),{pageWidth:h,pageHeight:d,pageX:c,pageY:u}=s.rawDims;a.left=100*(l[0]-c)/h+"%";a.top=100*(l[1]-u)/d+"%";const{rotation:p}=e;if(e.hasOwnCanvas||0===p){a.width=100*r/h+"%";a.height=100*o/d+"%"}else this.setRotation(p,n);return n}setRotation(t,e=this.container){if(!this.data.rect)return;const{pageWidth:i,pageHeight:s}=this.parent.viewport.rawDims,{width:n,height:a}=getRectDims(this.data.rect);let r,o;if(t%180==0){r=100*n/i;o=100*a/s}else{r=100*a/i;o=100*n/s}e.style.width=`${r}%`;e.style.height=`${o}%`;e.setAttribute("data-main-rotation",(360-t)%360)}get _commonActions(){const setColor=(t,e,i)=>{const s=i.detail[t],n=s[0],a=s.slice(1);i.target.style[e]=ColorConverters[`${n}_HTML`](a);this.annotationStorage.setValue(this.data.id,{[e]:ColorConverters[`${n}_rgb`](a)})};return shadow(this,"_commonActions",{display:t=>{const{display:e}=t.detail,i=e%2==1;this.container.style.visibility=i?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{noView:i,noPrint:1===e||2===e})},print:t=>{this.annotationStorage.setValue(this.data.id,{noPrint:!t.detail.print})},hidden:t=>{const{hidden:e}=t.detail;this.container.style.visibility=e?"hidden":"visible";this.annotationStorage.setValue(this.data.id,{noPrint:e,noView:e})},focus:t=>{setTimeout((()=>t.target.focus({preventScroll:!1})),0)},userName:t=>{t.target.title=t.detail.userName},readonly:t=>{t.target.disabled=t.detail.readonly},required:t=>{this._setRequired(t.target,t.detail.required)},bgColor:t=>{setColor("bgColor","backgroundColor",t)},fillColor:t=>{setColor("fillColor","backgroundColor",t)},fgColor:t=>{setColor("fgColor","color",t)},textColor:t=>{setColor("textColor","color",t)},borderColor:t=>{setColor("borderColor","borderColor",t)},strokeColor:t=>{setColor("strokeColor","borderColor",t)},rotation:t=>{const e=t.detail.rotation;this.setRotation(e);this.annotationStorage.setValue(this.data.id,{rotation:e})}})}_dispatchEventFromSandbox(t,e){const i=this._commonActions;for(const s of Object.keys(e.detail)){const n=t[s]||i[s];n?.(e)}}_setDefaultPropertiesFromJS(t){if(!this.enableScripting)return;const e=this.annotationStorage.getRawValue(this.data.id);if(!e)return;const i=this._commonActions;for(const[s,n]of Object.entries(e)){const a=i[s];if(a){a({detail:{[s]:n},target:t});delete e[s]}}}_createQuadrilaterals(){if(!this.container)return;const{quadPoints:t}=this.data;if(!t)return;const[e,i,s,n]=this.data.rect.map((t=>Math.fround(t)));if(8===t.length){const[a,r,o,l]=t.subarray(2,6);if(s===a&&n===r&&e===o&&i===l)return}const{style:a}=this.container;let r;if(this.#zs){const{borderColor:t,borderWidth:e}=a;a.borderWidth=0;r=["url('data:image/svg+xml;utf8,",'',``];this.container.classList.add("hasBorder")}const o=s-e,l=n-i,{svgFactory:h}=this,d=h.createElement("svg");d.classList.add("quadrilateralsContainer");d.setAttribute("width",0);d.setAttribute("height",0);const c=h.createElement("defs");d.append(c);const u=h.createElement("clipPath"),p=`clippath_${this.data.id}`;u.setAttribute("id",p);u.setAttribute("clipPathUnits","objectBoundingBox");c.append(u);for(let i=2,s=t.length;i`)}if(this.#zs){r.push("')");a.backgroundImage=r.join("")}this.container.append(d);this.container.style.clipPath=`url(#${p})`}_createPopup(){const{data:t}=this,e=this.#Us=new PopupAnnotationElement({data:{color:t.color,titleObj:t.titleObj,modificationDate:t.modificationDate,contentsObj:t.contentsObj,richText:t.richText,parentRect:t.rect,borderStyle:0,id:`popup_${t.id}`,rotation:t.rotation},parent:this.parent,elements:[this]});this.parent.div.append(e.render())}render(){unreachable("Abstract method `AnnotationElement.render` called")}_getElementsByName(t,e=null){const i=[];if(this._fieldObjects){const s=this._fieldObjects[t];if(s)for(const{page:t,id:n,exportValues:a}of s){if(-1===t)continue;if(n===e)continue;const s="string"==typeof a?a:null,r=document.querySelector(`[data-element-id="${n}"]`);!r||Ht.has(r)?i.push({id:n,exportValue:s,domElement:r}):warn(`_getElementsByName - element not allowed: ${n}`)}return i}for(const s of document.getElementsByName(t)){const{exportValue:t}=s,n=s.getAttribute("data-element-id");n!==e&&(Ht.has(s)&&i.push({id:n,exportValue:t,domElement:s}))}return i}show(){this.container&&(this.container.hidden=!1);this.popup?.maybeShow()}hide(){this.container&&(this.container.hidden=!0);this.popup?.forceHide()}getElementsToTriggerPopup(){return this.container}addHighlightArea(){const t=this.getElementsToTriggerPopup();if(Array.isArray(t))for(const e of t)e.classList.add("highlightArea");else t.classList.add("highlightArea")}_editOnDoubleClick(){if(!this._isEditable)return;const{annotationEditorType:t,data:{id:e}}=this;this.container.addEventListener("dblclick",(()=>{this.linkService.eventBus?.dispatch("switchannotationeditormode",{source:this,mode:t,editId:e})}))}}class LinkAnnotationElement extends AnnotationElement{constructor(t,e=null){super(t,{isRenderable:!0,ignoreBorder:!!e?.ignoreBorder,createQuadrilaterals:!0});this.isTooltipOnly=t.data.isTooltipOnly}render(){const{data:t,linkService:e}=this,i=document.createElement("a");i.setAttribute("data-element-id",t.id);let s=!1;if(t.url){e.addLinkAttributes(i,t.url,t.newWindow);s=!0}else if(t.action){this._bindNamedAction(i,t.action);s=!0}else if(t.attachment){this.#$s(i,t.attachment,t.attachmentDest);s=!0}else if(t.setOCGState){this.#Vs(i,t.setOCGState);s=!0}else if(t.dest){this._bindLink(i,t.dest);s=!0}else{if(t.actions&&(t.actions.Action||t.actions["Mouse Up"]||t.actions["Mouse Down"])&&this.enableScripting&&this.hasJSActions){this._bindJSAction(i,t);s=!0}if(t.resetForm){this._bindResetFormAction(i,t.resetForm);s=!0}else if(this.isTooltipOnly&&!s){this._bindLink(i,"");s=!0}}this.container.classList.add("linkAnnotation");s&&this.container.append(i);return this.container}#js(){this.container.setAttribute("data-internal-link","")}_bindLink(t,e){t.href=this.linkService.getDestinationHash(e);t.onclick=()=>{e&&this.linkService.goToDestination(e);return!1};(e||""===e)&&this.#js()}_bindNamedAction(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeNamedAction(e);return!1};this.#js()}#$s(t,e,i=null){t.href=this.linkService.getAnchorUrl("");e.description&&(t.title=e.description);t.onclick=()=>{this.downloadManager?.openOrDownloadData(e.content,e.filename,i);return!1};this.#js()}#Vs(t,e){t.href=this.linkService.getAnchorUrl("");t.onclick=()=>{this.linkService.executeSetOCGState(e);return!1};this.#js()}_bindJSAction(t,e){t.href=this.linkService.getAnchorUrl("");const i=new Map([["Action","onclick"],["Mouse Up","onmouseup"],["Mouse Down","onmousedown"]]);for(const s of Object.keys(e.actions)){const n=i.get(s);n&&(t[n]=()=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e.id,name:s}});return!1})}t.onclick||(t.onclick=()=>!1);this.#js()}_bindResetFormAction(t,e){const i=t.onclick;i||(t.href=this.linkService.getAnchorUrl(""));this.#js();if(this._fieldObjects)t.onclick=()=>{i?.();const{fields:t,refs:s,include:n}=e,a=[];if(0!==t.length||0!==s.length){const e=new Set(s);for(const i of t){const t=this._fieldObjects[i]||[];for(const{id:i}of t)e.add(i)}for(const t of Object.values(this._fieldObjects))for(const i of t)e.has(i.id)===n&&a.push(i)}else for(const t of Object.values(this._fieldObjects))a.push(...t);const r=this.annotationStorage,o=[];for(const t of a){const{id:e}=t;o.push(e);switch(t.type){case"text":{const i=t.defaultValue||"";r.setValue(e,{value:i});break}case"checkbox":case"radiobutton":{const i=t.defaultValue===t.exportValues;r.setValue(e,{value:i});break}case"combobox":case"listbox":{const i=t.defaultValue||"";r.setValue(e,{value:i});break}default:continue}const i=document.querySelector(`[data-element-id="${e}"]`);i&&(Ht.has(i)?i.dispatchEvent(new Event("resetform")):warn(`_bindResetFormAction - element not allowed: ${e}`))}this.enableScripting&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:"app",ids:o,name:"ResetForm"}});return!1};else{warn('_bindResetFormAction - "resetForm" action not supported, ensure that the `fieldObjects` parameter is provided.');i||(t.onclick=()=>!1)}}}class TextAnnotationElement extends AnnotationElement{constructor(t){super(t,{isRenderable:!0})}render(){this.container.classList.add("textAnnotation");const t=document.createElement("img");t.src=this.imageResourcesPath+"annotation-"+this.data.name.toLowerCase()+".svg";t.setAttribute("data-l10n-id","pdfjs-text-annotation-type");t.setAttribute("data-l10n-args",JSON.stringify({type:this.data.name}));!this.data.popupRef&&this.hasPopupData&&this._createPopup();this.container.append(t);return this.container}}class WidgetAnnotationElement extends AnnotationElement{render(){return this.container}showElementAndHideCanvas(t){if(this.data.hasOwnCanvas){"CANVAS"===t.previousSibling?.nodeName&&(t.previousSibling.hidden=!0);t.hidden=!1}}_getKeyModifier(t){return util_FeatureTest.platform.isMac?t.metaKey:t.ctrlKey}_setEventListener(t,e,i,s,n){i.includes("mouse")?t.addEventListener(i,(t=>{this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t),shift:t.shiftKey,modifier:this._getKeyModifier(t)}})})):t.addEventListener(i,(t=>{if("blur"===i){if(!e.focused||!t.relatedTarget)return;e.focused=!1}else if("focus"===i){if(e.focused)return;e.focused=!0}n&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:this.data.id,name:s,value:n(t)}})}))}_setEventListeners(t,e,i,s){for(const[n,a]of i)if("Action"===a||this.data.actions?.[a]){"Focus"!==a&&"Blur"!==a||(e||={focused:!1});this._setEventListener(t,e,n,a,s);"Focus"!==a||this.data.actions?.Blur?"Blur"!==a||this.data.actions?.Focus||this._setEventListener(t,e,"focus","Focus",null):this._setEventListener(t,e,"blur","Blur",null)}}_setBackgroundColor(t){const e=this.data.backgroundColor||null;t.style.backgroundColor=null===e?"transparent":Util.makeHexColor(e[0],e[1],e[2])}_setTextStyle(t){const e=["left","center","right"],{fontColor:i}=this.data.defaultAppearanceData,s=this.data.defaultAppearanceData.fontSize||9,a=t.style;let r;const roundToOneDecimal=t=>Math.round(10*t)/10;if(this.data.multiLine){const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2),e=t/(Math.round(t/(n*s))||1);r=Math.min(s,roundToOneDecimal(e/n))}else{const t=Math.abs(this.data.rect[3]-this.data.rect[1]-2);r=Math.min(s,roundToOneDecimal(t/n))}a.fontSize=`calc(${r}px * var(--scale-factor))`;a.color=Util.makeHexColor(i[0],i[1],i[2]);null!==this.data.textAlignment&&(a.textAlign=e[this.data.textAlignment])}_setRequired(t,e){e?t.setAttribute("required",!0):t.removeAttribute("required");t.setAttribute("aria-required",e)}}class TextWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms||t.data.hasOwnCanvas||!t.data.hasAppearance&&!!t.data.fieldValue})}setPropertyOnSiblings(t,e,i,s){const n=this.annotationStorage;for(const a of this._getElementsByName(t.name,t.id)){a.domElement&&(a.domElement[e]=i);n.setValue(a.id,{[s]:i})}}render(){const t=this.annotationStorage,e=this.data.id;this.container.classList.add("textWidgetAnnotation");let i=null;if(this.renderForms){const s=t.getValue(e,{value:this.data.fieldValue});let n=s.value||"";const a=t.getValue(e,{charLimit:this.data.maxLen}).charLimit;a&&n.length>a&&(n=n.slice(0,a));let r=s.formattedValue||this.data.textContent?.join("\n")||null;r&&this.data.comb&&(r=r.replaceAll(/\s+/g,""));const o={userValue:n,formattedValue:r,lastCommittedValue:null,commitKey:1,focused:!1};if(this.data.multiLine){i=document.createElement("textarea");i.textContent=r??n;this.data.doNotScroll&&(i.style.overflowY="hidden")}else{i=document.createElement("input");i.type="text";i.setAttribute("value",r??n);this.data.doNotScroll&&(i.style.overflowX="hidden")}this.data.hasOwnCanvas&&(i.hidden=!0);Ht.add(i);i.setAttribute("data-element-id",e);i.disabled=this.data.readOnly;i.name=this.data.fieldName;i.tabIndex=Bt;this._setRequired(i,this.data.required);a&&(i.maxLength=a);i.addEventListener("input",(s=>{t.setValue(e,{value:s.target.value});this.setPropertyOnSiblings(i,"value",s.target.value,"value");o.formattedValue=null}));i.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue??"";i.value=o.userValue=e;o.formattedValue=null}));let blurListener=t=>{const{formattedValue:e}=o;null!=e&&(t.target.value=e);t.target.scrollLeft=0};if(this.enableScripting&&this.hasJSActions){i.addEventListener("focus",(t=>{if(o.focused)return;const{target:e}=t;o.userValue&&(e.value=o.userValue);o.lastCommittedValue=e.value;o.commitKey=1;this.data.actions?.Focus||(o.focused=!0)}));i.addEventListener("updatefromsandbox",(i=>{this.showElementAndHideCanvas(i.target);const s={value(i){o.userValue=i.detail.value??"";t.setValue(e,{value:o.userValue.toString()});i.target.value=o.userValue},formattedValue(i){const{formattedValue:s}=i.detail;o.formattedValue=s;null!=s&&i.target!==document.activeElement&&(i.target.value=s);t.setValue(e,{formattedValue:s})},selRange(t){t.target.setSelectionRange(...t.detail.selRange)},charLimit:i=>{const{charLimit:s}=i.detail,{target:n}=i;if(0===s){n.removeAttribute("maxLength");return}n.setAttribute("maxLength",s);let a=o.userValue;if(a&&!(a.length<=s)){a=a.slice(0,s);n.value=o.userValue=a;t.setValue(e,{value:a});this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:a,willCommit:!0,commitKey:1,selStart:n.selectionStart,selEnd:n.selectionEnd}})}}};this._dispatchEventFromSandbox(s,i)}));i.addEventListener("keydown",(t=>{o.commitKey=1;let i=-1;"Escape"===t.key?i=0:"Enter"!==t.key||this.data.multiLine?"Tab"===t.key&&(o.commitKey=3):i=2;if(-1===i)return;const{value:s}=t.target;if(o.lastCommittedValue!==s){o.lastCommittedValue=s;o.userValue=s;this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:s,willCommit:!0,commitKey:i,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}})}}));const s=blurListener;blurListener=null;i.addEventListener("blur",(t=>{if(!o.focused||!t.relatedTarget)return;this.data.actions?.Blur||(o.focused=!1);const{value:i}=t.target;o.userValue=i;o.lastCommittedValue!==i&&this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:i,willCommit:!0,commitKey:o.commitKey,selStart:t.target.selectionStart,selEnd:t.target.selectionEnd}});s(t)}));this.data.actions?.Keystroke&&i.addEventListener("beforeinput",(t=>{o.lastCommittedValue=null;const{data:i,target:s}=t,{value:n,selectionStart:a,selectionEnd:r}=s;let l=a,h=r;switch(t.inputType){case"deleteWordBackward":{const t=n.substring(0,a).match(/\w*[^\w]*$/);t&&(l-=t[0].length);break}case"deleteWordForward":{const t=n.substring(a).match(/^[^\w]*\w*/);t&&(h+=t[0].length);break}case"deleteContentBackward":a===r&&(l-=1);break;case"deleteContentForward":a===r&&(h+=1)}t.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:n,change:i||"",willCommit:!1,selStart:l,selEnd:h}})}));this._setEventListeners(i,o,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.value))}blurListener&&i.addEventListener("blur",blurListener);if(this.data.comb){const t=(this.data.rect[2]-this.data.rect[0])/a;i.classList.add("comb");i.style.letterSpacing=`calc(${t}px * var(--scale-factor) - 1ch)`}}else{i=document.createElement("div");i.textContent=this.data.fieldValue;i.style.verticalAlign="middle";i.style.display="table-cell";this.data.hasOwnCanvas&&(i.hidden=!0)}this._setTextStyle(i);this._setBackgroundColor(i);this._setDefaultPropertiesFromJS(i);this.container.append(i);return this.container}}class SignatureWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:!!t.data.hasOwnCanvas})}}class CheckboxWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){const t=this.annotationStorage,e=this.data,i=e.id;let s=t.getValue(i,{value:e.exportValue===e.fieldValue}).value;if("string"==typeof s){s="Off"!==s;t.setValue(i,{value:s})}this.container.classList.add("buttonWidgetAnnotation","checkBox");const n=document.createElement("input");Ht.add(n);n.setAttribute("data-element-id",i);n.disabled=e.readOnly;this._setRequired(n,this.data.required);n.type="checkbox";n.name=e.fieldName;s&&n.setAttribute("checked",!0);n.setAttribute("exportValue",e.exportValue);n.tabIndex=Bt;n.addEventListener("change",(s=>{const{name:n,checked:a}=s.target;for(const s of this._getElementsByName(n,i)){const i=a&&s.exportValue===e.exportValue;s.domElement&&(s.domElement.checked=i);t.setValue(s.id,{value:i})}t.setValue(i,{value:a})}));n.addEventListener("resetform",(t=>{const i=e.defaultFieldValue||"Off";t.target.checked=i===e.exportValue}));if(this.enableScripting&&this.hasJSActions){n.addEventListener("updatefromsandbox",(e=>{const s={value(e){e.target.checked="Off"!==e.detail.value;t.setValue(i,{value:e.target.checked})}};this._dispatchEventFromSandbox(s,e)}));this._setEventListeners(n,null,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(n);this._setDefaultPropertiesFromJS(n);this.container.append(n);return this.container}}class RadioButtonWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.classList.add("buttonWidgetAnnotation","radioButton");const t=this.annotationStorage,e=this.data,i=e.id;let s=t.getValue(i,{value:e.fieldValue===e.buttonValue}).value;if("string"==typeof s){s=s!==e.buttonValue;t.setValue(i,{value:s})}if(s)for(const s of this._getElementsByName(e.fieldName,i))t.setValue(s.id,{value:!1});const n=document.createElement("input");Ht.add(n);n.setAttribute("data-element-id",i);n.disabled=e.readOnly;this._setRequired(n,this.data.required);n.type="radio";n.name=e.fieldName;s&&n.setAttribute("checked",!0);n.tabIndex=Bt;n.addEventListener("change",(e=>{const{name:s,checked:n}=e.target;for(const e of this._getElementsByName(s,i))t.setValue(e.id,{value:!1});t.setValue(i,{value:n})}));n.addEventListener("resetform",(t=>{const i=e.defaultFieldValue;t.target.checked=null!=i&&i===e.buttonValue}));if(this.enableScripting&&this.hasJSActions){const s=e.buttonValue;n.addEventListener("updatefromsandbox",(e=>{const n={value:e=>{const n=s===e.detail.value;for(const s of this._getElementsByName(e.target.name)){const e=n&&s.id===i;s.domElement&&(s.domElement.checked=e);t.setValue(s.id,{value:e})}}};this._dispatchEventFromSandbox(n,e)}));this._setEventListeners(n,null,[["change","Validate"],["change","Action"],["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"]],(t=>t.target.checked))}this._setBackgroundColor(n);this._setDefaultPropertiesFromJS(n);this.container.append(n);return this.container}}class PushButtonWidgetAnnotationElement extends LinkAnnotationElement{constructor(t){super(t,{ignoreBorder:t.data.hasAppearance})}render(){const t=super.render();t.classList.add("buttonWidgetAnnotation","pushButton");const e=t.lastChild;if(this.enableScripting&&this.hasJSActions&&e){this._setDefaultPropertiesFromJS(e);e.addEventListener("updatefromsandbox",(t=>{this._dispatchEventFromSandbox({},t)}))}return t}}class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement{constructor(t){super(t,{isRenderable:t.renderForms})}render(){this.container.classList.add("choiceWidgetAnnotation");const t=this.annotationStorage,e=this.data.id,i=t.getValue(e,{value:this.data.fieldValue}),s=document.createElement("select");Ht.add(s);s.setAttribute("data-element-id",e);s.disabled=this.data.readOnly;this._setRequired(s,this.data.required);s.name=this.data.fieldName;s.tabIndex=Bt;let n=this.data.combo&&this.data.options.length>0;if(!this.data.combo){s.size=this.data.options.length;this.data.multiSelect&&(s.multiple=!0)}s.addEventListener("resetform",(t=>{const e=this.data.defaultFieldValue;for(const t of s.options)t.selected=t.value===e}));for(const t of this.data.options){const e=document.createElement("option");e.textContent=t.displayValue;e.value=t.exportValue;if(i.value.includes(t.exportValue)){e.setAttribute("selected",!0);n=!1}s.append(e)}let a=null;if(n){const t=document.createElement("option");t.value=" ";t.setAttribute("hidden",!0);t.setAttribute("selected",!0);s.prepend(t);a=()=>{t.remove();s.removeEventListener("input",a);a=null};s.addEventListener("input",a)}const getValue=t=>{const e=t?"value":"textContent",{options:i,multiple:n}=s;return n?Array.prototype.filter.call(i,(t=>t.selected)).map((t=>t[e])):-1===i.selectedIndex?null:i[i.selectedIndex][e]};let r=getValue(!1);const getItems=t=>{const e=t.target.options;return Array.prototype.map.call(e,(t=>({displayValue:t.textContent,exportValue:t.value})))};if(this.enableScripting&&this.hasJSActions){s.addEventListener("updatefromsandbox",(i=>{const n={value(i){a?.();const n=i.detail.value,o=new Set(Array.isArray(n)?n:[n]);for(const t of s.options)t.selected=o.has(t.value);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},multipleSelection(t){s.multiple=!0},remove(i){const n=s.options,a=i.detail.remove;n[a].selected=!1;s.remove(a);if(n.length>0){-1===Array.prototype.findIndex.call(n,(t=>t.selected))&&(n[0].selected=!0)}t.setValue(e,{value:getValue(!0),items:getItems(i)});r=getValue(!1)},clear(i){for(;0!==s.length;)s.remove(0);t.setValue(e,{value:null,items:[]});r=getValue(!1)},insert(i){const{index:n,displayValue:a,exportValue:o}=i.detail.insert,l=s.children[n],h=document.createElement("option");h.textContent=a;h.value=o;l?l.before(h):s.append(h);t.setValue(e,{value:getValue(!0),items:getItems(i)});r=getValue(!1)},items(i){const{items:n}=i.detail;for(;0!==s.length;)s.remove(0);for(const t of n){const{displayValue:e,exportValue:i}=t,n=document.createElement("option");n.textContent=e;n.value=i;s.append(n)}s.options.length>0&&(s.options[0].selected=!0);t.setValue(e,{value:getValue(!0),items:getItems(i)});r=getValue(!1)},indices(i){const s=new Set(i.detail.indices);for(const t of i.target.options)t.selected=s.has(t.index);t.setValue(e,{value:getValue(!0)});r=getValue(!1)},editable(t){t.target.disabled=!t.detail.editable}};this._dispatchEventFromSandbox(n,i)}));s.addEventListener("input",(i=>{const s=getValue(!0),n=getValue(!1);t.setValue(e,{value:s});i.preventDefault();this.linkService.eventBus?.dispatch("dispatcheventinsandbox",{source:this,detail:{id:e,name:"Keystroke",value:r,change:n,changeEx:s,willCommit:!1,commitKey:1,keyDown:!1}})}));this._setEventListeners(s,null,[["focus","Focus"],["blur","Blur"],["mousedown","Mouse Down"],["mouseenter","Mouse Enter"],["mouseleave","Mouse Exit"],["mouseup","Mouse Up"],["input","Action"],["input","Validate"]],(t=>t.target.value))}else s.addEventListener("input",(function(i){t.setValue(e,{value:getValue(!0)})}));this.data.combo&&this._setTextStyle(s);this._setBackgroundColor(s);this._setDefaultPropertiesFromJS(s);this.container.append(s);return this.container}}class PopupAnnotationElement extends AnnotationElement{constructor(t){const{data:e,elements:i}=t;super(t,{isRenderable:AnnotationElement._hasPopupData(e)});this.elements=i;this.popup=null}render(){this.container.classList.add("popupAnnotation");const t=this.popup=new PopupElement({container:this.container,color:this.data.color,titleObj:this.data.titleObj,modificationDate:this.data.modificationDate,contentsObj:this.data.contentsObj,richText:this.data.richText,rect:this.data.rect,parentRect:this.data.parentRect||null,parent:this.parent,elements:this.elements,open:this.data.open}),e=[];for(const i of this.elements){i.popup=t;i.container.ariaHasPopup="dialog";e.push(i.data.id);i.addHighlightArea()}this.container.setAttribute("aria-controls",e.map((t=>`${et}${t}`)).join(","));return this.container}}class PopupElement{#Ws=this.#qs.bind(this);#Xs=this.#Ks.bind(this);#Ys=this.#Qs.bind(this);#Js=this.#Zs.bind(this);#tn=null;#pt=null;#en=null;#in=null;#sn=null;#nn=null;#an=null;#rn=!1;#on=null;#C=null;#ln=null;#hn=null;#dn=null;#Hs=null;#cn=!1;constructor({container:t,color:e,elements:i,titleObj:s,modificationDate:n,contentsObj:a,richText:r,parent:o,rect:l,parentRect:h,open:d}){this.#pt=t;this.#dn=s;this.#en=a;this.#hn=r;this.#nn=o;this.#tn=e;this.#ln=l;this.#an=h;this.#sn=i;this.#in=PDFDateString.toDateObject(n);this.trigger=i.flatMap((t=>t.getElementsToTriggerPopup()));for(const t of this.trigger){t.addEventListener("click",this.#Js);t.addEventListener("mouseenter",this.#Ys);t.addEventListener("mouseleave",this.#Xs);t.classList.add("popupTriggerArea")}for(const t of i)t.container?.addEventListener("keydown",this.#Ws);this.#pt.hidden=!0;d&&this.#Zs()}render(){if(this.#on)return;const t=this.#on=document.createElement("div");t.className="popup";if(this.#tn){const e=t.style.outlineColor=Util.makeHexColor(...this.#tn);if(CSS.supports("background-color","color-mix(in srgb, red 30%, white)"))t.style.backgroundColor=`color-mix(in srgb, ${e} 30%, white)`;else{const e=.7;t.style.backgroundColor=Util.makeHexColor(...this.#tn.map((t=>Math.floor(e*(255-t)+t))))}}const e=document.createElement("span");e.className="header";const i=document.createElement("h1");e.append(i);({dir:i.dir,str:i.textContent}=this.#dn);t.append(e);if(this.#in){const t=document.createElement("span");t.classList.add("popupDate");t.setAttribute("data-l10n-id","pdfjs-annotation-date-time-string");t.setAttribute("data-l10n-args",JSON.stringify({dateObj:this.#in.valueOf()}));e.append(t)}const s=this.#un;if(s){XfaLayer.render({xfaHtml:s,intent:"richText",div:t});t.lastChild.classList.add("richText","popupContent")}else{const e=this._formatContents(this.#en);t.append(e)}this.#pt.append(t)}get#un(){const t=this.#hn,e=this.#en;return!t?.str||e?.str&&e.str!==t.str?null:this.#hn.html||null}get#pn(){return this.#un?.attributes?.style?.fontSize||0}get#gn(){return this.#un?.attributes?.style?.color||null}#mn(t){const e=[],i={str:t,html:{name:"div",attributes:{dir:"auto"},children:[{name:"p",children:e}]}},s={style:{color:this.#gn,fontSize:this.#pn?`calc(${this.#pn}px * var(--scale-factor))`:""}};for(const i of t.split("\n"))e.push({name:"span",value:i,attributes:s});return i}_formatContents({str:t,dir:e}){const i=document.createElement("p");i.classList.add("popupContent");i.dir=e;const s=t.split(/(?:\r\n?|\n)/);for(let t=0,e=s.length;t=0&&n.setAttribute("stroke-width",e||1);if(i)for(let t=0,e=this.#xn.length;t{"Enter"===t.key&&(s?t.metaKey:t.ctrlKey)&&this.#Sn()}));!e.popupRef&&this.hasPopupData?this._createPopup():i.classList.add("popupTriggerArea");t.append(i);return t}getElementsToTriggerPopup(){return this.#En}addHighlightArea(){this.container.classList.add("highlightArea")}#Sn(){this.downloadManager?.openOrDownloadData(this.content,this.filename)}}class AnnotationLayer{#Cn=null;#Tn=null;#Mn=new Map;#Pn=null;constructor({div:t,accessibilityManager:e,annotationCanvasMap:i,annotationEditorUIManager:s,page:n,viewport:a,structTreeLayer:r}){this.div=t;this.#Cn=e;this.#Tn=i;this.#Pn=r||null;this.page=n;this.viewport=a;this.zIndex=0;this._annotationEditorUIManager=s}hasEditableAnnotations(){return this.#Mn.size>0}async#Dn(t,e){const i=t.firstChild||t,s=i.id=`${et}${e}`,n=await(this.#Pn?.getAriaAttributes(s));if(n)for(const[t,e]of n)i.setAttribute(t,e);this.div.append(t);this.#Cn?.moveElementInDOM(this.div,t,i,!1)}async render(t){const{annotations:e}=t,i=this.div;setLayerDimensions(i,this.viewport);const s=new Map,n={data:null,layer:i,linkService:t.linkService,downloadManager:t.downloadManager,imageResourcesPath:t.imageResourcesPath||"",renderForms:!1!==t.renderForms,svgFactory:new DOMSVGFactory,annotationStorage:t.annotationStorage||new AnnotationStorage,enableScripting:!0===t.enableScripting,hasJSActions:t.hasJSActions,fieldObjects:t.fieldObjects,parent:this,elements:null};for(const t of e){if(t.noHTML)continue;const e=t.annotationType===H;if(e){const e=s.get(t.id);if(!e)continue;n.elements=e}else{const{width:e,height:i}=getRectDims(t.rect);if(e<=0||i<=0)continue}n.data=t;const i=AnnotationElementFactory.create(n);if(!i.isRenderable)continue;if(!e&&t.popupRef){const e=s.get(t.popupRef);e?e.push(i):s.set(t.popupRef,[i])}const a=i.render();t.hidden&&(a.style.visibility="hidden");await this.#Dn(a,t.id);if(i._isEditable){this.#Mn.set(i.data.id,i);this._annotationEditorUIManager?.renderAnnotationElement(i)}}this.#kn()}update({viewport:t}){const e=this.div;this.viewport=t;setLayerDimensions(e,{rotation:t.rotation});this.#kn();e.hidden=!1}#kn(){if(!this.#Tn)return;const t=this.div;for(const[e,i]of this.#Tn){const s=t.querySelector(`[data-annotation-id="${e}"]`);if(!s)continue;i.className="annotationContent";const{firstChild:n}=s;n?"CANVAS"===n.nodeName?n.replaceWith(i):n.classList.contains("annotationContent")?n.after(i):n.before(i):s.append(i)}this.#Tn.clear()}getEditableAnnotations(){return Array.from(this.#Mn.values())}getEditableAnnotation(t){return this.#Mn.get(t)}}const zt=/\r\n?|\n/g;class FreeTextEditor extends AnnotationEditor{#tn;#Rn="";#In=`${this.id}-editor`;#Fn=null;#pn;static _freeTextDefaultContent="";static _internalPadding=0;static _defaultColor=null;static _defaultFontSize=10;static get _keyboardManager(){const t=FreeTextEditor.prototype,arrowChecker=t=>t.isEmpty(),e=AnnotationEditorUIManager.TRANSLATE_SMALL,i=AnnotationEditorUIManager.TRANSLATE_BIG;return shadow(this,"_keyboardManager",new KeyboardManager([[["ctrl+s","mac+meta+s","ctrl+p","mac+meta+p"],t.commitOrRemove,{bubbles:!0}],[["ctrl+Enter","mac+meta+Enter","Escape","mac+Escape"],t.commitOrRemove],[["ArrowLeft","mac+ArrowLeft"],t._translateEmpty,{args:[-e,0],checker:arrowChecker}],[["ctrl+ArrowLeft","mac+shift+ArrowLeft"],t._translateEmpty,{args:[-i,0],checker:arrowChecker}],[["ArrowRight","mac+ArrowRight"],t._translateEmpty,{args:[e,0],checker:arrowChecker}],[["ctrl+ArrowRight","mac+shift+ArrowRight"],t._translateEmpty,{args:[i,0],checker:arrowChecker}],[["ArrowUp","mac+ArrowUp"],t._translateEmpty,{args:[0,-e],checker:arrowChecker}],[["ctrl+ArrowUp","mac+shift+ArrowUp"],t._translateEmpty,{args:[0,-i],checker:arrowChecker}],[["ArrowDown","mac+ArrowDown"],t._translateEmpty,{args:[0,e],checker:arrowChecker}],[["ctrl+ArrowDown","mac+shift+ArrowDown"],t._translateEmpty,{args:[0,i],checker:arrowChecker}]]))}static _type="freetext";static _editorType=g.FREETEXT;constructor(t){super({...t,name:"freeTextEditor"});this.#tn=t.color||FreeTextEditor._defaultColor||AnnotationEditor._defaultLineColor;this.#pn=t.fontSize||FreeTextEditor._defaultFontSize}static initialize(t,e){AnnotationEditor.initialize(t,e);const i=getComputedStyle(document.documentElement);this._internalPadding=parseFloat(i.getPropertyValue("--freetext-padding"))}static updateDefaultParams(t,e){switch(t){case m.FREETEXT_SIZE:FreeTextEditor._defaultFontSize=e;break;case m.FREETEXT_COLOR:FreeTextEditor._defaultColor=e}}updateParams(t,e){switch(t){case m.FREETEXT_SIZE:this.#Ln(e);break;case m.FREETEXT_COLOR:this.#On(e)}}static get defaultPropertiesToUpdate(){return[[m.FREETEXT_SIZE,FreeTextEditor._defaultFontSize],[m.FREETEXT_COLOR,FreeTextEditor._defaultColor||AnnotationEditor._defaultLineColor]]}get propertiesToUpdate(){return[[m.FREETEXT_SIZE,this.#pn],[m.FREETEXT_COLOR,this.#tn]]}#Ln(t){const setFontsize=t=>{this.editorDiv.style.fontSize=`calc(${t}px * var(--scale-factor))`;this.translate(0,-(t-this.#pn)*this.parentScale);this.#pn=t;this.#Nn()},e=this.#pn;this.addCommands({cmd:setFontsize.bind(this,t),undo:setFontsize.bind(this,e),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.FREETEXT_SIZE,overwriteIfSameType:!0,keepUndo:!0})}#On(t){const setColor=t=>{this.#tn=this.editorDiv.style.color=t},e=this.#tn;this.addCommands({cmd:setColor.bind(this,t),undo:setColor.bind(this,e),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.FREETEXT_COLOR,overwriteIfSameType:!0,keepUndo:!0})}_translateEmpty(t,e){this._uiManager.translateSelectedEditors(t,e,!0)}getInitialTranslation(){const t=this.parentScale;return[-FreeTextEditor._internalPadding*t,-(FreeTextEditor._internalPadding+this.#pn)*t]}rebuild(){if(this.parent){super.rebuild();null!==this.div&&(this.isAttachedToDOM||this.parent.add(this))}}enableEditMode(){if(this.isInEditMode())return;this.parent.setEditingState(!1);this.parent.updateToolbar(g.FREETEXT);super.enableEditMode();this.overlayDiv.classList.remove("enabled");this.editorDiv.contentEditable=!0;this._isDraggable=!1;this.div.removeAttribute("aria-activedescendant");this.#Fn=new AbortController;const t=this._uiManager.combinedSignal(this.#Fn);this.editorDiv.addEventListener("keydown",this.editorDivKeydown.bind(this),{signal:t});this.editorDiv.addEventListener("focus",this.editorDivFocus.bind(this),{signal:t});this.editorDiv.addEventListener("blur",this.editorDivBlur.bind(this),{signal:t});this.editorDiv.addEventListener("input",this.editorDivInput.bind(this),{signal:t});this.editorDiv.addEventListener("paste",this.editorDivPaste.bind(this),{signal:t})}disableEditMode(){if(this.isInEditMode()){this.parent.setEditingState(!0);super.disableEditMode();this.overlayDiv.classList.add("enabled");this.editorDiv.contentEditable=!1;this.div.setAttribute("aria-activedescendant",this.#In);this._isDraggable=!0;this.#Fn?.abort();this.#Fn=null;this.div.focus({preventScroll:!0});this.isEditing=!1;this.parent.div.classList.add("freetextEditing")}}focusin(t){if(this._focusEventsAllowed){super.focusin(t);t.target!==this.editorDiv&&this.editorDiv.focus()}}onceAdded(t){if(!this.width){this.enableEditMode();t&&this.editorDiv.focus();this._initialOptions?.isCentered&&this.center();this._initialOptions=null}}isEmpty(){return!this.editorDiv||""===this.editorDiv.innerText.trim()}remove(){this.isEditing=!1;if(this.parent){this.parent.setEditingState(!0);this.parent.div.classList.add("freetextEditing")}super.remove()}#Bn(){const t=[];this.editorDiv.normalize();let e=null;for(const i of this.editorDiv.childNodes)if(e?.nodeType!==Node.TEXT_NODE||"BR"!==i.nodeName){t.push(FreeTextEditor.#Hn(i));e=i}return t.join("\n")}#Nn(){const[t,e]=this.parentDimensions;let i;if(this.isAttachedToDOM)i=this.div.getBoundingClientRect();else{const{currentLayer:t,div:e}=this,s=e.style.display,n=e.classList.contains("hidden");e.classList.remove("hidden");e.style.display="hidden";t.div.append(this.div);i=e.getBoundingClientRect();e.remove();e.style.display=s;e.classList.toggle("hidden",n)}if(this.rotation%180==this.parentRotation%180){this.width=i.width/t;this.height=i.height/e}else{this.width=i.height/t;this.height=i.width/e}this.fixAndSetPosition()}commit(){if(!this.isInEditMode())return;super.commit();this.disableEditMode();const t=this.#Rn,e=this.#Rn=this.#Bn().trimEnd();if(t===e)return;const setText=t=>{this.#Rn=t;if(t){this.#zn();this._uiManager.rebuild(this);this.#Nn()}else this.remove()};this.addCommands({cmd:()=>{setText(e)},undo:()=>{setText(t)},mustExec:!1});this.#Nn()}shouldGetKeyboardEvents(){return this.isInEditMode()}enterInEditMode(){this.enableEditMode();this.editorDiv.focus()}dblclick(t){this.enterInEditMode()}keydown(t){if(t.target===this.div&&"Enter"===t.key){this.enterInEditMode();t.preventDefault()}}editorDivKeydown(t){FreeTextEditor._keyboardManager.exec(this,t)}editorDivFocus(t){this.isEditing=!0}editorDivBlur(t){this.isEditing=!1}editorDivInput(t){this.parent.div.classList.toggle("freetextEditing",this.isEmpty())}disableEditing(){this.editorDiv.setAttribute("role","comment");this.editorDiv.removeAttribute("aria-multiline")}enableEditing(){this.editorDiv.setAttribute("role","textbox");this.editorDiv.setAttribute("aria-multiline",!0)}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();this.editorDiv=document.createElement("div");this.editorDiv.className="internal";this.editorDiv.setAttribute("id",this.#In);this.editorDiv.setAttribute("data-l10n-id","pdfjs-free-text2");this.editorDiv.setAttribute("data-l10n-attrs","default-content");this.enableEditing();this.editorDiv.contentEditable=!0;const{style:i}=this.editorDiv;i.fontSize=`calc(${this.#pn}px * var(--scale-factor))`;i.color=this.#tn;this.div.append(this.editorDiv);this.overlayDiv=document.createElement("div");this.overlayDiv.classList.add("overlay","enabled");this.div.append(this.overlayDiv);bindEvents(this,this.div,["dblclick","keydown"]);if(this.width){const[i,s]=this.parentDimensions;if(this.annotationElementId){const{position:n}=this._initialData;let[a,r]=this.getInitialTranslation();[a,r]=this.pageTranslationToScreen(a,r);const[o,l]=this.pageDimensions,[h,d]=this.pageTranslation;let c,u;switch(this.rotation){case 0:c=t+(n[0]-h)/o;u=e+this.height-(n[1]-d)/l;break;case 90:c=t+(n[0]-h)/o;u=e-(n[1]-d)/l;[a,r]=[r,-a];break;case 180:c=t-this.width+(n[0]-h)/o;u=e-(n[1]-d)/l;[a,r]=[-a,-r];break;case 270:c=t+(n[0]-h-this.height*l)/o;u=e+(n[1]-d-this.width*o)/l;[a,r]=[-r,a]}this.setAt(c*i,u*s,a,r)}else this.setAt(t*i,e*s,this.width*i,this.height*s);this.#zn();this._isDraggable=!0;this.editorDiv.contentEditable=!1}else{this._isDraggable=!1;this.editorDiv.contentEditable=!0}return this.div}static#Hn(t){return(t.nodeType===Node.TEXT_NODE?t.nodeValue:t.innerText).replaceAll(zt,"")}editorDivPaste(t){const e=t.clipboardData||window.clipboardData,{types:i}=e;if(1===i.length&&"text/plain"===i[0])return;t.preventDefault();const s=FreeTextEditor.#Un(e.getData("text")||"").replaceAll(zt,"\n");if(!s)return;const n=window.getSelection();if(!n.rangeCount)return;this.editorDiv.normalize();n.deleteFromDocument();const a=n.getRangeAt(0);if(!s.includes("\n")){a.insertNode(document.createTextNode(s));this.editorDiv.normalize();n.collapseToStart();return}const{startContainer:r,startOffset:o}=a,l=[],h=[];if(r.nodeType===Node.TEXT_NODE){const t=r.parentElement;h.push(r.nodeValue.slice(o).replaceAll(zt,""));if(t!==this.editorDiv){let e=l;for(const i of this.editorDiv.childNodes)i!==t?e.push(FreeTextEditor.#Hn(i)):e=h}l.push(r.nodeValue.slice(0,o).replaceAll(zt,""))}else if(r===this.editorDiv){let t=l,e=0;for(const i of this.editorDiv.childNodes){e++===o&&(t=h);t.push(FreeTextEditor.#Hn(i))}}this.#Rn=`${l.join("\n")}${s}${h.join("\n")}`;this.#zn();const d=new Range;let c=l.reduce(((t,e)=>t+e.length),0);for(const{firstChild:t}of this.editorDiv.childNodes)if(t.nodeType===Node.TEXT_NODE){const e=t.nodeValue.length;if(c<=e){d.setStart(t,c);d.setEnd(t,c);break}c-=e}n.removeAllRanges();n.addRange(d)}#zn(){this.editorDiv.replaceChildren();if(this.#Rn)for(const t of this.#Rn.split("\n")){const e=document.createElement("div");e.append(t?document.createTextNode(t):document.createElement("br"));this.editorDiv.append(e)}}#Gn(){return this.#Rn.replaceAll(" "," ")}static#Un(t){return t.replaceAll(" "," ")}get contentDiv(){return this.editorDiv}static async deserialize(t,e,i){let s=null;if(t instanceof FreeTextAnnotationElement){const{data:{defaultAppearanceData:{fontSize:e,fontColor:i},rect:n,rotation:a,id:r,popupRef:o},textContent:l,textPosition:h,parent:{page:{pageNumber:d}}}=t;if(!l||0===l.length)return null;s=t={annotationType:g.FREETEXT,color:Array.from(i),fontSize:e,value:l.join("\n"),position:h,pageIndex:d-1,rect:n.slice(0),rotation:a,id:r,deleted:!1,popupRef:o}}const n=await super.deserialize(t,e,i);n.#pn=t.fontSize;n.#tn=Util.makeHexColor(...t.color);n.#Rn=FreeTextEditor.#Un(t.value);n.annotationElementId=t.id||null;n._initialData=s;return n}serialize(t=!1){if(this.isEmpty())return null;if(this.deleted)return this.serializeDeleted();const e=FreeTextEditor._internalPadding*this.parentScale,i=this.getRect(e,e),s=AnnotationEditor._colorManager.convert(this.isAttachedToDOM?getComputedStyle(this.editorDiv).color:this.#tn),n={annotationType:g.FREETEXT,color:s,fontSize:this.#pn,value:this.#Gn(),pageIndex:this.pageIndex,rect:i,rotation:this.rotation,structTreeParentId:this._structTreeParentId};if(t)return n;if(this.annotationElementId&&!this.#$n(n))return null;n.id=this.annotationElementId;return n}#$n(t){const{value:e,fontSize:i,color:s,pageIndex:n}=this._initialData;return this._hasBeenMoved||t.value!==e||t.fontSize!==i||t.color.some(((t,e)=>t!==s[e]))||t.pageIndex!==n}renderAnnotationElement(t){const e=super.renderAnnotationElement(t);if(this.deleted)return e;const{style:i}=e;i.fontSize=`calc(${this.#pn}px * var(--scale-factor))`;i.color=this.#tn;e.replaceChildren();for(const t of this.#Rn.split("\n")){const i=document.createElement("div");i.append(t?document.createTextNode(t):document.createElement("br"));e.append(i)}const s=FreeTextEditor._internalPadding*this.parentScale;t.updateEdited({rect:this.getRect(s,s),popupContent:this.#Rn});return e}resetAnnotationElement(t){super.resetAnnotationElement(t);t.resetEdited()}}class Outline{static PRECISION=1e-4;toSVGPath(){unreachable("Abstract method `toSVGPath` must be implemented.")}get box(){unreachable("Abstract getter `box` must be implemented.")}serialize(t,e){unreachable("Abstract method `serialize` must be implemented.")}static _rescale(t,e,i,s,n,a){a||=new Float32Array(t.length);for(let r=0,o=t.length;r=6;t-=6)isNaN(e[t])?i.push(`L${e[t+4]} ${e[t+5]}`):i.push(`C${e[t]} ${e[t+1]} ${e[t+2]} ${e[t+3]} ${e[t+4]} ${e[t+5]}`);this.#ha(i);return i.join(" ")}#oa(){const[t,e,i,s]=this.#Vn,[n,a,r,o]=this.#ra();return`M${(this.#Kn[2]-t)/i} ${(this.#Kn[3]-e)/s} L${(this.#Kn[4]-t)/i} ${(this.#Kn[5]-e)/s} L${n} ${a} L${r} ${o} L${(this.#Kn[16]-t)/i} ${(this.#Kn[17]-e)/s} L${(this.#Kn[14]-t)/i} ${(this.#Kn[15]-e)/s} Z`}#ha(t){const e=this.#jn;t.push(`L${e[4]} ${e[5]} Z`)}#la(t){const[e,i,s,n]=this.#Vn,a=this.#Kn.subarray(4,6),r=this.#Kn.subarray(16,18),[o,l,h,d]=this.#ra();t.push(`L${(a[0]-e)/s} ${(a[1]-i)/n} L${o} ${l} L${h} ${d} L${(r[0]-e)/s} ${(r[1]-i)/n}`)}newFreeDrawOutline(t,e,i,s,n,a){return new FreeDrawOutline(t,e,i,s,n,a)}getOutlines(){const t=this.#Xn,e=this.#jn,i=this.#Kn,[s,n,a,r]=this.#Vn,o=new Float32Array((this.#ia?.length??0)+2);for(let t=0,e=o.length-2;t=6;t-=6)for(let i=0;i<6;i+=2)if(isNaN(e[t+i])){l[h]=l[h+1]=NaN;h+=2}else{l[h]=e[t+i];l[h+1]=e[t+i+1];h+=2}this.#ua(l,h);return this.newFreeDrawOutline(l,o,this.#Vn,this.#ta,this.#Wn,this.#qn)}#da(t){const e=this.#Kn,[i,s,n,a]=this.#Vn,[r,o,l,h]=this.#ra(),d=new Float32Array(36);d.set([NaN,NaN,NaN,NaN,(e[2]-i)/n,(e[3]-s)/a,NaN,NaN,NaN,NaN,(e[4]-i)/n,(e[5]-s)/a,NaN,NaN,NaN,NaN,r,o,NaN,NaN,NaN,NaN,l,h,NaN,NaN,NaN,NaN,(e[16]-i)/n,(e[17]-s)/a,NaN,NaN,NaN,NaN,(e[14]-i)/n,(e[15]-s)/a],0);return this.newFreeDrawOutline(d,t,this.#Vn,this.#ta,this.#Wn,this.#qn)}#ua(t,e){const i=this.#jn;t.set([NaN,NaN,NaN,NaN,i[4],i[5]],e);return e+6}#ca(t,e){const i=this.#Kn.subarray(4,6),s=this.#Kn.subarray(16,18),[n,a,r,o]=this.#Vn,[l,h,d,c]=this.#ra();t.set([NaN,NaN,NaN,NaN,(i[0]-n)/r,(i[1]-a)/o,NaN,NaN,NaN,NaN,l,h,NaN,NaN,NaN,NaN,d,c,NaN,NaN,NaN,NaN,(s[0]-n)/r,(s[1]-a)/o],e);return e+24}}class FreeDrawOutline extends Outline{#Vn;#pa=new Float32Array(4);#Wn;#qn;#ia;#ta;#ga;constructor(t,e,i,s,n,a){super();this.#ga=t;this.#ia=e;this.#Vn=i;this.#ta=s;this.#Wn=n;this.#qn=a;this.lastPoint=[NaN,NaN];this.#ma(a);const[r,o,l,h]=this.#pa;for(let e=0,i=t.length;et[0]-e[0]||t[1]-e[1]||t[2]-e[2]));const t=[];for(const e of this.#ba)if(e[3]){t.push(...this.#wa(e));this.#va(e)}else{this.#ya(e);t.push(...this.#wa(e))}return this.#xa(t)}#xa(t){const e=[],i=new Set;for(const i of t){const[t,s,n]=i;e.push([t,s,i],[t,n,i])}e.sort(((t,e)=>t[1]-e[1]||t[0]-e[0]));for(let t=0,s=e.length;t0;){const t=i.values().next().value;let[e,a,r,o,l]=t;i.delete(t);let h=e,d=a;n=[e,r];s.push(n);for(;;){let t;if(i.has(o))t=o;else{if(!i.has(l))break;t=l}i.delete(t);[e,a,r,o,l]=t;if(h!==e){n.push(h,d,e,d===a?a:r);h=e}d=d===a?r:a}n.push(h,d)}return new HighlightOutline(s,this.#Vn,this.#fa)}#_a(t){const e=this.#Aa;let i=0,s=e.length-1;for(;i<=s;){const n=i+s>>1,a=e[n][0];if(a===t)return n;a=0;s--){const[i,n]=this.#Aa[s];if(i!==t)break;if(i===t&&n===e){this.#Aa.splice(s,1);return}}}#wa(t){const[e,i,s]=t,n=[[e,i,s]],a=this.#_a(s);for(let t=0;t=i)if(o>s)n[t][1]=s;else{if(1===a)return[];n.splice(t,1);t--;a--}else{n[t][2]=i;o>s&&n.push([e,s,o])}}}return n}}class HighlightOutline extends Outline{#Vn;#Ea;constructor(t,e,i){super();this.#Ea=t;this.#Vn=e;this.lastPoint=i}toSVGPath(){const t=[];for(const e of this.#Ea){let[i,s]=e;t.push(`M${i} ${s}`);for(let n=2;n-1){this.#Xa=!0;this.#Za(t);this.#tr()}else if(this.#Ua){this.#Ha=t.anchorNode;this.#za=t.anchorOffset;this.#Va=t.focusNode;this.#ja=t.focusOffset;this.#er();this.#tr();this.rotate(this.rotation)}}get telemetryInitialData(){return{action:"added",type:this.#Xa?"free_highlight":"highlight",color:this._uiManager.highlightColorNames.get(this.color),thickness:this.#ea,methodOfCreation:this.#Ja}}get telemetryFinalData(){return{type:"highlight",color:this._uiManager.highlightColorNames.get(this.color)}}static computeTelemetryFinalData(t){return{numberOfColors:t.get("color").size}}#er(){const t=new HighlightOutliner(this.#Ua,.001);this.#qa=t.getOutlines();[this.x,this.y,this.width,this.height]=this.#qa.box;const e=new HighlightOutliner(this.#Ua,.0025,.001,"ltr"===this._uiManager.direction);this.#$a=e.getOutlines();const{lastPoint:i}=this.#$a;this.#fa=[(i[0]-this.x)/this.width,(i[1]-this.y)/this.height]}#Za({highlightOutlines:t,highlightId:e,clipPathId:i}){this.#qa=t;this.#$a=t.getNewOutline(this.#ea/2+1.5,.0025);if(e>=0){this.#w=e;this.#Ga=i;this.parent.drawLayer.finalizeDraw(e,{bbox:t.box,path:{d:t.toSVGPath()}});this.#Ya=this.parent.drawLayer.drawOutline({rootClass:{highlightOutline:!0,free:!0},bbox:this.#$a.box,path:{d:this.#$a.toSVGPath()}},!0)}else if(this.parent){const e=this.parent.viewport.rotation;this.parent.drawLayer.updateProperties(this.#w,{bbox:HighlightEditor.#ir(this.#qa.box,(e-this.rotation+360)%360),path:{d:t.toSVGPath()}});this.parent.drawLayer.updateProperties(this.#Ya,{bbox:HighlightEditor.#ir(this.#$a.box,e),path:{d:this.#$a.toSVGPath()}})}const[s,n,a,r]=t.box;switch(this.rotation){case 0:this.x=s;this.y=n;this.width=a;this.height=r;break;case 90:{const[t,e]=this.parentDimensions;this.x=n;this.y=1-s;this.width=a*e/t;this.height=r*t/e;break}case 180:this.x=1-s;this.y=1-n;this.width=a;this.height=r;break;case 270:{const[t,e]=this.parentDimensions;this.x=1-n;this.y=s;this.width=a*e/t;this.height=r*t/e;break}}const{lastPoint:o}=this.#$a;this.#fa=[(o[0]-s)/a,(o[1]-n)/r]}static initialize(t,e){AnnotationEditor.initialize(t,e);HighlightEditor._defaultColor||=e.highlightColors?.values().next().value||"#fff066"}static updateDefaultParams(t,e){switch(t){case m.HIGHLIGHT_DEFAULT_COLOR:HighlightEditor._defaultColor=e;break;case m.HIGHLIGHT_THICKNESS:HighlightEditor._defaultThickness=e}}translateInPage(t,e){}get toolbarPosition(){return this.#fa}updateParams(t,e){switch(t){case m.HIGHLIGHT_COLOR:this.#On(e);break;case m.HIGHLIGHT_THICKNESS:this.#sr(e)}}static get defaultPropertiesToUpdate(){return[[m.HIGHLIGHT_DEFAULT_COLOR,HighlightEditor._defaultColor],[m.HIGHLIGHT_THICKNESS,HighlightEditor._defaultThickness]]}get propertiesToUpdate(){return[[m.HIGHLIGHT_COLOR,this.color||HighlightEditor._defaultColor],[m.HIGHLIGHT_THICKNESS,this.#ea||HighlightEditor._defaultThickness],[m.HIGHLIGHT_FREE,this.#Xa]]}#On(t){const setColorAndOpacity=(t,e)=>{this.color=t;this.#Ka=e;this.parent?.drawLayer.updateProperties(this.#w,{root:{fill:t,"fill-opacity":e}});this.#n?.updateColor(t)},e=this.color,i=this.#Ka;this.addCommands({cmd:setColorAndOpacity.bind(this,t,HighlightEditor._defaultOpacity),undo:setColorAndOpacity.bind(this,e,i),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.HIGHLIGHT_COLOR,overwriteIfSameType:!0,keepUndo:!0});this._reportTelemetry({action:"color_changed",color:this._uiManager.highlightColorNames.get(t)},!0)}#sr(t){const e=this.#ea,setThickness=t=>{this.#ea=t;this.#nr(t)};this.addCommands({cmd:setThickness.bind(this,t),undo:setThickness.bind(this,e),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:m.INK_THICKNESS,overwriteIfSameType:!0,keepUndo:!0});this._reportTelemetry({action:"thickness_changed",thickness:t},!0)}async addEditToolbar(){const t=await super.addEditToolbar();if(!t)return null;if(this._uiManager.highlightColors){this.#n=new ColorPicker({editor:this});t.addColorPicker(this.#n)}return t}disableEditing(){super.disableEditing();this.div.classList.toggle("disabled",!0)}enableEditing(){super.enableEditing();this.div.classList.toggle("disabled",!1)}fixAndSetPosition(){return super.fixAndSetPosition(this.#ar())}getBaseTranslation(){return[0,0]}getRect(t,e){return super.getRect(t,e,this.#ar())}onceAdded(t){this.annotationElementId||this.parent.addUndoableEditor(this);t&&this.div.focus()}remove(){this.#rr();this._reportTelemetry({action:"deleted"});super.remove()}rebuild(){if(this.parent){super.rebuild();if(null!==this.div){this.#tr();this.isAttachedToDOM||this.parent.add(this)}}}setParent(t){let e=!1;if(this.parent&&!t)this.#rr();else if(t){this.#tr(t);e=!this.parent&&this.div?.classList.contains("selectedEditor")}super.setParent(t);this.show(this._isVisible);e&&this.select()}#nr(t){if(!this.#Xa)return;this.#Za({highlightOutlines:this.#qa.getNewOutline(t/2)});this.fixAndSetPosition();const[e,i]=this.parentDimensions;this.setDims(this.width*e,this.height*i)}#rr(){if(null!==this.#w&&this.parent){this.parent.drawLayer.remove(this.#w);this.#w=null;this.parent.drawLayer.remove(this.#Ya);this.#Ya=null}}#tr(t=this.parent){if(null===this.#w){({id:this.#w,clipPathId:this.#Ga}=t.drawLayer.draw({bbox:this.#qa.box,root:{viewBox:"0 0 1 1",fill:this.color,"fill-opacity":this.#Ka},rootClass:{highlight:!0,free:this.#Xa},path:{d:this.#qa.toSVGPath()}},!1,!0));this.#Ya=t.drawLayer.drawOutline({rootClass:{highlightOutline:!0,free:this.#Xa},bbox:this.#$a.box,path:{d:this.#$a.toSVGPath()}},this.#Xa);this.#Wa&&(this.#Wa.style.clipPath=this.#Ga)}}static#ir([t,e,i,s],n){switch(n){case 90:return[1-e-s,t,s,i];case 180:return[1-t-i,1-e-s,i,s];case 270:return[e,1-t-i,s,i]}return[t,e,i,s]}rotate(t){const{drawLayer:e}=this.parent;let i;if(this.#Xa){t=(t-this.rotation+360)%360;i=HighlightEditor.#ir(this.#qa.box,t)}else i=HighlightEditor.#ir([this.x,this.y,this.width,this.height],t);e.updateProperties(this.#w,{bbox:i,root:{"data-main-rotation":t}});e.updateProperties(this.#Ya,{bbox:HighlightEditor.#ir(this.#$a.box,t),root:{"data-main-rotation":t}})}render(){if(this.div)return this.div;const t=super.render();if(this.#Qa){t.setAttribute("aria-label",this.#Qa);t.setAttribute("role","mark")}this.#Xa?t.classList.add("free"):this.div.addEventListener("keydown",this.#or.bind(this),{signal:this._uiManager._signal});const e=this.#Wa=document.createElement("div");t.append(e);e.setAttribute("aria-hidden","true");e.className="internal";e.style.clipPath=this.#Ga;const[i,s]=this.parentDimensions;this.setDims(this.width*i,this.height*s);bindEvents(this,this.#Wa,["pointerover","pointerleave"]);this.enableEditing();return t}pointerover(){this.isSelected||this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{hovered:!0}})}pointerleave(){this.isSelected||this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{hovered:!1}})}#or(t){HighlightEditor._keyboardManager.exec(this,t)}_moveCaret(t){this.parent.unselect(this);switch(t){case 0:case 2:this.#lr(!0);break;case 1:case 3:this.#lr(!1)}}#lr(t){if(!this.#Ha)return;const e=window.getSelection();t?e.setPosition(this.#Ha,this.#za):e.setPosition(this.#Va,this.#ja)}select(){super.select();this.#Ya&&this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{hovered:!1,selected:!0}})}unselect(){super.unselect();if(this.#Ya){this.parent?.drawLayer.updateProperties(this.#Ya,{rootClass:{selected:!1}});this.#Xa||this.#lr(!1)}}get _mustFixPosition(){return!this.#Xa}show(t=this._isVisible){super.show(t);if(this.parent){this.parent.drawLayer.updateProperties(this.#w,{rootClass:{hidden:!t}});this.parent.drawLayer.updateProperties(this.#Ya,{rootClass:{hidden:!t}})}}#ar(){return this.#Xa?this.rotation:0}#hr(){if(this.#Xa)return null;const[t,e]=this.pageDimensions,[i,s]=this.pageTranslation,n=this.#Ua,a=new Float32Array(8*n.length);let r=0;for(const{x:o,y:l,width:h,height:d}of n){const n=o*t+i,c=(1-l)*e+s;a[r]=a[r+4]=n;a[r+1]=a[r+3]=c;a[r+2]=a[r+6]=n+h*t;a[r+5]=a[r+7]=c-d*e;r+=8}return a}#dr(t){return this.#qa.serialize(t,this.#ar())}static startHighlighting(t,e,{target:i,x:s,y:n}){const{x:a,y:r,width:o,height:l}=i.getBoundingClientRect(),h=new AbortController,d=t.combinedSignal(h),pointerUpCallback=e=>{h.abort();this.#cr(t,e)};window.addEventListener("blur",pointerUpCallback,{signal:d});window.addEventListener("pointerup",pointerUpCallback,{signal:d});window.addEventListener("pointerdown",stopEvent,{capture:!0,passive:!1,signal:d});window.addEventListener("contextmenu",noContextMenu,{signal:d});i.addEventListener("pointermove",this.#ur.bind(this,t),{signal:d});this._freeHighlight=new FreeHighlightOutliner({x:s,y:n},[a,r,o,l],t.scale,this._defaultThickness/2,e,.001);({id:this._freeHighlightId,clipPathId:this._freeHighlightClipId}=t.drawLayer.draw({bbox:[0,0,1,1],root:{viewBox:"0 0 1 1",fill:this._defaultColor,"fill-opacity":this._defaultOpacity},rootClass:{highlight:!0,free:!0},path:{d:this._freeHighlight.toSVGPath()}},!0,!0))}static#ur(t,e){this._freeHighlight.add(e)&&t.drawLayer.updateProperties(this._freeHighlightId,{path:{d:this._freeHighlight.toSVGPath()}})}static#cr(t,e){this._freeHighlight.isEmpty()?t.drawLayer.remove(this._freeHighlightId):t.createAndAddNewEditor(e,!1,{highlightId:this._freeHighlightId,highlightOutlines:this._freeHighlight.getOutlines(),clipPathId:this._freeHighlightClipId,methodOfCreation:"main_toolbar"});this._freeHighlightId=-1;this._freeHighlight=null;this._freeHighlightClipId=""}static async deserialize(t,e,i){let s=null;if(t instanceof HighlightAnnotationElement){const{data:{quadPoints:e,rect:i,rotation:n,id:a,color:r,opacity:o,popupRef:l},parent:{page:{pageNumber:h}}}=t;s=t={annotationType:g.HIGHLIGHT,color:Array.from(r),opacity:o,quadPoints:e,boxes:null,pageIndex:h-1,rect:i.slice(0),rotation:n,id:a,deleted:!1,popupRef:l}}else if(t instanceof InkAnnotationElement){const{data:{inkLists:e,rect:i,rotation:n,id:a,color:r,borderStyle:{rawWidth:o},popupRef:l},parent:{page:{pageNumber:h}}}=t;s=t={annotationType:g.HIGHLIGHT,color:Array.from(r),thickness:o,inkLists:e,boxes:null,pageIndex:h-1,rect:i.slice(0),rotation:n,id:a,deleted:!1,popupRef:l}}const{color:n,quadPoints:a,inkLists:r,opacity:o}=t,l=await super.deserialize(t,e,i);l.color=Util.makeHexColor(...n);l.#Ka=o||1;r&&(l.#ea=t.thickness);l.annotationElementId=t.id||null;l._initialData=s;const[h,d]=l.pageDimensions,[c,u]=l.pageTranslation;if(a){const t=l.#Ua=[];for(let e=0;et!==e[i]))}renderAnnotationElement(t){t.updateEdited({rect:this.getRect(0,0)});return null}static canCreateNewEmptyEditor(){return!1}}class DrawingOptions{#pr=Object.create(null);updateProperty(t,e){this[t]=e;this.updateSVGProperty(t,e)}updateProperties(t){if(t)for(const[e,i]of Object.entries(t))this.updateProperty(e,i)}updateSVGProperty(t,e){this.#pr[t]=e}toSVGProperties(){const t=this.#pr;this.#pr=Object.create(null);return{root:t}}reset(){this.#pr=Object.create(null)}updateAll(t=this){this.updateProperties(t)}clone(){unreachable("Not implemented")}}class DrawingEditor extends AnnotationEditor{#gr=null;#mr;_drawId=null;static _currentDrawId=-1;static _currentParent=null;static#fr=null;static#br=null;static#Ar=null;static#wr=NaN;static#vr=null;static#yr=null;static#xr=NaN;static _INNER_MARGIN=3;constructor(t){super(t);this.#mr=t.mustBeCommitted||!1;if(t.drawOutlines){this.#_r(t);this.#tr()}}#_r({drawOutlines:t,drawId:e,drawingOptions:i}){this.#gr=t;this._drawingOptions||=i;if(e>=0){this._drawId=e;this.parent.drawLayer.finalizeDraw(e,t.defaultProperties)}else this._drawId=this.#Er(t,this.parent);this.#Sr(t.box)}#Er(t,e){const{id:i}=e.drawLayer.draw(DrawingEditor._mergeSVGProperties(this._drawingOptions.toSVGProperties(),t.defaultSVGProperties),!1,!1);return i}static _mergeSVGProperties(t,e){const i=new Set(Object.keys(t));for(const[s,n]of Object.entries(e))i.has(s)?Object.assign(t[s],n):t[s]=n;return t}static getDefaultDrawingOptions(t){unreachable("Not implemented")}static get typesMap(){unreachable("Not implemented")}static get isDrawer(){return!0}static get supportMultipleDrawings(){return!1}static updateDefaultParams(t,e){const i=this.typesMap.get(t);i&&this._defaultDrawingOptions.updateProperty(i,e);if(this._currentParent){DrawingEditor.#fr.updateProperty(i,e);this._currentParent.drawLayer.updateProperties(this._currentDrawId,this._defaultDrawingOptions.toSVGProperties())}}updateParams(t,e){const i=this.constructor.typesMap.get(t);i&&this._updateProperty(t,i,e)}static get defaultPropertiesToUpdate(){const t=[],e=this._defaultDrawingOptions;for(const[i,s]of this.typesMap)t.push([i,e[s]]);return t}get propertiesToUpdate(){const t=[],{_drawingOptions:e}=this;for(const[i,s]of this.constructor.typesMap)t.push([i,e[s]]);return t}_updateProperty(t,e,i){const s=this._drawingOptions,n=s[e],setter=t=>{s.updateProperty(e,t);const i=this.#gr.updateProperty(e,t);i&&this.#Sr(i);this.parent?.drawLayer.updateProperties(this._drawId,s.toSVGProperties())};this.addCommands({cmd:setter.bind(this,i),undo:setter.bind(this,n),post:this._uiManager.updateUI.bind(this._uiManager,this),mustExec:!0,type:t,overwriteIfSameType:!0,keepUndo:!0})}_onResizing(){this.parent?.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties(this.#gr.getPathResizingSVGProperties(this.#Cr()),{bbox:this.#Tr()}))}_onResized(){this.parent?.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties(this.#gr.getPathResizedSVGProperties(this.#Cr()),{bbox:this.#Tr()}))}_onTranslating(t,e){this.parent?.drawLayer.updateProperties(this._drawId,{bbox:this.#Tr(t,e)})}_onTranslated(){this.parent?.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties(this.#gr.getPathTranslatedSVGProperties(this.#Cr(),this.parentDimensions),{bbox:this.#Tr()}))}_onStartDragging(){this.parent?.drawLayer.updateProperties(this._drawId,{rootClass:{moving:!0}})}_onStopDragging(){this.parent?.drawLayer.updateProperties(this._drawId,{rootClass:{moving:!1}})}commit(){super.commit();this.disableEditMode();this.disableEditing()}disableEditing(){super.disableEditing();this.div.classList.toggle("disabled",!0)}enableEditing(){super.enableEditing();this.div.classList.toggle("disabled",!1)}getBaseTranslation(){return[0,0]}get isResizable(){return!0}onceAdded(t){this.annotationElementId||this.parent.addUndoableEditor(this);this._isDraggable=!0;if(this.#mr){this.#mr=!1;this.commit();this.parent.setSelected(this);t&&this.isOnScreen&&this.div.focus()}}remove(){this.#rr();super.remove()}rebuild(){if(this.parent){super.rebuild();if(null!==this.div){this.#tr();this.#Sr(this.#gr.box);this.isAttachedToDOM||this.parent.add(this)}}}setParent(t){let e=!1;if(this.parent&&!t){this._uiManager.removeShouldRescale(this);this.#rr()}else if(t){this._uiManager.addShouldRescale(this);this.#tr(t);e=!this.parent&&this.div?.classList.contains("selectedEditor")}super.setParent(t);e&&this.select()}#rr(){if(null!==this._drawId&&this.parent){this.parent.drawLayer.remove(this._drawId);this._drawId=null;this._drawingOptions.reset()}}#tr(t=this.parent){if(null===this._drawId||this.parent!==t)if(null===this._drawId){this._drawingOptions.updateAll();this._drawId=this.#Er(this.#gr,t)}else this.parent.drawLayer.updateParent(this._drawId,t.drawLayer)}#Mr([t,e,i,s]){const{parentDimensions:[n,a],rotation:r}=this;switch(r){case 90:return[e,1-t,i*(a/n),s*(n/a)];case 180:return[1-t,1-e,i,s];case 270:return[1-e,t,i*(a/n),s*(n/a)];default:return[t,e,i,s]}}#Cr(){const{x:t,y:e,width:i,height:s,parentDimensions:[n,a],rotation:r}=this;switch(r){case 90:return[1-e,t,i*(n/a),s*(a/n)];case 180:return[1-t,1-e,i,s];case 270:return[e,1-t,i*(n/a),s*(a/n)];default:return[t,e,i,s]}}#Sr(t){[this.x,this.y,this.width,this.height]=this.#Mr(t);if(this.div){this.fixAndSetPosition();const[t,e]=this.parentDimensions;this.setDims(this.width*t,this.height*e)}this._onResized()}#Tr(){const{x:t,y:e,width:i,height:s,rotation:n,parentRotation:a,parentDimensions:[r,o]}=this;switch((4*n+a)/90){case 1:return[1-e-s,t,s,i];case 2:return[1-t-i,1-e-s,i,s];case 3:return[e,1-t-i,s,i];case 4:return[t,e-i*(r/o),s*(o/r),i*(r/o)];case 5:return[1-e,t,i*(r/o),s*(o/r)];case 6:return[1-t-s*(o/r),1-e,s*(o/r),i*(r/o)];case 7:return[e-i*(r/o),1-t-s*(o/r),i*(r/o),s*(o/r)];case 8:return[t-i,e-s,i,s];case 9:return[1-e,t-i,s,i];case 10:return[1-t,1-e,i,s];case 11:return[e-s,1-t,s,i];case 12:return[t-s*(o/r),e,s*(o/r),i*(r/o)];case 13:return[1-e-i*(r/o),t-s*(o/r),i*(r/o),s*(o/r)];case 14:return[1-t,1-e-i*(r/o),s*(o/r),i*(r/o)];case 15:return[e,1-t,i*(r/o),s*(o/r)];default:return[t,e,i,s]}}rotate(){this.parent&&this.parent.drawLayer.updateProperties(this._drawId,DrawingEditor._mergeSVGProperties({bbox:this.#Tr()},this.#gr.updateRotation((this.parentRotation-this.rotation+360)%360)))}onScaleChanging(){this.parent&&this.#Sr(this.#gr.updateParentDimensions(this.parentDimensions,this.parent.scale))}static onScaleChangingWhenDrawing(){}render(){if(this.div)return this.div;const t=super.render();t.classList.add("draw");const e=document.createElement("div");t.append(e);e.setAttribute("aria-hidden","true");e.className="internal";const[i,s]=this.parentDimensions;this.setDims(this.width*i,this.height*s);this._uiManager.addShouldRescale(this);this.disableEditing();return t}static createDrawerInstance(t,e,i,s,n){unreachable("Not implemented")}static startDrawing(t,e,i,s){const{target:n,offsetX:a,offsetY:r,pointerId:o,pointerType:l}=s;if(DrawingEditor.#vr&&DrawingEditor.#vr!==l)return;const{viewport:{rotation:h}}=t,{width:d,height:c}=n.getBoundingClientRect(),u=DrawingEditor.#br=new AbortController,p=t.combinedSignal(u);DrawingEditor.#wr||=o;DrawingEditor.#vr??=l;window.addEventListener("pointerup",(t=>{DrawingEditor.#wr===t.pointerId?this._endDraw(t):DrawingEditor.#yr?.delete(t.pointerId)}),{signal:p});window.addEventListener("pointercancel",(t=>{DrawingEditor.#wr===t.pointerId?this._currentParent.endDrawingSession():DrawingEditor.#yr?.delete(t.pointerId)}),{signal:p});window.addEventListener("pointerdown",(t=>{if(DrawingEditor.#vr===t.pointerType){(DrawingEditor.#yr||=new Set).add(t.pointerId);if(DrawingEditor.#fr.isCancellable()){DrawingEditor.#fr.removeLastElement();DrawingEditor.#fr.isEmpty()?this._currentParent.endDrawingSession(!0):this._endDraw(null)}}}),{capture:!0,passive:!1,signal:p});window.addEventListener("contextmenu",noContextMenu,{signal:p});n.addEventListener("pointermove",this._drawMove.bind(this),{signal:p});n.addEventListener("touchmove",(t=>{t.timeStamp===DrawingEditor.#xr&&stopEvent(t)}),{signal:p});t.toggleDrawing();e._editorUndoBar?.hide();if(DrawingEditor.#fr)t.drawLayer.updateProperties(this._currentDrawId,DrawingEditor.#fr.startNew(a,r,d,c,h));else{e.updateUIForDefaultProperties(this);DrawingEditor.#fr=this.createDrawerInstance(a,r,d,c,h);DrawingEditor.#Ar=this.getDefaultDrawingOptions();this._currentParent=t;({id:this._currentDrawId}=t.drawLayer.draw(this._mergeSVGProperties(DrawingEditor.#Ar.toSVGProperties(),DrawingEditor.#fr.defaultSVGProperties),!0,!1))}}static _drawMove(t){DrawingEditor.#xr=-1;if(!DrawingEditor.#fr)return;const{offsetX:e,offsetY:i,pointerId:s}=t;if(DrawingEditor.#wr===s)if(DrawingEditor.#yr?.size>=1)this._endDraw(t);else{this._currentParent.drawLayer.updateProperties(this._currentDrawId,DrawingEditor.#fr.add(e,i));DrawingEditor.#xr=t.timeStamp;stopEvent(t)}}static _cleanup(t){if(t){this._currentDrawId=-1;this._currentParent=null;DrawingEditor.#fr=null;DrawingEditor.#Ar=null;DrawingEditor.#vr=null;DrawingEditor.#xr=NaN}if(DrawingEditor.#br){DrawingEditor.#br.abort();DrawingEditor.#br=null;DrawingEditor.#wr=NaN;DrawingEditor.#yr=null}}static _endDraw(t){const e=this._currentParent;if(e){e.toggleDrawing(!0);this._cleanup(!1);t&&e.drawLayer.updateProperties(this._currentDrawId,DrawingEditor.#fr.end(t.offsetX,t.offsetY));if(this.supportMultipleDrawings){const t=DrawingEditor.#fr,i=this._currentDrawId,s=t.getLastElement();e.addCommands({cmd:()=>{e.drawLayer.updateProperties(i,t.setLastElement(s))},undo:()=>{e.drawLayer.updateProperties(i,t.removeLastElement())},mustExec:!1,type:m.DRAW_STEP})}else this.endDrawing(!1)}}static endDrawing(t){const e=this._currentParent;if(!e)return null;e.toggleDrawing(!0);e.cleanUndoStack(m.DRAW_STEP);if(!DrawingEditor.#fr.isEmpty()){const{pageDimensions:[i,s],scale:n}=e,a=e.createAndAddNewEditor({offsetX:0,offsetY:0},!1,{drawId:this._currentDrawId,drawOutlines:DrawingEditor.#fr.getOutlines(i*n,s*n,n,this._INNER_MARGIN),drawingOptions:DrawingEditor.#Ar,mustBeCommitted:!t});this._cleanup(!0);return a}e.drawLayer.remove(this._currentDrawId);this._cleanup(!0);return null}createDrawingOptions(t){}static deserializeDraw(t,e,i,s,n,a){unreachable("Not implemented")}static async deserialize(t,e,i){const{rawDims:{pageWidth:s,pageHeight:n,pageX:a,pageY:r}}=e.viewport,o=this.deserializeDraw(a,r,s,n,this._INNER_MARGIN,t),l=await super.deserialize(t,e,i);l.createDrawingOptions(t);l.#_r({drawOutlines:o});l.#tr();l.onScaleChanging();l.rotate();return l}serializeDraw(t){const[e,i]=this.pageTranslation,[s,n]=this.pageDimensions;return this.#gr.serialize([e,i,s,n],t)}renderAnnotationElement(t){t.updateEdited({rect:this.getRect(0,0)});return null}static canCreateNewEmptyEditor(){return!1}}class InkDrawOutliner{#Kn=new Float64Array(6);#bn;#Pr;#Fi;#ea;#ia;#Dr="";#kr=0;#Ea=new InkDrawOutline;#Rr;#Ir;constructor(t,e,i,s,n,a){this.#Rr=i;this.#Ir=s;this.#Fi=n;this.#ea=a;[t,e]=this.#Fr(t,e);const r=this.#bn=[NaN,NaN,NaN,NaN,t,e];this.#ia=[t,e];this.#Pr=[{line:r,points:this.#ia}];this.#Kn.set(r,0)}updateProperty(t,e){"stroke-width"===t&&(this.#ea=e)}#Fr(t,e){return Outline._normalizePoint(t,e,this.#Rr,this.#Ir,this.#Fi)}isEmpty(){return!this.#Pr||0===this.#Pr.length}isCancellable(){return this.#ia.length<=10}add(t,e){[t,e]=this.#Fr(t,e);const[i,s,n,a]=this.#Kn.subarray(2,6),r=t-n,o=e-a;if(Math.hypot(this.#Rr*r,this.#Ir*o)<=2)return null;this.#ia.push(t,e);if(isNaN(i)){this.#Kn.set([n,a,t,e],2);this.#bn.push(NaN,NaN,NaN,NaN,t,e);return{path:{d:this.toSVGPath()}}}isNaN(this.#Kn[0])&&this.#bn.splice(6,6);this.#Kn.set([i,s,n,a,t,e],0);this.#bn.push(...Outline.createBezierPoints(i,s,n,a,t,e));return{path:{d:this.toSVGPath()}}}end(t,e){const i=this.add(t,e);return i||(2===this.#ia.length?{path:{d:this.toSVGPath()}}:null)}startNew(t,e,i,s,n){this.#Rr=i;this.#Ir=s;this.#Fi=n;[t,e]=this.#Fr(t,e);const a=this.#bn=[NaN,NaN,NaN,NaN,t,e];this.#ia=[t,e];const r=this.#Pr.at(-1);if(r){r.line=new Float32Array(r.line);r.points=new Float32Array(r.points)}this.#Pr.push({line:a,points:this.#ia});this.#Kn.set(a,0);this.#kr=0;this.toSVGPath();return null}getLastElement(){return this.#Pr.at(-1)}setLastElement(t){if(!this.#Pr)return this.#Ea.setLastElement(t);this.#Pr.push(t);this.#bn=t.line;this.#ia=t.points;this.#kr=0;return{path:{d:this.toSVGPath()}}}removeLastElement(){if(!this.#Pr)return this.#Ea.removeLastElement();this.#Pr.pop();this.#Dr="";for(let t=0,e=this.#Pr.length;tt??NaN)),d,c,u,p),points:g(r[t].map((t=>t??NaN)),d,c,u,p)});const m=new InkDrawOutline;m.build(h,i,s,1,o,l,n);return m}#Hr(t=this.#ea){const e=this.#Wn+t/2*this.#Or;return this.#Fi%180==0?[e/this.#Rr,e/this.#Ir]:[e/this.#Ir,e/this.#Rr]}#Br(){const[t,e,i,s]=this.#pa,[n,a]=this.#Hr(0);return[t+n,e+a,i-2*n,s-2*a]}#Nr(){const t=this.#pa=new Float32Array([1/0,1/0,-1/0,-1/0]);for(const{line:e}of this.#Pr){if(e.length<=12){for(let i=4,s=e.length;it!==e[i]))||t.thickness!==i||t.opacity!==s||t.pageIndex!==n}renderAnnotationElement(t){const{points:e,rect:i}=this.serializeDraw(!1);t.updateEdited({rect:i,thickness:this._drawingOptions["stroke-width"],points:e});return null}}class StampEditor extends AnnotationEditor{#Ur=null;#Gr=null;#$r=null;#Vr=null;#jr=null;#Wr="";#qr=null;#Xr=null;#Kr=!1;#Yr=!1;static _type="stamp";static _editorType=g.STAMP;constructor(t){super({...t,name:"stampEditor"});this.#Vr=t.bitmapUrl;this.#jr=t.bitmapFile}static initialize(t,e){AnnotationEditor.initialize(t,e)}static get supportedTypes(){return shadow(this,"supportedTypes",["apng","avif","bmp","gif","jpeg","png","svg+xml","webp","x-icon"].map((t=>`image/${t}`)))}static get supportedTypesStr(){return shadow(this,"supportedTypesStr",this.supportedTypes.join(","))}static isHandlingMimeForPasting(t){return this.supportedTypes.includes(t)}static paste(t,e){e.pasteEditor(g.STAMP,{bitmapFile:t.getAsFile()})}altTextFinish(){this._uiManager.useNewAltTextFlow&&(this.div.hidden=!1);super.altTextFinish()}get telemetryFinalData(){return{type:"stamp",hasAltText:!!this.altTextData?.altText}}static computeTelemetryFinalData(t){const e=t.get("hasAltText");return{hasAltText:e.get(!0)??0,hasNoAltText:e.get(!1)??0}}#Qr(t,e=!1){if(t){this.#Ur=t.bitmap;if(!e){this.#Gr=t.id;this.#Kr=t.isSvg}t.file&&(this.#Wr=t.file.name);this.#Jr()}else this.remove()}#Zr(){this.#$r=null;this._uiManager.enableWaiting(!1);if(this.#qr)if(this._uiManager.useNewAltTextWhenAddingImage&&this._uiManager.useNewAltTextFlow&&this.#Ur){this._editToolbar.hide();this._uiManager.editAltText(this,!0)}else{if(!this._uiManager.useNewAltTextWhenAddingImage&&this._uiManager.useNewAltTextFlow&&this.#Ur){this._reportTelemetry({action:"pdfjs.image.image_added",data:{alt_text_modal:!1,alt_text_type:"empty"}});try{this.mlGuessAltText()}catch{}}this.div.focus()}}async mlGuessAltText(t=null,e=!0){if(this.hasAltTextData())return null;const{mlManager:i}=this._uiManager;if(!i)throw new Error("No ML.");if(!await i.isEnabledFor("altText"))throw new Error("ML isn't enabled for alt text.");const{data:s,width:n,height:a}=t||this.copyCanvas(null,null,!0).imageData,r=await i.guess({name:"altText",request:{data:s,width:n,height:a,channels:s.length/(n*a)}});if(!r)throw new Error("No response from the AI service.");if(r.error)throw new Error("Error from the AI service.");if(r.cancel)return null;if(!r.output)throw new Error("No valid response from the AI service.");const o=r.output;await this.setGuessedAltText(o);e&&!this.hasAltTextData()&&(this.altTextData={alt:o,decorative:!1});return o}#to(){if(this.#Gr){this._uiManager.enableWaiting(!0);this._uiManager.imageManager.getFromId(this.#Gr).then((t=>this.#Qr(t,!0))).finally((()=>this.#Zr()));return}if(this.#Vr){const t=this.#Vr;this.#Vr=null;this._uiManager.enableWaiting(!0);this.#$r=this._uiManager.imageManager.getFromUrl(t).then((t=>this.#Qr(t))).finally((()=>this.#Zr()));return}if(this.#jr){const t=this.#jr;this.#jr=null;this._uiManager.enableWaiting(!0);this.#$r=this._uiManager.imageManager.getFromFile(t).then((t=>this.#Qr(t))).finally((()=>this.#Zr()));return}const t=document.createElement("input");t.type="file";t.accept=StampEditor.supportedTypesStr;const e=this._uiManager._signal;this.#$r=new Promise((i=>{t.addEventListener("change",(async()=>{if(t.files&&0!==t.files.length){this._uiManager.enableWaiting(!0);const e=await this._uiManager.imageManager.getFromFile(t.files[0]);this._reportTelemetry({action:"pdfjs.image.image_selected",data:{alt_text_modal:this._uiManager.useNewAltTextFlow}});this.#Qr(e)}else this.remove();i()}),{signal:e});t.addEventListener("cancel",(()=>{this.remove();i()}),{signal:e})})).finally((()=>this.#Zr()));t.click()}remove(){if(this.#Gr){this.#Ur=null;this._uiManager.imageManager.deleteId(this.#Gr);this.#qr?.remove();this.#qr=null;if(this.#Xr){clearTimeout(this.#Xr);this.#Xr=null}}super.remove()}rebuild(){if(this.parent){super.rebuild();if(null!==this.div){this.#Gr&&null===this.#qr&&this.#to();this.isAttachedToDOM||this.parent.add(this)}}else this.#Gr&&this.#to()}onceAdded(t){this._isDraggable=!0;t&&this.div.focus()}isEmpty(){return!(this.#$r||this.#Ur||this.#Vr||this.#jr||this.#Gr)}get isResizable(){return!0}render(){if(this.div)return this.div;let t,e;if(this.width){t=this.x;e=this.y}super.render();this.div.hidden=!0;this.div.setAttribute("role","figure");this.addAltTextButton();this.#Ur?this.#Jr():this.#to();if(this.width&&!this.annotationElementId){const[i,s]=this.parentDimensions;this.setAt(t*i,e*s,this.width*i,this.height*s)}this._uiManager.addShouldRescale(this);return this.div}_onResized(){this.onScaleChanging()}onScaleChanging(){if(!this.parent)return;null!==this.#Xr&&clearTimeout(this.#Xr);this.#Xr=setTimeout((()=>{this.#Xr=null;this.#eo()}),200)}#Jr(){const{div:t}=this;let{width:e,height:i}=this.#Ur;const[s,n]=this.pageDimensions,a=.75;if(this.width){e=this.width*s;i=this.height*n}else if(e>a*s||i>a*n){const t=Math.min(a*s/e,a*n/i);e*=t;i*=t}const[r,o]=this.parentDimensions;this.setDims(e*r/s,i*o/n);this._uiManager.enableWaiting(!1);const l=this.#qr=document.createElement("canvas");l.setAttribute("role","img");this.addContainer(l);this.width=e/s;this.height=i/n;this._initialOptions?.isCentered?this.center():this.fixAndSetPosition();this._initialOptions=null;this._uiManager.useNewAltTextWhenAddingImage&&this._uiManager.useNewAltTextFlow&&!this.annotationElementId||(t.hidden=!1);this.#eo();if(!this.#Yr){this.parent.addUndoableEditor(this);this.#Yr=!0}this._reportTelemetry({action:"inserted_image"});this.#Wr&&l.setAttribute("aria-label",this.#Wr)}copyCanvas(t,e,i=!1){t||(t=224);const{width:s,height:n}=this.#Ur,a=new OutputScale;let r=this.#Ur,o=s,l=n,h=null;if(e){if(s>e||n>e){const t=Math.min(e/s,e/n);o=Math.floor(s*t);l=Math.floor(n*t)}h=document.createElement("canvas");const t=h.width=Math.ceil(o*a.sx),i=h.height=Math.ceil(l*a.sy);this.#Kr||(r=this.#io(t,i));const d=h.getContext("2d");d.filter=this._uiManager.hcmFilter;let c="white",u="#cfcfd8";if("none"!==this._uiManager.hcmFilter)u="black";else if(window.matchMedia?.("(prefers-color-scheme: dark)").matches){c="#8f8f9d";u="#42414d"}const p=15,g=p*a.sx,m=p*a.sy,f=new OffscreenCanvas(2*g,2*m),b=f.getContext("2d");b.fillStyle=c;b.fillRect(0,0,2*g,2*m);b.fillStyle=u;b.fillRect(0,0,g,m);b.fillRect(g,m,g,m);d.fillStyle=d.createPattern(f,"repeat");d.fillRect(0,0,t,i);d.drawImage(r,0,0,r.width,r.height,0,0,t,i)}let d=null;if(i){let e,i;if(a.symmetric&&r.widtht||n>t){const a=Math.min(t/s,t/n);e=Math.floor(s*a);i=Math.floor(n*a);this.#Kr||(r=this.#io(e,i))}}const o=new OffscreenCanvas(e,i).getContext("2d",{willReadFrequently:!0});o.drawImage(r,0,0,r.width,r.height,0,0,e,i);d={width:e,height:i,data:o.getImageData(0,0,e,i).data}}return{canvas:h,width:o,height:l,imageData:d}}#io(t,e){const{width:i,height:s}=this.#Ur;let n=i,a=s,r=this.#Ur;for(;n>2*t||a>2*e;){const i=n,s=a;n>2*t&&(n=n>=16384?Math.floor(n/2)-1:Math.ceil(n/2));a>2*e&&(a=a>=16384?Math.floor(a/2)-1:Math.ceil(a/2));const o=new OffscreenCanvas(n,a);o.getContext("2d").drawImage(r,0,0,i,s,0,0,n,a);r=o.transferToImageBitmap()}return r}#eo(){const[t,e]=this.parentDimensions,{width:i,height:s}=this,n=new OutputScale,a=Math.ceil(i*t*n.sx),r=Math.ceil(s*e*n.sy),o=this.#qr;if(!o||o.width===a&&o.height===r)return;o.width=a;o.height=r;const l=this.#Kr?this.#Ur:this.#io(a,r),h=o.getContext("2d");h.filter=this._uiManager.hcmFilter;h.drawImage(l,0,0,l.width,l.height,0,0,a,r)}getImageForAltText(){return this.#qr}#so(t){if(t){if(this.#Kr){const t=this._uiManager.imageManager.getSvgUrl(this.#Gr);if(t)return t}const t=document.createElement("canvas");({width:t.width,height:t.height}=this.#Ur);t.getContext("2d").drawImage(this.#Ur,0,0);return t.toDataURL()}if(this.#Kr){const[t,e]=this.pageDimensions,i=Math.round(this.width*t*PixelsPerInch.PDF_TO_CSS_UNITS),s=Math.round(this.height*e*PixelsPerInch.PDF_TO_CSS_UNITS),n=new OffscreenCanvas(i,s);n.getContext("2d").drawImage(this.#Ur,0,0,this.#Ur.width,this.#Ur.height,0,0,i,s);return n.transferToImageBitmap()}return structuredClone(this.#Ur)}static async deserialize(t,e,i){let s=null;if(t instanceof StampAnnotationElement){const{data:{rect:n,rotation:a,id:r,structParent:o,popupRef:l},container:h,parent:{page:{pageNumber:d}}}=t,c=h.querySelector("canvas"),u=i.imageManager.getFromCanvas(h.id,c);c.remove();const p=(await e._structTree.getAriaAttributes(`${et}${r}`))?.get("aria-label")||"";s=t={annotationType:g.STAMP,bitmapId:u.id,bitmap:u.bitmap,pageIndex:d-1,rect:n.slice(0),rotation:a,id:r,deleted:!1,accessibilityData:{decorative:!1,altText:p},isSvg:!1,structParent:o,popupRef:l}}const n=await super.deserialize(t,e,i),{rect:a,bitmap:r,bitmapUrl:o,bitmapId:l,isSvg:h,accessibilityData:d}=t;if(l&&i.imageManager.isValidId(l)){n.#Gr=l;r&&(n.#Ur=r)}else n.#Vr=o;n.#Kr=h;const[c,u]=n.pageDimensions;n.width=(a[2]-a[0])/c;n.height=(a[3]-a[1])/u;n.annotationElementId=t.id||null;d&&(n.altTextData=d);n._initialData=s;n.#Yr=!!s;return n}serialize(t=!1,e=null){if(this.isEmpty())return null;if(this.deleted)return this.serializeDeleted();const i={annotationType:g.STAMP,bitmapId:this.#Gr,pageIndex:this.pageIndex,rect:this.getRect(0,0),rotation:this.rotation,isSvg:this.#Kr,structTreeParentId:this._structTreeParentId};if(t){i.bitmapUrl=this.#so(!0);i.accessibilityData=this.serializeAltText(!0);return i}const{decorative:s,altText:n}=this.serializeAltText(!1);!s&&n&&(i.accessibilityData={type:"Figure",alt:n});if(this.annotationElementId){const t=this.#$n(i);if(t.isSame)return null;t.isSameAltText?delete i.accessibilityData:i.accessibilityData.structParent=this._initialData.structParent??-1}i.id=this.annotationElementId;if(null===e)return i;e.stamps||=new Map;const a=this.#Kr?(i.rect[2]-i.rect[0])*(i.rect[3]-i.rect[1]):null;if(e.stamps.has(this.#Gr)){if(this.#Kr){const t=e.stamps.get(this.#Gr);if(a>t.area){t.area=a;t.serialized.bitmap.close();t.serialized.bitmap=this.#so(!1)}}}else{e.stamps.set(this.#Gr,{area:a,serialized:i});i.bitmap=this.#so(!1)}return i}#$n(t){const{pageIndex:e,accessibilityData:{altText:i}}=this._initialData,s=t.pageIndex===e,n=(t.accessibilityData?.alt||"")===i;return{isSame:!this._hasBeenMoved&&!this._hasBeenResized&&s&&n,isSameAltText:n}}renderAnnotationElement(t){t.updateEdited({rect:this.getRect(0,0)});return null}}class AnnotationEditorLayer{#Cn;#no=!1;#ao=null;#ro=null;#oo=null;#lo=new Map;#ho=!1;#do=!1;#co=!1;#uo=null;#po=null;#go=null;#mo=null;#m;static _initialized=!1;static#U=new Map([FreeTextEditor,InkEditor,StampEditor,HighlightEditor].map((t=>[t._editorType,t])));constructor({uiManager:t,pageIndex:e,div:i,structTreeLayer:s,accessibilityManager:n,annotationLayer:a,drawLayer:r,textLayer:o,viewport:l,l10n:h}){const d=[...AnnotationEditorLayer.#U.values()];if(!AnnotationEditorLayer._initialized){AnnotationEditorLayer._initialized=!0;for(const e of d)e.initialize(h,t)}t.registerEditorTypes(d);this.#m=t;this.pageIndex=e;this.div=i;this.#Cn=n;this.#ao=a;this.viewport=l;this.#go=o;this.drawLayer=r;this._structTree=s;this.#m.addLayer(this)}get isEmpty(){return 0===this.#lo.size}get isInvisible(){return this.isEmpty&&this.#m.getMode()===g.NONE}updateToolbar(t){this.#m.updateToolbar(t)}updateMode(t=this.#m.getMode()){this.#fo();switch(t){case g.NONE:this.disableTextSelection();this.togglePointerEvents(!1);this.toggleAnnotationLayerPointerEvents(!0);this.disableClick();return;case g.INK:this.disableTextSelection();this.togglePointerEvents(!0);this.enableClick();break;case g.HIGHLIGHT:this.enableTextSelection();this.togglePointerEvents(!1);this.disableClick();break;default:this.disableTextSelection();this.togglePointerEvents(!0);this.enableClick()}this.toggleAnnotationLayerPointerEvents(!1);const{classList:e}=this.div;for(const i of AnnotationEditorLayer.#U.values())e.toggle(`${i._type}Editing`,t===i._editorType);this.div.hidden=!1}hasTextLayer(t){return t===this.#go?.div}setEditingState(t){this.#m.setEditingState(t)}addCommands(t){this.#m.addCommands(t)}cleanUndoStack(t){this.#m.cleanUndoStack(t)}toggleDrawing(t=!1){this.div.classList.toggle("drawing",!t)}togglePointerEvents(t=!1){this.div.classList.toggle("disabled",!t)}toggleAnnotationLayerPointerEvents(t=!1){this.#ao?.div.classList.toggle("disabled",!t)}async enable(){this.#co=!0;this.div.tabIndex=0;this.togglePointerEvents(!0);const t=new Set;for(const e of this.#lo.values()){e.enableEditing();e.show(!0);if(e.annotationElementId){this.#m.removeChangedExistingAnnotation(e);t.add(e.annotationElementId)}}if(!this.#ao){this.#co=!1;return}const e=this.#ao.getEditableAnnotations();for(const i of e){i.hide();if(this.#m.isDeletedAnnotationElement(i.data.id))continue;if(t.has(i.data.id))continue;const e=await this.deserialize(i);if(e){this.addOrRebuild(e);e.enableEditing()}}this.#co=!1}disable(){this.#do=!0;this.div.tabIndex=-1;this.togglePointerEvents(!1);const t=new Map,e=new Map;for(const i of this.#lo.values()){i.disableEditing();if(i.annotationElementId)if(null===i.serialize()){e.set(i.annotationElementId,i);this.getEditableAnnotation(i.annotationElementId)?.show();i.remove()}else t.set(i.annotationElementId,i)}if(this.#ao){const i=this.#ao.getEditableAnnotations();for(const s of i){const{id:i}=s.data;if(this.#m.isDeletedAnnotationElement(i))continue;let n=e.get(i);if(n){n.resetAnnotationElement(s);n.show(!1);s.show()}else{n=t.get(i);if(n){this.#m.addChangedExistingAnnotation(n);n.renderAnnotationElement(s)&&n.show(!1)}s.show()}}}this.#fo();this.isEmpty&&(this.div.hidden=!0);const{classList:i}=this.div;for(const t of AnnotationEditorLayer.#U.values())i.remove(`${t._type}Editing`);this.disableTextSelection();this.toggleAnnotationLayerPointerEvents(!0);this.#do=!1}getEditableAnnotation(t){return this.#ao?.getEditableAnnotation(t)||null}setActiveEditor(t){this.#m.getActive()!==t&&this.#m.setActiveEditor(t)}enableTextSelection(){this.div.tabIndex=-1;if(this.#go?.div&&!this.#mo){this.#mo=new AbortController;const t=this.#m.combinedSignal(this.#mo);this.#go.div.addEventListener("pointerdown",this.#bo.bind(this),{signal:t});this.#go.div.classList.add("highlighting")}}disableTextSelection(){this.div.tabIndex=0;if(this.#go?.div&&this.#mo){this.#mo.abort();this.#mo=null;this.#go.div.classList.remove("highlighting")}}#bo(t){this.#m.unselectAll();const{target:e}=t;if(e===this.#go.div||("img"===e.getAttribute("role")||e.classList.contains("endOfContent"))&&this.#go.div.contains(e)){const{isMac:e}=util_FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)return;this.#m.showAllEditors("highlight",!0,!0);this.#go.div.classList.add("free");this.toggleDrawing();HighlightEditor.startHighlighting(this,"ltr"===this.#m.direction,{target:this.#go.div,x:t.x,y:t.y});this.#go.div.addEventListener("pointerup",(()=>{this.#go.div.classList.remove("free");this.toggleDrawing(!0)}),{once:!0,signal:this.#m._signal});t.preventDefault()}}enableClick(){if(this.#ro)return;this.#ro=new AbortController;const t=this.#m.combinedSignal(this.#ro);this.div.addEventListener("pointerdown",this.pointerdown.bind(this),{signal:t});const e=this.pointerup.bind(this);this.div.addEventListener("pointerup",e,{signal:t});this.div.addEventListener("pointercancel",e,{signal:t})}disableClick(){this.#ro?.abort();this.#ro=null}attach(t){this.#lo.set(t.id,t);const{annotationElementId:e}=t;e&&this.#m.isDeletedAnnotationElement(e)&&this.#m.removeDeletedAnnotationElement(t)}detach(t){this.#lo.delete(t.id);this.#Cn?.removePointerInTextLayer(t.contentDiv);!this.#do&&t.annotationElementId&&this.#m.addDeletedAnnotationElement(t)}remove(t){this.detach(t);this.#m.removeEditor(t);t.div.remove();t.isAttachedToDOM=!1}changeParent(t){if(t.parent!==this){if(t.parent&&t.annotationElementId){this.#m.addDeletedAnnotationElement(t.annotationElementId);AnnotationEditor.deleteAnnotationElement(t);t.annotationElementId=null}this.attach(t);t.parent?.detach(t);t.setParent(this);if(t.div&&t.isAttachedToDOM){t.div.remove();this.div.append(t.div)}}}add(t){if(t.parent!==this||!t.isAttachedToDOM){this.changeParent(t);this.#m.addEditor(t);this.attach(t);if(!t.isAttachedToDOM){const e=t.render();this.div.append(e);t.isAttachedToDOM=!0}t.fixAndSetPosition();t.onceAdded(!this.#co);this.#m.addToAnnotationStorage(t);t._reportTelemetry(t.telemetryInitialData)}}moveEditorInDOM(t){if(!t.isAttachedToDOM)return;const{activeElement:e}=document;if(t.div.contains(e)&&!this.#oo){t._focusEventsAllowed=!1;this.#oo=setTimeout((()=>{this.#oo=null;if(t.div.contains(document.activeElement))t._focusEventsAllowed=!0;else{t.div.addEventListener("focusin",(()=>{t._focusEventsAllowed=!0}),{once:!0,signal:this.#m._signal});e.focus()}}),0)}t._structTreeParentId=this.#Cn?.moveElementInDOM(this.div,t.div,t.contentDiv,!0)}addOrRebuild(t){if(t.needsToBeRebuilt()){t.parent||=this;t.rebuild();t.show()}else this.add(t)}addUndoableEditor(t){this.addCommands({cmd:()=>t._uiManager.rebuild(t),undo:()=>{t.remove()},mustExec:!1})}getNextId(){return this.#m.getId()}get#Ao(){return AnnotationEditorLayer.#U.get(this.#m.getMode())}combinedSignal(t){return this.#m.combinedSignal(t)}#wo(t){const e=this.#Ao;return e?new e.prototype.constructor(t):null}canCreateNewEmptyEditor(){return this.#Ao?.canCreateNewEmptyEditor()}pasteEditor(t,e){this.#m.updateToolbar(t);this.#m.updateMode(t);const{offsetX:i,offsetY:s}=this.#vo(),n=this.getNextId(),a=this.#wo({parent:this,id:n,x:i,y:s,uiManager:this.#m,isCentered:!0,...e});a&&this.add(a)}async deserialize(t){return await(AnnotationEditorLayer.#U.get(t.annotationType??t.annotationEditorType)?.deserialize(t,this,this.#m))||null}createAndAddNewEditor(t,e,i={}){const s=this.getNextId(),n=this.#wo({parent:this,id:s,x:t.offsetX,y:t.offsetY,uiManager:this.#m,isCentered:e,...i});n&&this.add(n);return n}#vo(){const{x:t,y:e,width:i,height:s}=this.div.getBoundingClientRect(),n=Math.max(0,t),a=Math.max(0,e),r=(n+Math.min(window.innerWidth,t+i))/2-t,o=(a+Math.min(window.innerHeight,e+s))/2-e,[l,h]=this.viewport.rotation%180==0?[r,o]:[o,r];return{offsetX:l,offsetY:h}}addNewEditor(){this.createAndAddNewEditor(this.#vo(),!0)}setSelected(t){this.#m.setSelected(t)}toggleSelected(t){this.#m.toggleSelected(t)}unselect(t){this.#m.unselect(t)}pointerup(t){const{isMac:e}=util_FeatureTest.platform;if(!(0!==t.button||t.ctrlKey&&e)&&t.target===this.div&&this.#ho){this.#ho=!1;this.#Ao?.isDrawer&&this.#Ao.supportMultipleDrawings||(this.#no?this.#m.getMode()!==g.STAMP?this.createAndAddNewEditor(t,!1):this.#m.unselectAll():this.#no=!0)}}pointerdown(t){this.#m.getMode()===g.HIGHLIGHT&&this.enableTextSelection();if(this.#ho){this.#ho=!1;return}const{isMac:e}=util_FeatureTest.platform;if(0!==t.button||t.ctrlKey&&e)return;if(t.target!==this.div)return;this.#ho=!0;if(this.#Ao?.isDrawer){this.startDrawingSession(t);return}const i=this.#m.getActive();this.#no=!i||i.isEmpty()}startDrawingSession(t){this.div.focus();if(this.#uo){this.#Ao.startDrawing(this,this.#m,!1,t);return}this.#m.setCurrentDrawingSession(this);this.#uo=new AbortController;const e=this.#m.combinedSignal(this.#uo);this.div.addEventListener("blur",(({relatedTarget:t})=>{if(t&&!this.div.contains(t)){this.#po=null;this.commitOrRemove()}}),{signal:e});this.#Ao.startDrawing(this,this.#m,!1,t)}pause(t){if(t){const{activeElement:t}=document;this.div.contains(t)&&(this.#po=t)}else this.#po&&setTimeout((()=>{this.#po?.focus();this.#po=null}),0)}endDrawingSession(t=!1){if(!this.#uo)return null;this.#m.setCurrentDrawingSession(null);this.#uo.abort();this.#uo=null;this.#po=null;return this.#Ao.endDrawing(t)}findNewParent(t,e,i){const s=this.#m.findParent(e,i);if(null===s||s===this)return!1;s.changeParent(t);return!0}commitOrRemove(){if(this.#uo){this.endDrawingSession();return!0}return!1}onScaleChanging(){this.#uo&&this.#Ao.onScaleChangingWhenDrawing(this)}destroy(){this.commitOrRemove();if(this.#m.getActive()?.parent===this){this.#m.commitOrRemove();this.#m.setActiveEditor(null)}if(this.#oo){clearTimeout(this.#oo);this.#oo=null}for(const t of this.#lo.values()){this.#Cn?.removePointerInTextLayer(t.contentDiv);t.setParent(null);t.isAttachedToDOM=!1;t.div.remove()}this.div=null;this.#lo.clear();this.#m.removeLayer(this)}#fo(){for(const t of this.#lo.values())t.isEmpty()&&t.remove()}render({viewport:t}){this.viewport=t;setLayerDimensions(this.div,t);for(const t of this.#m.getEditors(this.pageIndex)){this.add(t);t.rebuild()}this.updateMode()}update({viewport:t}){this.#m.commitOrRemove();this.#fo();const e=this.viewport.rotation,i=t.rotation;this.viewport=t;setLayerDimensions(this.div,{rotation:i});if(e!==i)for(const t of this.#lo.values())t.rotate(i)}get pageDimensions(){const{pageWidth:t,pageHeight:e}=this.viewport.rawDims;return[t,e]}get scale(){return this.#m.viewParameters.realScale}}class DrawLayer{#nn=null;#w=0;#yo=new Map;#xo=new Map;constructor({pageIndex:t}){this.pageIndex=t}setParent(t){if(this.#nn){if(this.#nn!==t){if(this.#yo.size>0)for(const e of this.#yo.values()){e.remove();t.append(e)}this.#nn=t}}else this.#nn=t}static get _svgFactory(){return shadow(this,"_svgFactory",new DOMSVGFactory)}static#_o(t,[e,i,s,n]){const{style:a}=t;a.top=100*i+"%";a.left=100*e+"%";a.width=100*s+"%";a.height=100*n+"%"}#Eo(){const t=DrawLayer._svgFactory.create(1,1,!0);this.#nn.append(t);t.setAttribute("aria-hidden",!0);return t}#So(t,e){const i=DrawLayer._svgFactory.createElement("clipPath");t.append(i);const s=`clip_${e}`;i.setAttribute("id",s);i.setAttribute("clipPathUnits","objectBoundingBox");const n=DrawLayer._svgFactory.createElement("use");i.append(n);n.setAttribute("href",`#${e}`);n.classList.add("clip");return s}#Co(t,e){for(const[i,s]of Object.entries(e))null===s?t.removeAttribute(i):t.setAttribute(i,s)}draw(t,e=!1,i=!1){const s=this.#w++,n=this.#Eo(),a=DrawLayer._svgFactory.createElement("defs");n.append(a);const r=DrawLayer._svgFactory.createElement("path");a.append(r);const o=`path_p${this.pageIndex}_${s}`;r.setAttribute("id",o);r.setAttribute("vector-effect","non-scaling-stroke");e&&this.#xo.set(s,r);const l=i?this.#So(a,o):null,h=DrawLayer._svgFactory.createElement("use");n.append(h);h.setAttribute("href",`#${o}`);this.updateProperties(n,t);this.#yo.set(s,n);return{id:s,clipPathId:`url(#${l})`}}drawOutline(t,e){const i=this.#w++,s=this.#Eo(),n=DrawLayer._svgFactory.createElement("defs");s.append(n);const a=DrawLayer._svgFactory.createElement("path");n.append(a);const r=`path_p${this.pageIndex}_${i}`;a.setAttribute("id",r);a.setAttribute("vector-effect","non-scaling-stroke");let o;if(e){const t=DrawLayer._svgFactory.createElement("mask");n.append(t);o=`mask_p${this.pageIndex}_${i}`;t.setAttribute("id",o);t.setAttribute("maskUnits","objectBoundingBox");const e=DrawLayer._svgFactory.createElement("rect");t.append(e);e.setAttribute("width","1");e.setAttribute("height","1");e.setAttribute("fill","white");const s=DrawLayer._svgFactory.createElement("use");t.append(s);s.setAttribute("href",`#${r}`);s.setAttribute("stroke","none");s.setAttribute("fill","black");s.setAttribute("fill-rule","nonzero");s.classList.add("mask")}const l=DrawLayer._svgFactory.createElement("use");s.append(l);l.setAttribute("href",`#${r}`);o&&l.setAttribute("mask",`url(#${o})`);const h=l.cloneNode();s.append(h);l.classList.add("mainOutline");h.classList.add("secondaryOutline");this.updateProperties(s,t);this.#yo.set(i,s);return i}finalizeDraw(t,e){this.#xo.delete(t);this.updateProperties(t,e)}updateProperties(t,e){if(!e)return;const{root:i,bbox:s,rootClass:n,path:a}=e,r="number"==typeof t?this.#yo.get(t):t;if(r){i&&this.#Co(r,i);s&&DrawLayer.#_o(r,s);if(n){const{classList:t}=r;for(const[e,i]of Object.entries(n))t.toggle(e,i)}if(a){const t=r.firstChild.firstChild;this.#Co(t,a)}}}updateParent(t,e){if(e===this)return;const i=this.#yo.get(t);if(i){e.#nn.append(i);this.#yo.delete(t);e.#yo.set(t,i)}}remove(t){this.#xo.delete(t);if(null!==this.#nn){this.#yo.get(t).remove();this.#yo.delete(t)}}destroy(){this.#nn=null;for(const t of this.#yo.values())t.remove();this.#yo.clear();this.#xo.clear()}}globalThis.pdfjsTestingUtils={HighlightOutliner};var Ut=__webpack_exports__.AbortException,Gt=__webpack_exports__.AnnotationEditorLayer,$t=__webpack_exports__.AnnotationEditorParamsType,Vt=__webpack_exports__.AnnotationEditorType,jt=__webpack_exports__.AnnotationEditorUIManager,Wt=__webpack_exports__.AnnotationLayer,qt=__webpack_exports__.AnnotationMode,Xt=__webpack_exports__.ColorPicker,Kt=__webpack_exports__.DOMSVGFactory,Yt=__webpack_exports__.DrawLayer,Qt=__webpack_exports__.FeatureTest,Jt=__webpack_exports__.GlobalWorkerOptions,Zt=__webpack_exports__.ImageKind,te=__webpack_exports__.InvalidPDFException,ee=__webpack_exports__.MissingPDFException,ie=__webpack_exports__.OPS,se=__webpack_exports__.OutputScale,ne=__webpack_exports__.PDFDataRangeTransport,ae=__webpack_exports__.PDFDateString,re=__webpack_exports__.PDFWorker,oe=__webpack_exports__.PasswordResponses,le=__webpack_exports__.PermissionFlag,he=__webpack_exports__.PixelsPerInch,de=__webpack_exports__.RenderingCancelledException,ce=__webpack_exports__.TextLayer,ue=__webpack_exports__.TouchManager,pe=__webpack_exports__.UnexpectedResponseException,ge=__webpack_exports__.Util,me=__webpack_exports__.VerbosityLevel,fe=__webpack_exports__.XfaLayer,be=__webpack_exports__.build,Ae=__webpack_exports__.createValidAbsoluteUrl,we=__webpack_exports__.fetchData,ve=__webpack_exports__.getDocument,ye=__webpack_exports__.getFilenameFromUrl,xe=__webpack_exports__.getPdfFilenameFromUrl,_e=__webpack_exports__.getXfaPageViewport,Ee=__webpack_exports__.isDataScheme,Se=__webpack_exports__.isPdfFile,Ce=__webpack_exports__.noContextMenu,Te=__webpack_exports__.normalizeUnicode,Me=__webpack_exports__.setLayerDimensions,Pe=__webpack_exports__.shadow,De=__webpack_exports__.stopEvent,ke=__webpack_exports__.version;export{Ut as AbortException,Gt as AnnotationEditorLayer,$t as AnnotationEditorParamsType,Vt as AnnotationEditorType,jt as AnnotationEditorUIManager,Wt as AnnotationLayer,qt as AnnotationMode,Xt as ColorPicker,Kt as DOMSVGFactory,Yt as DrawLayer,Qt as FeatureTest,Jt as GlobalWorkerOptions,Zt as ImageKind,te as InvalidPDFException,ee as MissingPDFException,ie as OPS,se as OutputScale,ne as PDFDataRangeTransport,ae as PDFDateString,re as PDFWorker,oe as PasswordResponses,le as PermissionFlag,he as PixelsPerInch,de as RenderingCancelledException,ce as TextLayer,ue as TouchManager,pe as UnexpectedResponseException,ge as Util,me as VerbosityLevel,fe as XfaLayer,be as build,Ae as createValidAbsoluteUrl,we as fetchData,ve as getDocument,ye as getFilenameFromUrl,xe as getPdfFilenameFromUrl,_e as getXfaPageViewport,Ee as isDataScheme,Se as isPdfFile,Ce as noContextMenu,Te as normalizeUnicode,Me as setLayerDimensions,Pe as shadow,De as stopEvent,ke as version}; \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs new file mode 100644 index 00000000000..ee4038504a6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/build/pdf.worker.min.mjs @@ -0,0 +1,21 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2024 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */var e={d:(t,i)=>{for(var a in i)e.o(i,a)&&!e.o(t,a)&&Object.defineProperty(t,a,{enumerable:!0,get:i[a]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},__webpack_exports__ = globalThis.pdfjsWorker = {};e.d(__webpack_exports__,{WorkerMessageHandler:()=>WorkerMessageHandler});const t=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type),i=[1,0,0,1,0,0],a=[.001,0,0,.001,0,0],s=1.35,r=.35,n=.25925925925925924,g=1,o=2,c=4,C=8,h=16,l=64,Q=128,E=256,u="pdfjs_internal_editor_",d=3,f=9,p=13,m=15,y={PRINT:4,MODIFY_CONTENTS:8,COPY:16,MODIFY_ANNOTATIONS:32,FILL_INTERACTIVE_FORMS:256,COPY_FOR_ACCESSIBILITY:512,ASSEMBLE:1024,PRINT_HIGH_QUALITY:2048},w=0,D=4,b=1,F=2,S=3,k=1,R=2,N=3,G=4,M=5,U=6,x=7,L=8,H=9,J=10,Y=11,v=12,K=13,T=14,q=15,O=16,W=17,j=20,X="Group",Z="R",V=1,z=2,_=4,$=16,AA=32,eA=128,tA=512,iA=1,aA=2,sA=4096,rA=8192,nA=32768,gA=65536,oA=131072,IA=1048576,cA=2097152,CA=8388608,hA=16777216,lA=1,BA=2,QA=3,EA=4,uA=5,dA={E:"Mouse Enter",X:"Mouse Exit",D:"Mouse Down",U:"Mouse Up",Fo:"Focus",Bl:"Blur",PO:"PageOpen",PC:"PageClose",PV:"PageVisible",PI:"PageInvisible",K:"Keystroke",F:"Format",V:"Validate",C:"Calculate"},fA={WC:"WillClose",WS:"WillSave",DS:"DidSave",WP:"WillPrint",DP:"DidPrint"},pA={O:"PageOpen",C:"PageClose"},mA=1,yA=5,wA=1,DA=2,bA=3,FA=4,SA=5,kA=6,RA=7,NA=8,GA=9,MA=10,UA=11,xA=12,LA=13,HA=14,JA=15,YA=16,vA=17,KA=18,TA=19,qA=20,OA=21,PA=22,WA=23,jA=24,XA=25,ZA=26,VA=27,zA=28,_A=29,$A=30,Ae=31,ee=32,te=33,ie=34,ae=35,se=36,re=37,ne=38,ge=39,oe=40,Ie=41,ce=42,Ce=43,he=44,le=45,Be=46,Qe=47,Ee=48,ue=49,de=50,fe=51,pe=52,me=53,ye=54,we=55,De=56,be=57,Fe=58,Se=59,ke=60,Re=61,Ne=62,Ge=63,Me=64,Ue=65,xe=66,Le=67,He=68,Je=69,Ye=70,ve=71,Ke=72,Te=73,qe=74,Oe=75,Pe=76,We=77,je=80,Xe=81,Ze=83,Ve=84,ze=85,_e=86,$e=87,At=88,et=89,tt=90,it=91,at=92,st=93,rt=1,nt=2;let gt=mA;function getVerbosityLevel(){return gt}function info(e){gt>=yA&&console.log(`Info: ${e}`)}function warn(e){gt>=mA&&console.log(`Warning: ${e}`)}function unreachable(e){throw new Error(e)}function assert(e,t){e||unreachable(t)}function createValidAbsoluteUrl(e,t=null,i=null){if(!e)return null;try{if(i&&"string"==typeof e){if(i.addDefaultProtocol&&e.startsWith("www.")){const t=e.match(/\./g);t?.length>=2&&(e=`http://${e}`)}if(i.tryConvertEncoding)try{e=stringToUTF8String(e)}catch{}}const a=t?new URL(e,t):new URL(e);if(function _isValidProtocol(e){switch(e?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(a))return a}catch{}return null}function shadow(e,t,i,a=!1){Object.defineProperty(e,t,{value:i,enumerable:!a,configurable:!0,writable:!1});return i}const ot=function BaseExceptionClosure(){function BaseException(e,t){this.message=e;this.name=t}BaseException.prototype=new Error;BaseException.constructor=BaseException;return BaseException}();class PasswordException extends ot{constructor(e,t){super(e,"PasswordException");this.code=t}}class UnknownErrorException extends ot{constructor(e,t){super(e,"UnknownErrorException");this.details=t}}class InvalidPDFException extends ot{constructor(e){super(e,"InvalidPDFException")}}class MissingPDFException extends ot{constructor(e){super(e,"MissingPDFException")}}class UnexpectedResponseException extends ot{constructor(e,t){super(e,"UnexpectedResponseException");this.status=t}}class FormatError extends ot{constructor(e){super(e,"FormatError")}}class AbortException extends ot{constructor(e){super(e,"AbortException")}}function bytesToString(e){"object"==typeof e&&void 0!==e?.length||unreachable("Invalid argument for bytesToString");const t=e.length,i=8192;if(t>24&255,e>>16&255,e>>8&255,255&e)}function objectSize(e){return Object.keys(e).length}class FeatureTest{static get isLittleEndian(){return shadow(this,"isLittleEndian",function isLittleEndian(){const e=new Uint8Array(4);e[0]=1;return 1===new Uint32Array(e.buffer,0,1)[0]}())}static get isEvalSupported(){return shadow(this,"isEvalSupported",function isEvalSupported(){try{new Function("");return!0}catch{return!1}}())}static get isOffscreenCanvasSupported(){return shadow(this,"isOffscreenCanvasSupported","undefined"!=typeof OffscreenCanvas)}static get isImageDecoderSupported(){return shadow(this,"isImageDecoderSupported","undefined"!=typeof ImageDecoder)}static get platform(){return"undefined"!=typeof navigator&&"string"==typeof navigator?.platform?shadow(this,"platform",{isMac:navigator.platform.includes("Mac"),isWindows:navigator.platform.includes("Win"),isFirefox:"string"==typeof navigator?.userAgent&&navigator.userAgent.includes("Firefox")}):shadow(this,"platform",{isMac:!1,isWindows:!1,isFirefox:!1})}static get isCSSRoundSupported(){return shadow(this,"isCSSRoundSupported",globalThis.CSS?.supports?.("width: round(1.5px, 1px)"))}}const It=Array.from(Array(256).keys(),(e=>e.toString(16).padStart(2,"0")));class Util{static makeHexColor(e,t,i){return`#${It[e]}${It[t]}${It[i]}`}static scaleMinMax(e,t){let i;if(e[0]){if(e[0]<0){i=t[0];t[0]=t[2];t[2]=i}t[0]*=e[0];t[2]*=e[0];if(e[3]<0){i=t[1];t[1]=t[3];t[3]=i}t[1]*=e[3];t[3]*=e[3]}else{i=t[0];t[0]=t[1];t[1]=i;i=t[2];t[2]=t[3];t[3]=i;if(e[1]<0){i=t[1];t[1]=t[3];t[3]=i}t[1]*=e[1];t[3]*=e[1];if(e[2]<0){i=t[0];t[0]=t[2];t[2]=i}t[0]*=e[2];t[2]*=e[2]}t[0]+=e[4];t[1]+=e[5];t[2]+=e[4];t[3]+=e[5]}static transform(e,t){return[e[0]*t[0]+e[2]*t[1],e[1]*t[0]+e[3]*t[1],e[0]*t[2]+e[2]*t[3],e[1]*t[2]+e[3]*t[3],e[0]*t[4]+e[2]*t[5]+e[4],e[1]*t[4]+e[3]*t[5]+e[5]]}static applyTransform(e,t){return[e[0]*t[0]+e[1]*t[2]+t[4],e[0]*t[1]+e[1]*t[3]+t[5]]}static applyInverseTransform(e,t){const i=t[0]*t[3]-t[1]*t[2];return[(e[0]*t[3]-e[1]*t[2]+t[2]*t[5]-t[4]*t[3])/i,(-e[0]*t[1]+e[1]*t[0]+t[4]*t[1]-t[5]*t[0])/i]}static getAxialAlignedBoundingBox(e,t){const i=this.applyTransform(e,t),a=this.applyTransform(e.slice(2,4),t),s=this.applyTransform([e[0],e[3]],t),r=this.applyTransform([e[2],e[1]],t);return[Math.min(i[0],a[0],s[0],r[0]),Math.min(i[1],a[1],s[1],r[1]),Math.max(i[0],a[0],s[0],r[0]),Math.max(i[1],a[1],s[1],r[1])]}static inverseTransform(e){const t=e[0]*e[3]-e[1]*e[2];return[e[3]/t,-e[1]/t,-e[2]/t,e[0]/t,(e[2]*e[5]-e[4]*e[3])/t,(e[4]*e[1]-e[5]*e[0])/t]}static singularValueDecompose2dScale(e){const t=[e[0],e[2],e[1],e[3]],i=e[0]*t[0]+e[1]*t[2],a=e[0]*t[1]+e[1]*t[3],s=e[2]*t[0]+e[3]*t[2],r=e[2]*t[1]+e[3]*t[3],n=(i+r)/2,g=Math.sqrt((i+r)**2-4*(i*r-s*a))/2,o=n+g||1,c=n-g||1;return[Math.sqrt(o),Math.sqrt(c)]}static normalizeRect(e){const t=e.slice(0);if(e[0]>e[2]){t[0]=e[2];t[2]=e[0]}if(e[1]>e[3]){t[1]=e[3];t[3]=e[1]}return t}static intersect(e,t){const i=Math.max(Math.min(e[0],e[2]),Math.min(t[0],t[2])),a=Math.min(Math.max(e[0],e[2]),Math.max(t[0],t[2]));if(i>a)return null;const s=Math.max(Math.min(e[1],e[3]),Math.min(t[1],t[3])),r=Math.min(Math.max(e[1],e[3]),Math.max(t[1],t[3]));return s>r?null:[i,s,a,r]}static#A(e,t,i,a,s,r,n,g,o,c){if(o<=0||o>=1)return;const C=1-o,h=o*o,l=h*o,Q=C*(C*(C*e+3*o*t)+3*h*i)+l*a,E=C*(C*(C*s+3*o*r)+3*h*n)+l*g;c[0]=Math.min(c[0],Q);c[1]=Math.min(c[1],E);c[2]=Math.max(c[2],Q);c[3]=Math.max(c[3],E)}static#e(e,t,i,a,s,r,n,g,o,c,C,h){if(Math.abs(o)<1e-12){Math.abs(c)>=1e-12&&this.#A(e,t,i,a,s,r,n,g,-C/c,h);return}const l=c**2-4*C*o;if(l<0)return;const Q=Math.sqrt(l),E=2*o;this.#A(e,t,i,a,s,r,n,g,(-c+Q)/E,h);this.#A(e,t,i,a,s,r,n,g,(-c-Q)/E,h)}static bezierBoundingBox(e,t,i,a,s,r,n,g,o){if(o){o[0]=Math.min(o[0],e,n);o[1]=Math.min(o[1],t,g);o[2]=Math.max(o[2],e,n);o[3]=Math.max(o[3],t,g)}else o=[Math.min(e,n),Math.min(t,g),Math.max(e,n),Math.max(t,g)];this.#e(e,i,s,n,t,a,r,g,3*(3*(i-s)-e+n),6*(e-2*i+s),3*(i-e),o);this.#e(e,i,s,n,t,a,r,g,3*(3*(a-r)-t+g),6*(t-2*a+r),3*(a-t),o);return o}}const ct=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,728,711,710,729,733,731,730,732,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8226,8224,8225,8230,8212,8211,402,8260,8249,8250,8722,8240,8222,8220,8221,8216,8217,8218,8482,64257,64258,321,338,352,376,381,305,322,339,353,382,0,8364];function stringToPDFString(e){if(e[0]>="ï"){let t;if("þ"===e[0]&&"ÿ"===e[1]){t="utf-16be";e.length%2==1&&(e=e.slice(0,-1))}else if("ÿ"===e[0]&&"þ"===e[1]){t="utf-16le";e.length%2==1&&(e=e.slice(0,-1))}else"ï"===e[0]&&"»"===e[1]&&"¿"===e[2]&&(t="utf-8");if(t)try{const i=new TextDecoder(t,{fatal:!0}),a=stringToBytes(e),s=i.decode(a);return s.includes("")?s.replaceAll(/\x1b[^\x1b]*(?:\x1b|$)/g,""):s}catch(e){warn(`stringToPDFString: "${e}".`)}}const t=[];for(let i=0,a=e.length;iIt[e])).join("")}"function"!=typeof Promise.try&&(Promise.try=function(e,...t){return new Promise((i=>{i(e(...t))}))});const lt=Symbol("CIRCULAR_REF"),Bt=Symbol("EOF");let Qt=Object.create(null),Et=Object.create(null),ut=Object.create(null);class Name{constructor(e){this.name=e}static get(e){return Et[e]||=new Name(e)}}class Cmd{constructor(e){this.cmd=e}static get(e){return Qt[e]||=new Cmd(e)}}const dt=function nonSerializableClosure(){return dt};class Dict{constructor(e=null){this._map=new Map;this.xref=e;this.objId=null;this.suppressEncryption=!1;this.__nonSerializable__=dt}assignXref(e){this.xref=e}get size(){return this._map.size}get(e,t,i){let a=this._map.get(e);if(void 0===a&&void 0!==t){a=this._map.get(t);void 0===a&&void 0!==i&&(a=this._map.get(i))}return a instanceof Ref&&this.xref?this.xref.fetch(a,this.suppressEncryption):a}async getAsync(e,t,i){let a=this._map.get(e);if(void 0===a&&void 0!==t){a=this._map.get(t);void 0===a&&void 0!==i&&(a=this._map.get(i))}return a instanceof Ref&&this.xref?this.xref.fetchAsync(a,this.suppressEncryption):a}getArray(e,t,i){let a=this._map.get(e);if(void 0===a&&void 0!==t){a=this._map.get(t);void 0===a&&void 0!==i&&(a=this._map.get(i))}a instanceof Ref&&this.xref&&(a=this.xref.fetch(a,this.suppressEncryption));if(Array.isArray(a)){a=a.slice();for(let e=0,t=a.length;e{unreachable("Should not call `set` on the empty dictionary.")};return shadow(this,"empty",e)}static merge({xref:e,dictArray:t,mergeSubDicts:i=!1}){const a=new Dict(e),s=new Map;for(const e of t)if(e instanceof Dict)for(const[t,a]of e._map){let e=s.get(t);if(void 0===e){e=[];s.set(t,e)}else if(!(i&&a instanceof Dict))continue;e.push(a)}for(const[t,i]of s){if(1===i.length||!(i[0]instanceof Dict)){a._map.set(t,i[0]);continue}const s=new Dict(e);for(const e of i)for(const[t,i]of e._map)s._map.has(t)||s._map.set(t,i);s.size>0&&a._map.set(t,s)}s.clear();return a.size>0?a:Dict.empty}clone(){const e=new Dict(this.xref);for(const t of this.getKeys())e.set(t,this.getRaw(t));return e}delete(e){delete this._map[e]}}class Ref{constructor(e,t){this.num=e;this.gen=t}toString(){return 0===this.gen?`${this.num}R`:`${this.num}R${this.gen}`}static fromString(e){const t=ut[e];if(t)return t;const i=/^(\d+)R(\d*)$/.exec(e);return i&&"0"!==i[1]?ut[e]=new Ref(parseInt(i[1]),i[2]?parseInt(i[2]):0):null}static get(e,t){const i=0===t?`${e}R`:`${e}R${t}`;return ut[i]||=new Ref(e,t)}}class RefSet{constructor(e=null){this._set=new Set(e?._set)}has(e){return this._set.has(e.toString())}put(e){this._set.add(e.toString())}remove(e){this._set.delete(e.toString())}[Symbol.iterator](){return this._set.values()}clear(){this._set.clear()}}class RefSetCache{constructor(){this._map=new Map}get size(){return this._map.size}get(e){return this._map.get(e.toString())}has(e){return this._map.has(e.toString())}put(e,t){this._map.set(e.toString(),t)}putAlias(e,t){this._map.set(e.toString(),this.get(t))}[Symbol.iterator](){return this._map.values()}clear(){this._map.clear()}*values(){yield*this._map.values()}*items(){for(const[e,t]of this._map)yield[Ref.fromString(e),t]}}function isName(e,t){return e instanceof Name&&(void 0===t||e.name===t)}function isCmd(e,t){return e instanceof Cmd&&(void 0===t||e.cmd===t)}function isDict(e,t){return e instanceof Dict&&(void 0===t||isName(e.get("Type"),t))}function isRefsEqual(e,t){return e.num===t.num&&e.gen===t.gen}class BaseStream{get length(){unreachable("Abstract getter `length` accessed")}get isEmpty(){unreachable("Abstract getter `isEmpty` accessed")}get isDataLoaded(){return shadow(this,"isDataLoaded",!0)}getByte(){unreachable("Abstract method `getByte` called")}getBytes(e){unreachable("Abstract method `getBytes` called")}async getImageData(e,t){return this.getBytes(e,t)}async asyncGetBytes(){unreachable("Abstract method `asyncGetBytes` called")}get isAsync(){return!1}get canAsyncDecodeImageFromBuffer(){return!1}async getTransferableImage(){return null}peekByte(){const e=this.getByte();-1!==e&&this.pos--;return e}peekBytes(e){const t=this.getBytes(e);this.pos-=t.length;return t}getUint16(){const e=this.getByte(),t=this.getByte();return-1===e||-1===t?-1:(e<<8)+t}getInt32(){return(this.getByte()<<24)+(this.getByte()<<16)+(this.getByte()<<8)+this.getByte()}getByteRange(e,t){unreachable("Abstract method `getByteRange` called")}getString(e){return bytesToString(this.getBytes(e))}skip(e){this.pos+=e||1}reset(){unreachable("Abstract method `reset` called")}moveStart(){unreachable("Abstract method `moveStart` called")}makeSubStream(e,t,i=null){unreachable("Abstract method `makeSubStream` called")}getBaseStreams(){return null}}const ft=/^[1-9]\.\d$/,pt=2**31-1;function getLookupTableFactory(e){let t;return function(){if(e){t=Object.create(null);e(t);e=null}return t}}class MissingDataException extends ot{constructor(e,t){super(`Missing data [${e}, ${t})`,"MissingDataException");this.begin=e;this.end=t}}class ParserEOFException extends ot{constructor(e){super(e,"ParserEOFException")}}class XRefEntryException extends ot{constructor(e){super(e,"XRefEntryException")}}class XRefParseException extends ot{constructor(e){super(e,"XRefParseException")}}function arrayBuffersToBytes(e){const t=e.length;if(0===t)return new Uint8Array(0);if(1===t)return new Uint8Array(e[0]);let i=0;for(let a=0;a0,"The number should be a positive integer.");const i="M".repeat(e/1e3|0)+mt[e%1e3/100|0]+mt[10+(e%100/10|0)]+mt[20+e%10];return t?i.toLowerCase():i}function log2(e){return e>0?Math.ceil(Math.log2(e)):0}function readInt8(e,t){return e[t]<<24>>24}function readUint16(e,t){return e[t]<<8|e[t+1]}function readUint32(e,t){return(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0}function isWhiteSpace(e){return 32===e||9===e||13===e||10===e}function isNumberArray(e,t){return Array.isArray(e)?(null===t||e.length===t)&&e.every((e=>"number"==typeof e)):ArrayBuffer.isView(e)&&(0===e.length||"number"==typeof e[0])&&(null===t||e.length===t)}function lookupMatrix(e,t){return isNumberArray(e,6)?e:t}function lookupRect(e,t){return isNumberArray(e,4)?e:t}function lookupNormalRect(e,t){return isNumberArray(e,4)?Util.normalizeRect(e):t}function parseXFAPath(e){const t=/(.+)\[(\d+)\]$/;return e.split(".").map((e=>{const i=e.match(t);return i?{name:i[1],pos:parseInt(i[2],10)}:{name:e,pos:0}}))}function escapePDFName(e){const t=[];let i=0;for(let a=0,s=e.length;a126||35===s||40===s||41===s||60===s||62===s||91===s||93===s||123===s||125===s||47===s||37===s){i"\n"===e?"\\n":"\r"===e?"\\r":`\\${e}`))}function _collectJS(e,t,i,a){if(!e)return;let s=null;if(e instanceof Ref){if(a.has(e))return;s=e;a.put(s);e=t.fetch(e)}if(Array.isArray(e))for(const s of e)_collectJS(s,t,i,a);else if(e instanceof Dict){if(isName(e.get("S"),"JavaScript")){const t=e.get("JS");let a;t instanceof BaseStream?a=t.getString():"string"==typeof t&&(a=t);a&&=stringToPDFString(a).replaceAll("\0","");a&&i.push(a)}_collectJS(e.getRaw("Next"),t,i,a)}s&&a.remove(s)}function collectActions(e,t,i){const a=Object.create(null),s=getInheritableProperty({dict:t,key:"AA",stopWhenFound:!1});if(s)for(let t=s.length-1;t>=0;t--){const r=s[t];if(r instanceof Dict)for(const t of r.getKeys()){const s=i[t];if(!s)continue;const n=[];_collectJS(r.getRaw(t),e,n,new RefSet);n.length>0&&(a[s]=n)}}if(t.has("A")){const i=[];_collectJS(t.get("A"),e,i,new RefSet);i.length>0&&(a.Action=i)}return objectSize(a)>0?a:null}const yt={60:"<",62:">",38:"&",34:""",39:"'"};function*codePointIter(e){for(let t=0,i=e.length;t55295&&(i<57344||i>65533)&&t++;yield i}}function encodeToXmlString(e){const t=[];let i=0;for(let a=0,s=e.length;a55295&&(s<57344||s>65533)&&a++;i=a+1}}if(0===t.length)return e;i: ${e}.`);return!1}return!0}function validateCSSFont(e){const t=new Set(["100","200","300","400","500","600","700","800","900","1000","normal","bold","bolder","lighter"]),{fontFamily:i,fontWeight:a,italicAngle:s}=e;if(!validateFontName(i,!0))return!1;const r=a?a.toString():"";e.fontWeight=t.has(r)?r:"400";const n=parseFloat(s);e.italicAngle=isNaN(n)||n<-90||n>90?"14":s.toString();return!0}function recoverJsURL(e){const t=new RegExp("^\\s*("+["app.launchURL","window.open","xfa.host.gotoURL"].join("|").replaceAll(".","\\.")+")\\((?:'|\")([^'\"]*)(?:'|\")(?:,\\s*(\\w+)\\)|\\))","i").exec(e);return t?.[2]?{url:t[2],newWindow:"app.launchURL"===t[1]&&"true"===t[3]}:null}function numberToString(e){if(Number.isInteger(e))return e.toString();const t=Math.round(100*e);return t%100==0?(t/100).toString():t%10==0?e.toFixed(1):e.toFixed(2)}function getNewAnnotationsMap(e){if(!e)return null;const t=new Map;for(const[i,a]of e){if(!i.startsWith(u))continue;let e=t.get(a.pageIndex);if(!e){e=[];t.set(a.pageIndex,e)}e.push(a)}return t.size>0?t:null}function stringToAsciiOrUTF16BE(e){return function isAscii(e){return/^[\x00-\x7F]*$/.test(e)}(e)?e:stringToUTF16String(e,!0)}function stringToUTF16HexString(e){const t=[];for(let i=0,a=e.length;i>8&255],It[255&a])}return t.join("")}function stringToUTF16String(e,t=!1){const i=[];t&&i.push("þÿ");for(let t=0,a=e.length;t>8&255),String.fromCharCode(255&a))}return i.join("")}function getRotationMatrix(e,t,i){switch(e){case 90:return[0,1,-1,0,t,0];case 180:return[-1,0,0,-1,t,i];case 270:return[0,-1,1,0,0,i];default:throw new Error("Invalid rotation")}}function getSizeInBytes(e){return Math.ceil(Math.ceil(Math.log2(1+e))/8)}class Stream extends BaseStream{constructor(e,t,i,a){super();this.bytes=e instanceof Uint8Array?e:new Uint8Array(e);this.start=t||0;this.pos=this.start;this.end=t+i||this.bytes.length;this.dict=a}get length(){return this.end-this.start}get isEmpty(){return 0===this.length}getByte(){return this.pos>=this.end?-1:this.bytes[this.pos++]}getBytes(e){const t=this.bytes,i=this.pos,a=this.end;if(!e)return t.subarray(i,a);let s=i+e;s>a&&(s=a);this.pos=s;return t.subarray(i,s)}getByteRange(e,t){e<0&&(e=0);t>this.end&&(t=this.end);return this.bytes.subarray(e,t)}reset(){this.pos=this.start}moveStart(){this.start=this.pos}makeSubStream(e,t,i=null){return new Stream(this.bytes.buffer,e,t,i)}}class StringStream extends Stream{constructor(e){super(stringToBytes(e))}}class NullStream extends Stream{constructor(){super(new Uint8Array(0))}}class ChunkedStream extends Stream{constructor(e,t,i){super(new Uint8Array(e),0,e,null);this.chunkSize=t;this._loadedChunks=new Set;this.numChunks=Math.ceil(e/t);this.manager=i;this.progressiveDataLength=0;this.lastSuccessfulEnsureByteChunk=-1}getMissingChunks(){const e=[];for(let t=0,i=this.numChunks;t=this.end?this.numChunks:Math.floor(t/this.chunkSize);for(let e=i;ethis.numChunks)&&t!==this.lastSuccessfulEnsureByteChunk){if(!this._loadedChunks.has(t))throw new MissingDataException(e,e+1);this.lastSuccessfulEnsureByteChunk=t}}ensureRange(e,t){if(e>=t)return;if(t<=this.progressiveDataLength)return;const i=Math.floor(e/this.chunkSize);if(i>this.numChunks)return;const a=Math.min(Math.floor((t-1)/this.chunkSize)+1,this.numChunks);for(let s=i;s=this.end)return-1;e>=this.progressiveDataLength&&this.ensureByte(e);return this.bytes[this.pos++]}getBytes(e){const t=this.bytes,i=this.pos,a=this.end;if(!e){a>this.progressiveDataLength&&this.ensureRange(i,a);return t.subarray(i,a)}let s=i+e;s>a&&(s=a);s>this.progressiveDataLength&&this.ensureRange(i,s);this.pos=s;return t.subarray(i,s)}getByteRange(e,t){e<0&&(e=0);t>this.end&&(t=this.end);t>this.progressiveDataLength&&this.ensureRange(e,t);return this.bytes.subarray(e,t)}makeSubStream(e,t,i=null){t?e+t>this.progressiveDataLength&&this.ensureRange(e,e+t):e>=this.progressiveDataLength&&this.ensureByte(e);function ChunkedStreamSubstream(){}ChunkedStreamSubstream.prototype=Object.create(this);ChunkedStreamSubstream.prototype.getMissingChunks=function(){const e=this.chunkSize,t=Math.floor(this.start/e),i=Math.floor((this.end-1)/e)+1,a=[];for(let e=t;e{const readChunk=({value:r,done:n})=>{try{if(n){const t=arrayBuffersToBytes(a);a=null;e(t);return}s+=r.byteLength;i.isStreamingSupported&&this.onProgress({loaded:s});a.push(r);i.read().then(readChunk,t)}catch(e){t(e)}};i.read().then(readChunk,t)})).then((t=>{this.aborted||this.onReceiveData({chunk:t,begin:e})}))}requestAllChunks(e=!1){if(!e){const e=this.stream.getMissingChunks();this._requestChunks(e)}return this._loadedStreamCapability.promise}_requestChunks(e){const t=this.currRequestId++,i=new Set;this._chunksNeededByRequest.set(t,i);for(const t of e)this.stream.hasChunk(t)||i.add(t);if(0===i.size)return Promise.resolve();const a=Promise.withResolvers();this._promisesByRequest.set(t,a);const s=[];for(const e of i){let i=this._requestsByChunk.get(e);if(!i){i=[];this._requestsByChunk.set(e,i);s.push(e)}i.push(t)}if(s.length>0){const e=this.groupChunks(s);for(const t of e){const e=t.beginChunk*this.chunkSize,i=Math.min(t.endChunk*this.chunkSize,this.length);this.sendRequest(e,i).catch(a.reject)}}return a.promise.catch((e=>{if(!this.aborted)throw e}))}getStream(){return this.stream}requestRange(e,t){t=Math.min(t,this.length);const i=this.getBeginChunk(e),a=this.getEndChunk(t),s=[];for(let e=i;e=0&&a+1!==r){t.push({beginChunk:i,endChunk:a+1});i=r}s+1===e.length&&t.push({beginChunk:i,endChunk:r+1});a=r}return t}onProgress(e){this.msgHandler.send("DocProgress",{loaded:this.stream.numChunksLoaded*this.chunkSize+e.loaded,total:this.length})}onReceiveData(e){const t=e.chunk,i=void 0===e.begin,a=i?this.progressiveDataLength:e.begin,s=a+t.byteLength,r=Math.floor(a/this.chunkSize),n=s0||g.push(i)}}}if(!this.disableAutoFetch&&0===this._requestsByChunk.size){let e;if(1===this.stream.numChunksLoaded){const t=this.stream.numChunks-1;this.stream.hasChunk(t)||(e=t)}else e=this.stream.nextEmptyChunk(n);Number.isInteger(e)&&this._requestChunks([e])}for(const e of g){const t=this._promisesByRequest.get(e);this._promisesByRequest.delete(e);t.resolve()}this.msgHandler.send("DocProgress",{loaded:this.stream.numChunksLoaded*this.chunkSize,total:this.length})}onError(e){this._loadedStreamCapability.reject(e)}getBeginChunk(e){return Math.floor(e/this.chunkSize)}getEndChunk(e){return Math.floor((e-1)/this.chunkSize)+1}abort(e){this.aborted=!0;this.pdfNetworkStream?.cancelAllRequests(e);for(const t of this._promisesByRequest.values())t.reject(e)}}class ColorSpace{constructor(e,t){this.name=e;this.numComps=t}getRgb(e,t){const i=new Uint8ClampedArray(3);this.getRgbItem(e,t,i,0);return i}getRgbItem(e,t,i,a){unreachable("Should not call ColorSpace.getRgbItem")}getRgbBuffer(e,t,i,a,s,r,n){unreachable("Should not call ColorSpace.getRgbBuffer")}getOutputLength(e,t){unreachable("Should not call ColorSpace.getOutputLength")}isPassthrough(e){return!1}isDefaultDecode(e,t){return ColorSpace.isDefaultDecode(e,this.numComps)}fillRgb(e,t,i,a,s,r,n,g,o){const c=t*i;let C=null;const h=1<h&&"DeviceGray"!==this.name&&"DeviceRGB"!==this.name){const t=n<=8?new Uint8Array(h):new Uint16Array(h);for(let e=0;e=.99554525?1:this.#B(0,1,1.055*e**(1/2.4)-.055)}#B(e,t,i){return Math.max(e,Math.min(t,i))}#Q(e){return e<0?-this.#Q(-e):e>8?((e+16)/116)**3:e*CalRGBCS.#I}#E(e,t,i){if(0===e[0]&&0===e[1]&&0===e[2]){i[0]=t[0];i[1]=t[1];i[2]=t[2];return}const a=this.#Q(0),s=(1-a)/(1-this.#Q(e[0])),r=1-s,n=(1-a)/(1-this.#Q(e[1])),g=1-n,o=(1-a)/(1-this.#Q(e[2])),c=1-o;i[0]=t[0]*s+r;i[1]=t[1]*n+g;i[2]=t[2]*o+c}#u(e,t,i){if(1===e[0]&&1===e[2]){i[0]=t[0];i[1]=t[1];i[2]=t[2];return}const a=i;this.#c(CalRGBCS.#i,t,a);const s=CalRGBCS.#n;this.#C(e,a,s);this.#c(CalRGBCS.#a,s,i)}#d(e,t,i){const a=i;this.#c(CalRGBCS.#i,t,a);const s=CalRGBCS.#n;this.#h(e,a,s);this.#c(CalRGBCS.#a,s,i)}#t(e,t,i,a,s){const r=this.#B(0,1,e[t]*s),n=this.#B(0,1,e[t+1]*s),g=this.#B(0,1,e[t+2]*s),o=1===r?1:r**this.GR,c=1===n?1:n**this.GG,C=1===g?1:g**this.GB,h=this.MXA*o+this.MXB*c+this.MXC*C,l=this.MYA*o+this.MYB*c+this.MYC*C,Q=this.MZA*o+this.MZB*c+this.MZC*C,E=CalRGBCS.#g;E[0]=h;E[1]=l;E[2]=Q;const u=CalRGBCS.#o;this.#u(this.whitePoint,E,u);const d=CalRGBCS.#g;this.#E(this.blackPoint,u,d);const f=CalRGBCS.#o;this.#d(CalRGBCS.#r,d,f);const p=CalRGBCS.#g;this.#c(CalRGBCS.#s,f,p);i[a]=255*this.#l(p[0]);i[a+1]=255*this.#l(p[1]);i[a+2]=255*this.#l(p[2])}getRgbItem(e,t,i,a){this.#t(e,t,i,a,1)}getRgbBuffer(e,t,i,a,s,r,n){const g=1/((1<this.amax||this.bmin>this.bmax){info("Invalid Range, falling back to defaults");this.amin=-100;this.amax=100;this.bmin=-100;this.bmax=100}}#f(e){return e>=6/29?e**3:108/841*(e-4/29)}#p(e,t,i,a){return i+e*(a-i)/t}#t(e,t,i,a,s){let r=e[t],n=e[t+1],g=e[t+2];if(!1!==i){r=this.#p(r,i,0,100);n=this.#p(n,i,this.amin,this.amax);g=this.#p(g,i,this.bmin,this.bmax)}n>this.amax?n=this.amax:nthis.bmax?g=this.bmax:g>>0}function hexToStr(e,t){return 1===t?String.fromCharCode(e[0],e[1]):3===t?String.fromCharCode(e[0],e[1],e[2],e[3]):String.fromCharCode(...e.subarray(0,t+1))}function addHex(e,t,i){let a=0;for(let s=i;s>=0;s--){a+=e[s]+t[s];e[s]=255&a;a>>=8}}function incHex(e,t){let i=1;for(let a=t;a>=0&&i>0;a--){i+=e[a];e[a]=255&i;i>>=8}}const wt=16;class BinaryCMapStream{constructor(e){this.buffer=e;this.pos=0;this.end=e.length;this.tmpBuf=new Uint8Array(19)}readByte(){return this.pos>=this.end?-1:this.buffer[this.pos++]}readNumber(){let e,t=0;do{const i=this.readByte();if(i<0)throw new FormatError("unexpected EOF in bcmap");e=!(128&i);t=t<<7|127&i}while(!e);return t}readSigned(){const e=this.readNumber();return 1&e?~(e>>>1):e>>>1}readHex(e,t){e.set(this.buffer.subarray(this.pos,this.pos+t+1));this.pos+=t+1}readHexNumber(e,t){let i;const a=this.tmpBuf;let s=0;do{const e=this.readByte();if(e<0)throw new FormatError("unexpected EOF in bcmap");i=!(128&e);a[s++]=127&e}while(!i);let r=t,n=0,g=0;for(;r>=0;){for(;g<8&&a.length>0;){n|=a[--s]<>=8;g-=8}}readHexSigned(e,t){this.readHexNumber(e,t);const i=1&e[t]?255:0;let a=0;for(let s=0;s<=t;s++){a=(1&a)<<8|e[s];e[s]=a>>1^i}}readString(){const e=this.readNumber(),t=new Array(e);for(let i=0;i=0;){const e=l>>5;if(7===e){switch(31&l){case 0:a.readString();break;case 1:r=a.readString()}continue}const i=!!(16&l),s=15&l;if(s+1>wt)throw new Error("BinaryCMapReader.process: Invalid dataSize.");const Q=1,E=a.readNumber();switch(e){case 0:a.readHex(n,s);a.readHexNumber(g,s);addHex(g,n,s);t.addCodespaceRange(s+1,hexToInt(n,s),hexToInt(g,s));for(let e=1;es&&(a=s)}else{for(;!this.eof;)this.readBlock(t);a=this.bufferLength}this.pos=a;return this.buffer.subarray(i,a)}async getImageData(e,t=null){if(!this.canAsyncDecodeImageFromBuffer)return this.getBytes(e,t);const i=await this.stream.asyncGetBytes();return this.decodeImage(i,t)}reset(){this.pos=0}makeSubStream(e,t,i=null){if(void 0===t)for(;!this.eof;)this.readBlock();else{const i=e+t;for(;this.bufferLength<=i&&!this.eof;)this.readBlock()}return new Stream(this.buffer,e,t,i)}getBaseStreams(){return this.str?this.str.getBaseStreams():null}}class StreamsSequenceStream extends DecodeStream{constructor(e,t=null){e=e.filter((e=>e instanceof BaseStream));let i=0;for(const t of e)i+=t instanceof DecodeStream?t._rawMinBufferLength:t.length;super(i);this.streams=e;this._onError=t}readBlock(){const e=this.streams;if(0===e.length){this.eof=!0;return}const t=e.shift();let i;try{i=t.getBytes()}catch(e){if(this._onError){this._onError(e,t.dict?.objId);return}throw e}const a=this.bufferLength,s=a+i.length;this.ensureBuffer(s).set(i,a);this.bufferLength=s}getBaseStreams(){const e=[];for(const t of this.streams){const i=t.getBaseStreams();i&&e.push(...i)}return e.length>0?e:null}}class Ascii85Stream extends DecodeStream{constructor(e,t){t&&(t*=.8);super(t);this.str=e;this.dict=e.dict;this.input=new Uint8Array(5)}readBlock(){const e=this.str;let t=e.getByte();for(;isWhiteSpace(t);)t=e.getByte();if(-1===t||126===t){this.eof=!0;return}const i=this.bufferLength;let a,s;if(122===t){a=this.ensureBuffer(i+4);for(s=0;s<4;++s)a[i+s]=0;this.bufferLength+=4}else{const r=this.input;r[0]=t;for(s=1;s<5;++s){t=e.getByte();for(;isWhiteSpace(t);)t=e.getByte();r[s]=t;if(-1===t||126===t)break}a=this.ensureBuffer(i+s-1);this.bufferLength+=s-1;if(s<5){for(;s<5;++s)r[s]=117;this.eof=!0}let n=0;for(s=0;s<5;++s)n=85*n+(r[s]-33);for(s=3;s>=0;--s){a[i+s]=255&n;n>>=8}}}}class AsciiHexStream extends DecodeStream{constructor(e,t){t&&(t*=.5);super(t);this.str=e;this.dict=e.dict;this.firstDigit=-1}readBlock(){const e=this.str.getBytes(8e3);if(!e.length){this.eof=!0;return}const t=e.length+1>>1,i=this.ensureBuffer(this.bufferLength+t);let a=this.bufferLength,s=this.firstDigit;for(const t of e){let e;if(t>=48&&t<=57)e=15&t;else{if(!(t>=65&&t<=70||t>=97&&t<=102)){if(62===t){this.eof=!0;break}continue}e=9+(15&t)}if(s<0)s=e;else{i[a++]=s<<4|e;s=-1}}if(s>=0&&this.eof){i[a++]=s<<4;s=-1}this.firstDigit=s;this.bufferLength=a}}const bt=-1,Ft=[[-1,-1],[-1,-1],[7,8],[7,7],[6,6],[6,6],[6,5],[6,5],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[4,0],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[3,3],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2]],St=[[-1,-1],[12,-2],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[11,1792],[11,1792],[12,1984],[12,2048],[12,2112],[12,2176],[12,2240],[12,2304],[11,1856],[11,1856],[11,1920],[11,1920],[12,2368],[12,2432],[12,2496],[12,2560]],kt=[[-1,-1],[-1,-1],[-1,-1],[-1,-1],[8,29],[8,29],[8,30],[8,30],[8,45],[8,45],[8,46],[8,46],[7,22],[7,22],[7,22],[7,22],[7,23],[7,23],[7,23],[7,23],[8,47],[8,47],[8,48],[8,48],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[6,13],[7,20],[7,20],[7,20],[7,20],[8,33],[8,33],[8,34],[8,34],[8,35],[8,35],[8,36],[8,36],[8,37],[8,37],[8,38],[8,38],[7,19],[7,19],[7,19],[7,19],[8,31],[8,31],[8,32],[8,32],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,1],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[6,12],[8,53],[8,53],[8,54],[8,54],[7,26],[7,26],[7,26],[7,26],[8,39],[8,39],[8,40],[8,40],[8,41],[8,41],[8,42],[8,42],[8,43],[8,43],[8,44],[8,44],[7,21],[7,21],[7,21],[7,21],[7,28],[7,28],[7,28],[7,28],[8,61],[8,61],[8,62],[8,62],[8,63],[8,63],[8,0],[8,0],[8,320],[8,320],[8,384],[8,384],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,10],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[5,11],[7,27],[7,27],[7,27],[7,27],[8,59],[8,59],[8,60],[8,60],[9,1472],[9,1536],[9,1600],[9,1728],[7,18],[7,18],[7,18],[7,18],[7,24],[7,24],[7,24],[7,24],[8,49],[8,49],[8,50],[8,50],[8,51],[8,51],[8,52],[8,52],[7,25],[7,25],[7,25],[7,25],[8,55],[8,55],[8,56],[8,56],[8,57],[8,57],[8,58],[8,58],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,192],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[6,1664],[8,448],[8,448],[8,512],[8,512],[9,704],[9,768],[8,640],[8,640],[8,576],[8,576],[9,832],[9,896],[9,960],[9,1024],[9,1088],[9,1152],[9,1216],[9,1280],[9,1344],[9,1408],[7,256],[7,256],[7,256],[7,256],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,2],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,128],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,8],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[5,9],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,16],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[6,17],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,4],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[4,5],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,14],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[6,15],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[5,64],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,6],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7],[4,7]],Rt=[[-1,-1],[-1,-1],[12,-2],[12,-2],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[-1,-1],[11,1792],[11,1792],[11,1792],[11,1792],[12,1984],[12,1984],[12,2048],[12,2048],[12,2112],[12,2112],[12,2176],[12,2176],[12,2240],[12,2240],[12,2304],[12,2304],[11,1856],[11,1856],[11,1856],[11,1856],[11,1920],[11,1920],[11,1920],[11,1920],[12,2368],[12,2368],[12,2432],[12,2432],[12,2496],[12,2496],[12,2560],[12,2560],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[10,18],[12,52],[12,52],[13,640],[13,704],[13,768],[13,832],[12,55],[12,55],[12,56],[12,56],[13,1280],[13,1344],[13,1408],[13,1472],[12,59],[12,59],[12,60],[12,60],[13,1536],[13,1600],[11,24],[11,24],[11,24],[11,24],[11,25],[11,25],[11,25],[11,25],[13,1664],[13,1728],[12,320],[12,320],[12,384],[12,384],[12,448],[12,448],[13,512],[13,576],[12,53],[12,53],[12,54],[12,54],[13,896],[13,960],[13,1024],[13,1088],[13,1152],[13,1216],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64],[10,64]],Nt=[[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[8,13],[11,23],[11,23],[12,50],[12,51],[12,44],[12,45],[12,46],[12,47],[12,57],[12,58],[12,61],[12,256],[10,16],[10,16],[10,16],[10,16],[10,17],[10,17],[10,17],[10,17],[12,48],[12,49],[12,62],[12,63],[12,30],[12,31],[12,32],[12,33],[12,40],[12,41],[11,22],[11,22],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[8,14],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,10],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[7,11],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[9,15],[12,128],[12,192],[12,26],[12,27],[12,28],[12,29],[11,19],[11,19],[11,20],[11,20],[12,34],[12,35],[12,36],[12,37],[12,38],[12,39],[11,21],[11,21],[12,42],[12,43],[10,0],[10,0],[10,0],[10,0],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12],[7,12]],Gt=[[-1,-1],[-1,-1],[-1,-1],[-1,-1],[6,9],[6,8],[5,7],[5,7],[4,6],[4,6],[4,6],[4,6],[4,5],[4,5],[4,5],[4,5],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[3,4],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,3],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2],[2,2]];class CCITTFaxDecoder{constructor(e,t={}){if("function"!=typeof e?.next)throw new Error('CCITTFaxDecoder - invalid "source" parameter.');this.source=e;this.eof=!1;this.encoding=t.K||0;this.eoline=t.EndOfLine||!1;this.byteAlign=t.EncodedByteAlign||!1;this.columns=t.Columns||1728;this.rows=t.Rows||0;this.eoblock=t.EndOfBlock??!0;this.black=t.BlackIs1||!1;this.codingLine=new Uint32Array(this.columns+1);this.refLine=new Uint32Array(this.columns+2);this.codingLine[0]=this.columns;this.codingPos=0;this.row=0;this.nextLine2D=this.encoding<0;this.inputBits=0;this.inputBuf=0;this.outputBits=0;this.rowsDone=!1;let i;for(;0===(i=this._lookBits(12));)this._eatBits(1);1===i&&this._eatBits(12);if(this.encoding>0){this.nextLine2D=!this._lookBits(1);this._eatBits(1)}}readNextChar(){if(this.eof)return-1;const e=this.refLine,t=this.codingLine,i=this.columns;let a,s,r,n,g;if(0===this.outputBits){this.rowsDone&&(this.eof=!0);if(this.eof)return-1;this.err=!1;let r,g,o;if(this.nextLine2D){for(n=0;t[n]=64);do{g+=o=this._getWhiteCode()}while(o>=64)}else{do{r+=o=this._getWhiteCode()}while(o>=64);do{g+=o=this._getBlackCode()}while(o>=64)}this._addPixels(t[this.codingPos]+r,s);t[this.codingPos]0?--a:++a;for(;e[a]<=t[this.codingPos]&&e[a]0?--a:++a;for(;e[a]<=t[this.codingPos]&&e[a]0?--a:++a;for(;e[a]<=t[this.codingPos]&&e[a]=64);else do{r+=o=this._getWhiteCode()}while(o>=64);this._addPixels(t[this.codingPos]+r,s);s^=1}}let c=!1;this.byteAlign&&(this.inputBits&=-8);if(this.eoblock||this.row!==this.rows-1){r=this._lookBits(12);if(this.eoline)for(;r!==bt&&1!==r;){this._eatBits(1);r=this._lookBits(12)}else for(;0===r;){this._eatBits(1);r=this._lookBits(12)}if(1===r){this._eatBits(12);c=!0}else r===bt&&(this.eof=!0)}else this.rowsDone=!0;if(!this.eof&&this.encoding>0&&!this.rowsDone){this.nextLine2D=!this._lookBits(1);this._eatBits(1)}if(this.eoblock&&c&&this.byteAlign){r=this._lookBits(12);if(1===r){this._eatBits(12);if(this.encoding>0){this._lookBits(1);this._eatBits(1)}if(this.encoding>=0)for(n=0;n<4;++n){r=this._lookBits(12);1!==r&&info("bad rtc code: "+r);this._eatBits(12);if(this.encoding>0){this._lookBits(1);this._eatBits(1)}}this.eof=!0}}else if(this.err&&this.eoline){for(;;){r=this._lookBits(13);if(r===bt){this.eof=!0;return-1}if(r>>1==1)break;this._eatBits(1)}this._eatBits(12);if(this.encoding>0){this._eatBits(1);this.nextLine2D=!(1&r)}}this.outputBits=t[0]>0?t[this.codingPos=0]:t[this.codingPos=1];this.row++}if(this.outputBits>=8){g=1&this.codingPos?0:255;this.outputBits-=8;if(0===this.outputBits&&t[this.codingPos]r){g<<=r;1&this.codingPos||(g|=255>>8-r);this.outputBits-=r;r=0}else{g<<=this.outputBits;1&this.codingPos||(g|=255>>8-this.outputBits);r-=this.outputBits;this.outputBits=0;if(t[this.codingPos]0){g<<=r;r=0}}}while(r)}this.black&&(g^=255);return g}_addPixels(e,t){const i=this.codingLine;let a=this.codingPos;if(e>i[a]){if(e>this.columns){info("row is wrong length");this.err=!0;e=this.columns}1&a^t&&++a;i[a]=e}this.codingPos=a}_addPixelsNeg(e,t){const i=this.codingLine;let a=this.codingPos;if(e>i[a]){if(e>this.columns){info("row is wrong length");this.err=!0;e=this.columns}1&a^t&&++a;i[a]=e}else if(e0&&e=s){const t=i[e-s];if(t[0]===a){this._eatBits(a);return[!0,t[1],!0]}}}return[!1,0,!1]}_getTwoDimCode(){let e,t=0;if(this.eoblock){t=this._lookBits(7);e=Ft[t];if(e?.[0]>0){this._eatBits(e[0]);return e[1]}}else{const e=this._findTableCode(1,7,Ft);if(e[0]&&e[2])return e[1]}info("Bad two dim code");return bt}_getWhiteCode(){let e,t=0;if(this.eoblock){t=this._lookBits(12);if(t===bt)return 1;e=t>>5?kt[t>>3]:St[t];if(e[0]>0){this._eatBits(e[0]);return e[1]}}else{let e=this._findTableCode(1,9,kt);if(e[0])return e[1];e=this._findTableCode(11,12,St);if(e[0])return e[1]}info("bad white code");this._eatBits(1);return 1}_getBlackCode(){let e,t;if(this.eoblock){e=this._lookBits(13);if(e===bt)return 1;t=e>>7?!(e>>9)&&e>>7?Nt[(e>>1)-64]:Gt[e>>7]:Rt[e];if(t[0]>0){this._eatBits(t[0]);return t[1]}}else{let e=this._findTableCode(2,6,Gt);if(e[0])return e[1];e=this._findTableCode(7,12,Nt,64);if(e[0])return e[1];e=this._findTableCode(10,13,Rt);if(e[0])return e[1]}info("bad black code");this._eatBits(1);return 1}_lookBits(e){let t;for(;this.inputBits>16-e;this.inputBuf=this.inputBuf<<8|t;this.inputBits+=8}return this.inputBuf>>this.inputBits-e&65535>>16-e}_eatBits(e){(this.inputBits-=e)<0&&(this.inputBits=0)}}class CCITTFaxStream extends DecodeStream{constructor(e,t,i){super(t);this.str=e;this.dict=e.dict;i instanceof Dict||(i=Dict.empty);const a={next:()=>e.getByte()};this.ccittFaxDecoder=new CCITTFaxDecoder(a,{K:i.get("K"),EndOfLine:i.get("EndOfLine"),EncodedByteAlign:i.get("EncodedByteAlign"),Columns:i.get("Columns"),Rows:i.get("Rows"),EndOfBlock:i.get("EndOfBlock"),BlackIs1:i.get("BlackIs1")})}readBlock(){for(;!this.eof;){const e=this.ccittFaxDecoder.readNextChar();if(-1===e){this.eof=!0;return}this.ensureBuffer(this.bufferLength+1);this.buffer[this.bufferLength++]=e}}}const Mt=new Int32Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Ut=new Int32Array([3,4,5,6,7,8,9,10,65547,65549,65551,65553,131091,131095,131099,131103,196643,196651,196659,196667,262211,262227,262243,262259,327811,327843,327875,327907,258,258,258]),xt=new Int32Array([1,2,3,4,65541,65543,131081,131085,196625,196633,262177,262193,327745,327777,393345,393409,459009,459137,524801,525057,590849,591361,657409,658433,724993,727041,794625,798721,868353,876545]),Lt=[new Int32Array([459008,524368,524304,524568,459024,524400,524336,590016,459016,524384,524320,589984,524288,524416,524352,590048,459012,524376,524312,589968,459028,524408,524344,590032,459020,524392,524328,59e4,524296,524424,524360,590064,459010,524372,524308,524572,459026,524404,524340,590024,459018,524388,524324,589992,524292,524420,524356,590056,459014,524380,524316,589976,459030,524412,524348,590040,459022,524396,524332,590008,524300,524428,524364,590072,459009,524370,524306,524570,459025,524402,524338,590020,459017,524386,524322,589988,524290,524418,524354,590052,459013,524378,524314,589972,459029,524410,524346,590036,459021,524394,524330,590004,524298,524426,524362,590068,459011,524374,524310,524574,459027,524406,524342,590028,459019,524390,524326,589996,524294,524422,524358,590060,459015,524382,524318,589980,459031,524414,524350,590044,459023,524398,524334,590012,524302,524430,524366,590076,459008,524369,524305,524569,459024,524401,524337,590018,459016,524385,524321,589986,524289,524417,524353,590050,459012,524377,524313,589970,459028,524409,524345,590034,459020,524393,524329,590002,524297,524425,524361,590066,459010,524373,524309,524573,459026,524405,524341,590026,459018,524389,524325,589994,524293,524421,524357,590058,459014,524381,524317,589978,459030,524413,524349,590042,459022,524397,524333,590010,524301,524429,524365,590074,459009,524371,524307,524571,459025,524403,524339,590022,459017,524387,524323,589990,524291,524419,524355,590054,459013,524379,524315,589974,459029,524411,524347,590038,459021,524395,524331,590006,524299,524427,524363,590070,459011,524375,524311,524575,459027,524407,524343,590030,459019,524391,524327,589998,524295,524423,524359,590062,459015,524383,524319,589982,459031,524415,524351,590046,459023,524399,524335,590014,524303,524431,524367,590078,459008,524368,524304,524568,459024,524400,524336,590017,459016,524384,524320,589985,524288,524416,524352,590049,459012,524376,524312,589969,459028,524408,524344,590033,459020,524392,524328,590001,524296,524424,524360,590065,459010,524372,524308,524572,459026,524404,524340,590025,459018,524388,524324,589993,524292,524420,524356,590057,459014,524380,524316,589977,459030,524412,524348,590041,459022,524396,524332,590009,524300,524428,524364,590073,459009,524370,524306,524570,459025,524402,524338,590021,459017,524386,524322,589989,524290,524418,524354,590053,459013,524378,524314,589973,459029,524410,524346,590037,459021,524394,524330,590005,524298,524426,524362,590069,459011,524374,524310,524574,459027,524406,524342,590029,459019,524390,524326,589997,524294,524422,524358,590061,459015,524382,524318,589981,459031,524414,524350,590045,459023,524398,524334,590013,524302,524430,524366,590077,459008,524369,524305,524569,459024,524401,524337,590019,459016,524385,524321,589987,524289,524417,524353,590051,459012,524377,524313,589971,459028,524409,524345,590035,459020,524393,524329,590003,524297,524425,524361,590067,459010,524373,524309,524573,459026,524405,524341,590027,459018,524389,524325,589995,524293,524421,524357,590059,459014,524381,524317,589979,459030,524413,524349,590043,459022,524397,524333,590011,524301,524429,524365,590075,459009,524371,524307,524571,459025,524403,524339,590023,459017,524387,524323,589991,524291,524419,524355,590055,459013,524379,524315,589975,459029,524411,524347,590039,459021,524395,524331,590007,524299,524427,524363,590071,459011,524375,524311,524575,459027,524407,524343,590031,459019,524391,524327,589999,524295,524423,524359,590063,459015,524383,524319,589983,459031,524415,524351,590047,459023,524399,524335,590015,524303,524431,524367,590079]),9],Ht=[new Int32Array([327680,327696,327688,327704,327684,327700,327692,327708,327682,327698,327690,327706,327686,327702,327694,0,327681,327697,327689,327705,327685,327701,327693,327709,327683,327699,327691,327707,327687,327703,327695,0]),5];class FlateStream extends DecodeStream{constructor(e,t){super(t);this.str=e;this.dict=e.dict;const i=e.getByte(),a=e.getByte();if(-1===i||-1===a)throw new FormatError(`Invalid header in flate stream: ${i}, ${a}`);if(8!=(15&i))throw new FormatError(`Unknown compression method in flate stream: ${i}, ${a}`);if(((i<<8)+a)%31!=0)throw new FormatError(`Bad FCHECK in flate stream: ${i}, ${a}`);if(32&a)throw new FormatError(`FDICT bit set in flate stream: ${i}, ${a}`);this.codeSize=0;this.codeBuf=0}async getImageData(e,t){const i=await this.asyncGetBytes();return i?.subarray(0,e)||this.getBytes(e)}async asyncGetBytes(){this.str.reset();const e=this.str.getBytes();try{const{readable:t,writable:i}=new DecompressionStream("deflate"),a=i.getWriter();await a.ready;a.write(e).then((async()=>{await a.ready;await a.close()})).catch((()=>{}));const s=[];let r=0;for await(const e of t){s.push(e);r+=e.byteLength}const n=new Uint8Array(r);let g=0;for(const e of s){n.set(e,g);g+=e.byteLength}return n}catch{this.str=new Stream(e,2,e.length,this.str.dict);this.reset();return null}}get isAsync(){return!0}getBits(e){const t=this.str;let i,a=this.codeSize,s=this.codeBuf;for(;a>e;this.codeSize=a-=e;return i}getCode(e){const t=this.str,i=e[0],a=e[1];let s,r=this.codeSize,n=this.codeBuf;for(;r>16,c=65535&g;if(o<1||r>o;this.codeSize=r-o;return c}generateHuffmanTable(e){const t=e.length;let i,a=0;for(i=0;ia&&(a=e[i]);const s=1<>=1}for(i=e;i>=1;if(0===t){let t;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}let i=t;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}i|=t<<8;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}let s=t;if(-1===(t=a.getByte())){this.#m("Bad block header in flate stream");return}s|=t<<8;if(s!==(65535&~i)&&(0!==i||0!==s))throw new FormatError("Bad uncompressed block length in flate stream");this.codeBuf=0;this.codeSize=0;const r=this.bufferLength,n=r+i;e=this.ensureBuffer(n);this.bufferLength=n;if(0===i)-1===a.peekByte()&&(this.eof=!0);else{const t=a.getBytes(i);e.set(t,r);t.length0;)C[g++]=Q}s=this.generateHuffmanTable(C.subarray(0,e));r=this.generateHuffmanTable(C.subarray(e,c))}}e=this.buffer;let n=e?e.length:0,g=this.bufferLength;for(;;){let t=this.getCode(s);if(t<256){if(g+1>=n){e=this.ensureBuffer(g+1);n=e.length}e[g++]=t;continue}if(256===t){this.bufferLength=g;return}t-=257;t=Ut[t];let a=t>>16;a>0&&(a=this.getBits(a));i=(65535&t)+a;t=this.getCode(r);t=xt[t];a=t>>16;a>0&&(a=this.getBits(a));const o=(65535&t)+a;if(g+i>=n){e=this.ensureBuffer(g+i);n=e.length}for(let t=0;t>9&127;this.clow=this.clow<<7&65535;this.ct-=7;this.a=32768}byteIn(){const e=this.data;let t=this.bp;if(255===e[t])if(e[t+1]>143){this.clow+=65280;this.ct=8}else{t++;this.clow+=e[t]<<9;this.ct=7;this.bp=t}else{t++;this.clow+=t65535){this.chigh+=this.clow>>16;this.clow&=65535}}readBit(e,t){let i=e[t]>>1,a=1&e[t];const s=Jt[i],r=s.qe;let n,g=this.a-r;if(this.chigh>15&1;this.clow=this.clow<<1&65535;this.ct--}while(!(32768&g));this.a=g;e[t]=i<<1|a;return n}}class Jbig2Error extends ot{constructor(e){super(e,"Jbig2Error")}}class ContextCache{getContexts(e){return e in this?this[e]:this[e]=new Int8Array(65536)}}class DecodingContext{constructor(e,t,i){this.data=e;this.start=t;this.end=i}get decoder(){return shadow(this,"decoder",new ArithmeticDecoder(this.data,this.start,this.end))}get contextCache(){return shadow(this,"contextCache",new ContextCache)}}function decodeInteger(e,t,i){const a=e.getContexts(t);let s=1;function readBits(e){let t=0;for(let r=0;r>>0}const r=readBits(1),n=readBits(1)?readBits(1)?readBits(1)?readBits(1)?readBits(1)?readBits(32)+4436:readBits(12)+340:readBits(8)+84:readBits(6)+20:readBits(4)+4:readBits(2);let g;0===r?g=n:n>0&&(g=-n);return g>=-2147483648&&g<=pt?g:null}function decodeIAID(e,t,i){const a=e.getContexts("IAID");let s=1;for(let e=0;e=F&&x=S){K=K<<1&d;for(u=0;u=0&&H=0){J=G[L][H];J&&(K|=J<=e?c<<=1:c=c<<1|w[g][o]}for(Q=0;Q=m||o<0||o>=p?c<<=1:c=c<<1|a[g][o]}const E=D.readBit(b,c);t[n]=E}}return w}function decodeTextRegion(e,t,i,a,s,r,n,g,o,c,C,h,l,Q,E,u,d,f,p){if(e&&t)throw new Jbig2Error("refinement with Huffman is not supported");const m=[];let y,w;for(y=0;y1&&(s=e?p.readBits(f):decodeInteger(b,"IAIT",D));const r=n*F+s,S=e?Q.symbolIDTable.decode(p):decodeIAID(b,D,o),k=t&&(e?p.readBit():decodeInteger(b,"IARI",D));let R=g[S],N=R[0].length,G=R.length;if(k){const e=decodeInteger(b,"IARDW",D),t=decodeInteger(b,"IARDH",D);N+=e;G+=t;R=decodeRefinement(N,G,E,R,(e>>1)+decodeInteger(b,"IARDX",D),(t>>1)+decodeInteger(b,"IARDY",D),!1,u,d)}let M=0;c?1&h?M=G-1:a+=G-1:h>1?a+=N-1:M=N-1;const U=r-(1&h?0:G-1),x=a-(2&h?N-1:0);let L,H,J;if(c)for(L=0;L>5&7;const o=[31&n];let c=t+6;if(7===n){g=536870911&readUint32(e,c-1);c+=3;let t=g+7>>3;o[0]=e[c++];for(;--t>0;)o.push(e[c++])}else if(5===n||6===n)throw new Jbig2Error("invalid referred-to flags");i.retainBits=o;let C=4;i.number<=256?C=1:i.number<=65536&&(C=2);const h=[];let l,Q;for(l=0;l>>24&255;r[3]=t.height>>16&255;r[4]=t.height>>8&255;r[5]=255&t.height;for(l=c,Q=e.length;l>2&3;e.huffmanDWSelector=t>>4&3;e.bitmapSizeSelector=t>>6&1;e.aggregationInstancesSelector=t>>7&1;e.bitmapCodingContextUsed=!!(256&t);e.bitmapCodingContextRetained=!!(512&t);e.template=t>>10&3;e.refinementTemplate=t>>12&1;c+=2;if(!e.huffman){o=0===e.template?4:1;n=[];for(g=0;g>2&3;C.stripSize=1<>4&3;C.transposed=!!(64&h);C.combinationOperator=h>>7&3;C.defaultPixelValue=h>>9&1;C.dsOffset=h<<17>>27;C.refinementTemplate=h>>15&1;if(C.huffman){const e=readUint16(a,c);c+=2;C.huffmanFS=3&e;C.huffmanDS=e>>2&3;C.huffmanDT=e>>4&3;C.huffmanRefinementDW=e>>6&3;C.huffmanRefinementDH=e>>8&3;C.huffmanRefinementDX=e>>10&3;C.huffmanRefinementDY=e>>12&3;C.huffmanRefinementSizeSelector=!!(16384&e)}if(C.refinement&&!C.refinementTemplate){n=[];for(g=0;g<2;g++){n.push({x:readInt8(a,c),y:readInt8(a,c+1)});c+=2}C.refinementAt=n}C.numberOfSymbolInstances=readUint32(a,c);c+=4;r=[C,i.referredTo,a,c,s];break;case 16:const l={},Q=a[c++];l.mmr=!!(1&Q);l.template=Q>>1&3;l.patternWidth=a[c++];l.patternHeight=a[c++];l.maxPatternIndex=readUint32(a,c);c+=4;r=[l,i.number,a,c,s];break;case 22:case 23:const E={};E.info=readRegionSegmentInformation(a,c);c+=Ot;const u=a[c++];E.mmr=!!(1&u);E.template=u>>1&3;E.enableSkip=!!(8&u);E.combinationOperator=u>>4&7;E.defaultPixelValue=u>>7&1;E.gridWidth=readUint32(a,c);c+=4;E.gridHeight=readUint32(a,c);c+=4;E.gridOffsetX=4294967295&readUint32(a,c);c+=4;E.gridOffsetY=4294967295&readUint32(a,c);c+=4;E.gridVectorX=readUint16(a,c);c+=2;E.gridVectorY=readUint16(a,c);c+=2;r=[E,i.referredTo,a,c,s];break;case 38:case 39:const d={};d.info=readRegionSegmentInformation(a,c);c+=Ot;const f=a[c++];d.mmr=!!(1&f);d.template=f>>1&3;d.prediction=!!(8&f);if(!d.mmr){o=0===d.template?4:1;n=[];for(g=0;g>2&1;p.combinationOperator=m>>3&3;p.requiresBuffer=!!(32&m);p.combinationOperatorOverride=!!(64&m);r=[p];break;case 49:case 50:case 51:case 62:break;case 53:r=[i.number,a,c,s];break;default:throw new Jbig2Error(`segment type ${i.typeName}(${i.type}) is not implemented`)}const C="on"+i.typeName;C in t&&t[C].apply(t,r)}function processSegments(e,t){for(let i=0,a=e.length;i>3,i=new Uint8ClampedArray(t*e.height);e.defaultPixelValue&&i.fill(255);this.buffer=i}drawBitmap(e,t){const i=this.currentPageInfo,a=e.width,s=e.height,r=i.width+7>>3,n=i.combinationOperatorOverride?e.combinationOperator:i.combinationOperator,g=this.buffer,o=128>>(7&e.x);let c,C,h,l,Q=e.y*r+(e.x>>3);switch(n){case 0:for(c=0;c>=1;if(!h){h=128;l++}}Q+=r}break;case 2:for(c=0;c>=1;if(!h){h=128;l++}}Q+=r}break;default:throw new Jbig2Error(`operator ${n} is not supported`)}}onImmediateGenericRegion(e,t,i,a){const s=e.info,r=new DecodingContext(t,i,a),n=decodeBitmap(e.mmr,s.width,s.height,e.template,e.prediction,null,e.at,r);this.drawBitmap(s,n)}onImmediateLosslessGenericRegion(){this.onImmediateGenericRegion(...arguments)}onSymbolDictionary(e,t,i,a,s,r){let n,g;if(e.huffman){n=function getSymbolDictionaryHuffmanTables(e,t,i){let a,s,r,n,g=0;switch(e.huffmanDHSelector){case 0:case 1:a=getStandardTable(e.huffmanDHSelector+4);break;case 3:a=getCustomHuffmanTable(g,t,i);g++;break;default:throw new Jbig2Error("invalid Huffman DH selector")}switch(e.huffmanDWSelector){case 0:case 1:s=getStandardTable(e.huffmanDWSelector+2);break;case 3:s=getCustomHuffmanTable(g,t,i);g++;break;default:throw new Jbig2Error("invalid Huffman DW selector")}if(e.bitmapSizeSelector){r=getCustomHuffmanTable(g,t,i);g++}else r=getStandardTable(1);n=e.aggregationInstancesSelector?getCustomHuffmanTable(g,t,i):getStandardTable(1);return{tableDeltaHeight:a,tableDeltaWidth:s,tableBitmapSize:r,tableAggregateInstances:n}}(e,i,this.customTables);g=new Reader(a,s,r)}let o=this.symbols;o||(this.symbols=o={});const c=[];for(const e of i){const t=o[e];t&&c.push(...t)}const C=new DecodingContext(a,s,r);o[t]=function decodeSymbolDictionary(e,t,i,a,s,r,n,g,o,c,C,h){if(e&&t)throw new Jbig2Error("symbol refinement with Huffman is not supported");const l=[];let Q=0,E=log2(i.length+a);const u=C.decoder,d=C.contextCache;let f,p;if(e){f=getStandardTable(1);p=[];E=Math.max(E,1)}for(;l.length1)m=decodeTextRegion(e,t,a,Q,0,s,1,i.concat(l),E,0,0,1,0,r,o,c,C,0,h);else{const e=decodeIAID(d,u,E),t=decodeInteger(d,"IARDX",u),s=decodeInteger(d,"IARDY",u);m=decodeRefinement(a,Q,o,e=32){let i,a,n;switch(t){case 32:if(0===e)throw new Jbig2Error("no previous value in symbol ID table");a=s.readBits(2)+3;i=r[e-1].prefixLength;break;case 33:a=s.readBits(3)+3;i=0;break;case 34:a=s.readBits(7)+11;i=0;break;default:throw new Jbig2Error("invalid code length in symbol ID table")}for(n=0;n=0;d--){R=e?decodeMMRBitmap(k,o,c,!0):decodeBitmap(!1,o,c,i,!1,null,F,E);S[d]=R}for(N=0;N=0;f--){M^=S[f][N][G];U|=M<>8;H=h+N*l-G*Q>>8;if(L>=0&&L+w<=a&&H>=0&&H+D<=s)for(d=0;d=s)){Y=u[t];J=x[d];for(f=0;f=0&&e>1&7),o=1+(a>>4&7),c=[];let C,h,l=s;do{C=n.readBits(g);h=n.readBits(o);c.push(new HuffmanLine([l,C,h,0]));l+=1<>t&1;if(t<=0)this.children[i]=new HuffmanTreeNode(e);else{let a=this.children[i];a||(this.children[i]=a=new HuffmanTreeNode(null));a.buildTree(e,t-1)}}decodeNode(e){if(this.isLeaf){if(this.isOOB)return null;const t=e.readBits(this.rangeLength);return this.rangeLow+(this.isLowerRange?-t:t)}const t=this.children[e.readBit()];if(!t)throw new Jbig2Error("invalid Huffman data");return t.decodeNode(e)}}class HuffmanTable{constructor(e,t){t||this.assignPrefixCodes(e);this.rootNode=new HuffmanTreeNode(null);for(let t=0,i=e.length;t0&&this.rootNode.buildTree(i,i.prefixLength-1)}}decode(e){return this.rootNode.decodeNode(e)}assignPrefixCodes(e){const t=e.length;let i=0;for(let a=0;a=this.end)throw new Jbig2Error("end of data while reading bit");this.currentByte=this.data[this.position++];this.shift=7}const e=this.currentByte>>this.shift&1;this.shift--;return e}readBits(e){let t,i=0;for(t=e-1;t>=0;t--)i|=this.readBit()<=this.end?-1:this.data[this.position++]}}function getCustomHuffmanTable(e,t,i){let a=0;for(let s=0,r=t.length;s>i&1;i--}}if(a&&!g){const e=5;for(let t=0;t>2,c=new Uint32Array(e.buffer,t,o);if(FeatureTest.isLittleEndian){for(;n>>24|t<<8|4278190080;i[a+2]=t>>>16|s<<16|4278190080;i[a+3]=s>>>8|4278190080}for(let s=4*n,r=t+g;s>>8|255;i[a+2]=t<<16|s>>>16|255;i[a+3]=s<<8|255}for(let s=4*n,r=t+g;s>3,h=7&a,l=e.length;i=new Uint32Array(i.buffer);let Q=0;for(let a=0;a0&&!e[r-1];)r--;const n=[{children:[],index:0}];let g,o=n[0];for(i=0;i0;)o=n.pop();o.index++;n.push(o);for(;n.length<=i;){n.push(g={children:[],index:0});o.children[o.index]=g.children;o=g}s++}if(i+10){E--;return Q>>E&1}Q=e[t++];if(255===Q){const a=e[t++];if(a){if(220===a&&c){const a=readUint16(e,t+=2);t+=2;if(a>0&&a!==i.scanLines)throw new DNLMarkerError("Found DNL marker (0xFFDC) while parsing scan data",a)}else if(217===a){if(c){const e=p*(8===i.precision?8:0);if(e>0&&Math.round(i.scanLines/e)>=5)throw new DNLMarkerError("Found EOI marker (0xFFD9) while parsing scan data, possibly caused by incorrect `scanLines` parameter",e)}throw new EOIMarkerError("Found EOI marker (0xFFD9) while parsing scan data")}throw new JpegError(`unexpected marker ${(Q<<8|a).toString(16)}`)}}E=7;return Q>>>7}function decodeHuffman(e){let t=e;for(;;){t=t[readBit()];switch(typeof t){case"number":return t;case"object":continue}throw new JpegError("invalid huffman sequence")}}function receive(e){let t=0;for(;e>0;){t=t<<1|readBit();e--}return t}function receiveAndExtend(e){if(1===e)return 1===readBit()?1:-1;const t=receive(e);return t>=1<0){u--;return}let i=r;const a=n;for(;i<=a;){const a=decodeHuffman(e.huffmanTableAC),s=15&a,r=a>>4;if(0===s){if(r<15){u=receive(r)+(1<>4;if(0===s)if(c<15){u=receive(c)+(1<>4;if(0===a){if(r<15)break;s+=16;continue}s+=r;const n=Wt[s];e.blockData[t+n]=receiveAndExtend(a);s++}};let k,R=0;const N=1===m?a[0].blocksPerLine*a[0].blocksPerColumn:C*i.mcusPerColumn;let G,M;for(;R<=N;){const i=s?Math.min(N-R,s):N;if(i>0){for(w=0;w0?"unexpected":"excessive"} MCU data, current marker is: ${k.invalid}`);t=k.offset}if(!(k.marker>=65488&&k.marker<=65495))break;t+=2}return t-l}function quantizeAndInverse(e,t,i){const a=e.quantizationTable,s=e.blockData;let r,n,g,o,c,C,h,l,Q,E,u,d,f,p,m,y,w;if(!a)throw new JpegError("missing required Quantization Table.");for(let e=0;e<64;e+=8){Q=s[t+e];E=s[t+e+1];u=s[t+e+2];d=s[t+e+3];f=s[t+e+4];p=s[t+e+5];m=s[t+e+6];y=s[t+e+7];Q*=a[e];if(E|u|d|f|p|m|y){E*=a[e+1];u*=a[e+2];d*=a[e+3];f*=a[e+4];p*=a[e+5];m*=a[e+6];y*=a[e+7];r=$t*Q+128>>8;n=$t*f+128>>8;g=u;o=m;c=Ai*(E-y)+128>>8;l=Ai*(E+y)+128>>8;C=d<<4;h=p<<4;r=r+n+1>>1;n=r-n;w=g*_t+o*zt+128>>8;g=g*zt-o*_t+128>>8;o=w;c=c+h+1>>1;h=c-h;l=l+C+1>>1;C=l-C;r=r+o+1>>1;o=r-o;n=n+g+1>>1;g=n-g;w=c*Vt+l*Zt+2048>>12;c=c*Zt-l*Vt+2048>>12;l=w;w=C*Xt+h*jt+2048>>12;C=C*jt-h*Xt+2048>>12;h=w;i[e]=r+l;i[e+7]=r-l;i[e+1]=n+h;i[e+6]=n-h;i[e+2]=g+C;i[e+5]=g-C;i[e+3]=o+c;i[e+4]=o-c}else{w=$t*Q+512>>10;i[e]=w;i[e+1]=w;i[e+2]=w;i[e+3]=w;i[e+4]=w;i[e+5]=w;i[e+6]=w;i[e+7]=w}}for(let e=0;e<8;++e){Q=i[e];E=i[e+8];u=i[e+16];d=i[e+24];f=i[e+32];p=i[e+40];m=i[e+48];y=i[e+56];if(E|u|d|f|p|m|y){r=$t*Q+2048>>12;n=$t*f+2048>>12;g=u;o=m;c=Ai*(E-y)+2048>>12;l=Ai*(E+y)+2048>>12;C=d;h=p;r=4112+(r+n+1>>1);n=r-n;w=g*_t+o*zt+2048>>12;g=g*zt-o*_t+2048>>12;o=w;c=c+h+1>>1;h=c-h;l=l+C+1>>1;C=l-C;r=r+o+1>>1;o=r-o;n=n+g+1>>1;g=n-g;w=c*Vt+l*Zt+2048>>12;c=c*Zt-l*Vt+2048>>12;l=w;w=C*Xt+h*jt+2048>>12;C=C*jt-h*Xt+2048>>12;h=w;Q=r+l;y=r-l;E=n+h;m=n-h;u=g+C;p=g-C;d=o+c;f=o-c;Q<16?Q=0:Q>=4080?Q=255:Q>>=4;E<16?E=0:E>=4080?E=255:E>>=4;u<16?u=0:u>=4080?u=255:u>>=4;d<16?d=0:d>=4080?d=255:d>>=4;f<16?f=0:f>=4080?f=255:f>>=4;p<16?p=0:p>=4080?p=255:p>>=4;m<16?m=0:m>=4080?m=255:m>>=4;y<16?y=0:y>=4080?y=255:y>>=4;s[t+e]=Q;s[t+e+8]=E;s[t+e+16]=u;s[t+e+24]=d;s[t+e+32]=f;s[t+e+40]=p;s[t+e+48]=m;s[t+e+56]=y}else{w=$t*Q+8192>>14;w=w<-2040?0:w>=2024?255:w+2056>>4;s[t+e]=w;s[t+e+8]=w;s[t+e+16]=w;s[t+e+24]=w;s[t+e+32]=w;s[t+e+40]=w;s[t+e+48]=w;s[t+e+56]=w}}}function buildComponentData(e,t){const i=t.blocksPerLine,a=t.blocksPerColumn,s=new Int16Array(64);for(let e=0;e=a)return null;const r=readUint16(e,t);if(r>=65472&&r<=65534)return{invalid:null,marker:r,offset:t};let n=readUint16(e,s);for(;!(n>=65472&&n<=65534);){if(++s>=a)return null;n=readUint16(e,s)}return{invalid:r.toString(16),marker:n,offset:s}}function prepareComponents(e){const t=Math.ceil(e.samplesPerLine/8/e.maxH),i=Math.ceil(e.scanLines/8/e.maxV);for(const a of e.components){const s=Math.ceil(Math.ceil(e.samplesPerLine/8)*a.h/e.maxH),r=Math.ceil(Math.ceil(e.scanLines/8)*a.v/e.maxV),n=t*a.h,g=64*(i*a.v)*(n+1);a.blockData=new Int16Array(g);a.blocksPerLine=s;a.blocksPerColumn=r}e.mcusPerLine=t;e.mcusPerColumn=i}function readDataBlock(e,t){const i=readUint16(e,t);let a=(t+=2)+i-2;const s=findNextFileMarker(e,a,t);if(s?.invalid){warn("readDataBlock - incorrect length, current marker is: "+s.invalid);a=s.offset}const r=e.subarray(t,a);return{appData:r,newOffset:t+=r.length}}function skipData(e,t){const i=readUint16(e,t),a=(t+=2)+i-2,s=findNextFileMarker(e,a,t);return s?.invalid?s.offset:a}class JpegImage{constructor({decodeTransform:e=null,colorTransform:t=-1}={}){this._decodeTransform=e;this._colorTransform=t}static canUseImageDecoder(e,t=-1){let i=0,a=null,s=readUint16(e,i);i+=2;if(65496!==s)throw new JpegError("SOI not found");s=readUint16(e,i);i+=2;A:for(;65497!==s;){switch(s){case 65472:case 65473:case 65474:a=e[i+7];break A;case 65535:255!==e[i]&&i--}i=skipData(e,i);s=readUint16(e,i);i+=2}return 4!==a&&(3!==a||0!==t)}parse(e,{dnlScanLines:t=null}={}){let i,a,s=0,r=null,n=null,g=0;const o=[],c=[],C=[];let h=readUint16(e,s);s+=2;if(65496!==h)throw new JpegError("SOI not found");h=readUint16(e,s);s+=2;A:for(;65497!==h;){let l,Q,E;switch(h){case 65504:case 65505:case 65506:case 65507:case 65508:case 65509:case 65510:case 65511:case 65512:case 65513:case 65514:case 65515:case 65516:case 65517:case 65518:case 65519:case 65534:const{appData:u,newOffset:d}=readDataBlock(e,s);s=d;65504===h&&74===u[0]&&70===u[1]&&73===u[2]&&70===u[3]&&0===u[4]&&(r={version:{major:u[5],minor:u[6]},densityUnits:u[7],xDensity:u[8]<<8|u[9],yDensity:u[10]<<8|u[11],thumbWidth:u[12],thumbHeight:u[13],thumbData:u.subarray(14,14+3*u[12]*u[13])});65518===h&&65===u[0]&&100===u[1]&&111===u[2]&&98===u[3]&&101===u[4]&&(n={version:u[5]<<8|u[6],flags0:u[7]<<8|u[8],flags1:u[9]<<8|u[10],transformCode:u[11]});break;case 65499:const f=readUint16(e,s);s+=2;const p=f+s-2;let m;for(;s>4){if(t>>4!=1)throw new JpegError("DQT - invalid table spec");for(Q=0;Q<64;Q++){m=Wt[Q];i[m]=readUint16(e,s);s+=2}}else for(Q=0;Q<64;Q++){m=Wt[Q];i[m]=e[s++]}o[15&t]=i}break;case 65472:case 65473:case 65474:if(i)throw new JpegError("Only single frame JPEGs supported");s+=2;i={};i.extended=65473===h;i.progressive=65474===h;i.precision=e[s++];const y=readUint16(e,s);s+=2;i.scanLines=t||y;i.samplesPerLine=readUint16(e,s);s+=2;i.components=[];i.componentIds={};const w=e[s++];let D=0,b=0;for(l=0;l>4,r=15&e[s+1];D>4?c:C)[15&t]=buildHuffmanTable(i,r)}break;case 65501:s+=2;a=readUint16(e,s);s+=2;break;case 65498:const S=1==++g&&!t;s+=2;const k=e[s++],R=[];for(l=0;l>4];r.huffmanTableAC=c[15&n];R.push(r)}const N=e[s++],G=e[s++],M=e[s++];try{s+=decodeScan(e,s,i,R,a,N,G,M>>4,15&M,S)}catch(t){if(t instanceof DNLMarkerError){warn(`${t.message} -- attempting to re-parse the JPEG image.`);return this.parse(e,{dnlScanLines:t.scanLines})}if(t instanceof EOIMarkerError){warn(`${t.message} -- ignoring the rest of the image data.`);break A}throw t}break;case 65500:s+=4;break;case 65535:255!==e[s]&&s--;break;default:const U=findNextFileMarker(e,s-2,s-3);if(U?.invalid){warn("JpegImage.parse - unexpected data, current marker is: "+U.invalid);s=U.offset;break}if(!U||s>=e.length-1){warn("JpegImage.parse - reached the end of the image data without finding an EOI marker (0xFFD9).");break A}throw new JpegError("JpegImage.parse - unknown marker: "+h.toString(16))}h=readUint16(e,s);s+=2}if(!i)throw new JpegError("JpegImage.parse - no frame data found.");this.width=i.samplesPerLine;this.height=i.scanLines;this.jfif=r;this.adobe=n;this.components=[];for(const e of i.components){const t=o[e.quantizationId];t&&(e.quantizationTable=t);this.components.push({index:e.index,output:buildComponentData(0,e),scaleX:e.h/i.maxH,scaleY:e.v/i.maxV,blocksPerLine:e.blocksPerLine,blocksPerColumn:e.blocksPerColumn})}this.numComponents=this.components.length}_getLinearizedBlockData(e,t,i=!1){const a=this.width/e,s=this.height/t;let r,n,g,o,c,C,h,l,Q,E,u,d=0;const f=this.components.length,p=e*t*f,m=new Uint8ClampedArray(p),y=new Uint32Array(e),w=4294967288;let D;for(h=0;h>8)+b[Q+1];return m}get _isColorConversionNeeded(){return this.adobe?!!this.adobe.transformCode:3===this.numComponents?0!==this._colorTransform&&(82!==this.components[0].index||71!==this.components[1].index||66!==this.components[2].index):1===this._colorTransform}_convertYccToRgb(e){let t,i,a;for(let s=0,r=e.length;s4)throw new JpegError("Unsupported color mode");const r=this._getLinearizedBlockData(e,t,s);if(1===this.numComponents&&(i||a)){const e=r.length*(i?4:3),t=new Uint8ClampedArray(e);let a=0;if(i)!function grayToRGBA(e,t){if(FeatureTest.isLittleEndian)for(let i=0,a=e.length;i0&&(e=e.subarray(t));break}return e}decodeImage(e){if(this.eof)return this.buffer;e=this.#w(e||this.bytes);const t=new JpegImage(this.jpegOptions);t.parse(e);const i=t.getData({width:this.drawWidth,height:this.drawHeight,forceRGBA:this.forceRGBA,forceRGB:this.forceRGB,isSourcePDF:!0});this.buffer=i;this.bufferLength=i.length;this.eof=!0;return this.buffer}get canAsyncDecodeImageFromBuffer(){return this.stream.isAsync}async getTransferableImage(){if(!await JpegStream.canUseImageDecoder)return null;const e=this.jpegOptions;if(e.decodeTransform)return null;let t;try{const i=this.canAsyncDecodeImageFromBuffer&&await this.stream.asyncGetBytes()||this.bytes;if(!i)return null;const a=this.#w(i);if(!JpegImage.canUseImageDecoder(a,e.colorTransform))return null;t=new ImageDecoder({data:a,type:"image/jpeg",preferAnimation:!1});return(await t.decode()).image}catch(e){warn(`getTransferableImage - failed: "${e}".`);return null}finally{t?.close()}}}var ei,ti=(ei="undefined"!=typeof document?document.currentScript?.src:void 0,function(e={}){var t,i,a=e;new Promise(((e,a)=>{t=e;i=a}));a.decode=function(e,{numComponents:t=4,isIndexedColormap:i=!1,smaskInData:s=!1}){const r=e.length,n=a._malloc(r);a.HEAPU8.set(e,n);const g=a._jp2_decode(n,r,t>0?t:0,!!i,!!s);a._free(n);if(g){const{errorMessages:e}=a;if(e){delete a.errorMessages;return e}return"Unknown error"}const{imageData:o}=a;a.imageData=null;return o};var s=Object.assign({},a),r="./this.program",quit_=(e,t)=>{throw t},n="";"undefined"!=typeof document&&document.currentScript&&(n=document.currentScript.src);ei&&(n=ei);n=n.startsWith("blob:")?"":n.substr(0,n.replace(/[?#].*/,"").lastIndexOf("/")+1);var g=a.print||console.log.bind(console),o=a.printErr||console.error.bind(console);Object.assign(a,s);s=null;a.arguments&&a.arguments;a.thisProgram&&(r=a.thisProgram);var c,C=a.wasmBinary;function tryParseAsDataURI(e){if(isDataURI(e))return function intArrayFromBase64(e){for(var t=atob(e),i=new Uint8Array(t.length),a=0;ae.startsWith(b);function instantiateSync(e,t){var i,a=function getBinarySync(e){if(e==d&&C)return new Uint8Array(C);var t=tryParseAsDataURI(e);if(t)return t;throw'sync fetching of the wasm failed: you can preload it to Module["wasmBinary"] manually, or emcc.py will do that for you when generating HTML (but not JS)'}(e);i=new WebAssembly.Module(a);return[new WebAssembly.Instance(i,t),i]}class ExitStatus{name="ExitStatus";constructor(e){this.message=`Program terminated with exit(${e})`;this.status=e}}var F,callRuntimeCallbacks=e=>{for(;e.length>0;)e.shift()(a)},S=a.noExitRuntime||!0,k=0,R={},handleException=e=>{if(e instanceof ExitStatus||"unwind"==e)return h;quit_(0,e)},keepRuntimeAlive=()=>S||k>0,_proc_exit=e=>{h=e;if(!keepRuntimeAlive()){a.onExit?.(e);u=!0}quit_(0,new ExitStatus(e))},_exit=(e,t)=>{h=e;_proc_exit(e)},callUserCallback=e=>{if(!u)try{e();(()=>{if(!keepRuntimeAlive())try{_exit(h)}catch(e){handleException(e)}})()}catch(e){handleException(e)}},growMemory=e=>{var t=(e-c.buffer.byteLength+65535)/65536|0;try{c.grow(t);updateMemoryViews();return 1}catch(e){}},N={},getEnvStrings=()=>{if(!getEnvStrings.strings){var e={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"==typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:r||"./this.program"};for(var t in N)void 0===N[t]?delete e[t]:e[t]=N[t];var i=[];for(var t in e)i.push(`${t}=${e[t]}`);getEnvStrings.strings=i}return getEnvStrings.strings},G=[null,[],[]],M="undefined"!=typeof TextDecoder?new TextDecoder:void 0,UTF8ArrayToString=(e,t=0,i=NaN)=>{for(var a=t+i,s=t;e[s]&&!(s>=a);)++s;if(s-t>16&&e.buffer&&M)return M.decode(e.subarray(t,s));for(var r="";t>10,56320|1023&c)}}else r+=String.fromCharCode((31&n)<<6|g)}else r+=String.fromCharCode(n)}return r},printChar=(e,t)=>{var i=G[e];if(0===t||10===t){(1===e?g:o)(UTF8ArrayToString(i));i.length=0}else i.push(t)},UTF8ToString=(e,t)=>e?UTF8ArrayToString(Q,e,t):"",U={m:()=>function abort(e){a.onAbort?.(e);o(e="Aborted("+e+")");u=!0;e+=". Build with -sASSERTIONS for more info.";var t=new WebAssembly.RuntimeError(e);i(t);throw t}(""),c:(e,t,i)=>Q.copyWithin(e,t,t+i),l:()=>{S=!1;k=0},n:(e,t)=>{if(R[e]){clearTimeout(R[e].id);delete R[e]}if(!t)return 0;var i=setTimeout((()=>{delete R[e];callUserCallback((()=>L(e,performance.now())))}),t);R[e]={id:i,timeout_ms:t};return 0},g:function _copy_pixels_1(e,t){e>>=2;const i=a.imageData=new Uint8ClampedArray(t),s=a.HEAP32.subarray(e,e+t);i.set(s)},f:function _copy_pixels_3(e,t,i,s){e>>=2;t>>=2;i>>=2;const r=a.imageData=new Uint8ClampedArray(3*s),n=a.HEAP32.subarray(e,e+s),g=a.HEAP32.subarray(t,t+s),o=a.HEAP32.subarray(i,i+s);for(let e=0;e>=2;t>>=2;i>>=2;s>>=2;const n=a.imageData=new Uint8ClampedArray(4*r),g=a.HEAP32.subarray(e,e+r),o=a.HEAP32.subarray(t,t+r),c=a.HEAP32.subarray(i,i+r),C=a.HEAP32.subarray(s,s+r);for(let e=0;e{var t,i,a=Q.length,s=2147483648;if((e>>>=0)>s)return!1;for(var r=1;r<=4;r*=2){var n=a*(1+.2/r);n=Math.min(n,e+100663296);var g=Math.min(s,(t=Math.max(e,n),i=65536,Math.ceil(t/i)*i));if(growMemory(g))return!0}return!1},p:(e,t)=>{var i=0;getEnvStrings().forEach(((a,s)=>{var r=t+i;E[e+4*s>>2]=r;((e,t)=>{for(var i=0;i{var i=getEnvStrings();E[e>>2]=i.length;var a=0;i.forEach((e=>a+=e.length+1));E[t>>2]=a;return 0},r:e=>52,j:function _fd_seek(e,t,i,a,s){return 70},b:(e,t,i,a)=>{for(var s=0,r=0;r>2],g=E[t+4>>2];t+=8;for(var o=0;o>2]=s;return 0},s:function _gray_to_rgba(e,t){e>>=2;const i=a.imageData=new Uint8ClampedArray(4*t),s=a.HEAP32.subarray(e,e+t);for(let e=0;e>=2;t>>=2;const s=a.imageData=new Uint8ClampedArray(4*i),r=a.HEAP32.subarray(e,e+i),n=a.HEAP32.subarray(t,t+i);for(let e=0;e>=2;t>>=2;i>>=2;const r=a.imageData=new Uint8ClampedArray(4*s),n=a.HEAP32.subarray(e,e+s),g=a.HEAP32.subarray(t,t+s),o=a.HEAP32.subarray(i,i+s);for(let e=0;e0)){!function preRun(){if(a.preRun){"function"==typeof a.preRun&&(a.preRun=[a.preRun]);for(;a.preRun.length;)e=a.preRun.shift(),f.unshift(e)}var e;callRuntimeCallbacks(f)}();if(!(y>0))if(a.setStatus){a.setStatus("Running...");setTimeout((()=>{setTimeout((()=>a.setStatus("")),1);doRun()}),1)}else doRun()}function doRun(){if(!F){F=!0;a.calledRun=!0;if(!u){!function initRuntime(){callRuntimeCallbacks(p)}();t(a);a.onRuntimeInitialized?.();!function postRun(){if(a.postRun){"function"==typeof a.postRun&&(a.postRun=[a.postRun]);for(;a.postRun.length;)e=a.postRun.shift(),m.unshift(e)}var e;callRuntimeCallbacks(m)}()}}}}if(a.preInit){"function"==typeof a.preInit&&(a.preInit=[a.preInit]);for(;a.preInit.length>0;)a.preInit.pop()()}run();return a});const ii=ti;class JpxError extends ot{constructor(e){super(e,"JpxError")}}class JpxImage{static#D=null;static decode(e,t){t||={};this.#D||=ii({warn});const i=this.#D.decode(e,t);if("string"==typeof i)throw new JpxError(i);return i}static cleanup(){this.#D=null}static parseImageProperties(e){let t=e.getByte();for(;t>=0;){const i=t;t=e.getByte();if(65361===(i<<8|t)){e.skip(4);const t=e.getInt32()>>>0,i=e.getInt32()>>>0,a=e.getInt32()>>>0,s=e.getInt32()>>>0;e.skip(16);return{width:t-a,height:i-s,bitsPerComponent:8,componentsCount:e.getUint16()}}}throw new JpxError("No size marker found in JPX stream")}}class JpxStream extends DecodeStream{constructor(e,t,i){super(t);this.stream=e;this.dict=e.dict;this.maybeLength=t;this.params=i}get bytes(){return shadow(this,"bytes",this.stream.getBytes(this.maybeLength))}ensureBuffer(e){}readBlock(e){this.decodeImage(null,e)}decodeImage(e,t){if(this.eof)return this.buffer;e||=this.bytes;this.buffer=JpxImage.decode(e,t);this.bufferLength=this.buffer.length;this.eof=!0;return this.buffer}get canAsyncDecodeImageFromBuffer(){return this.stream.isAsync}}class LZWStream extends DecodeStream{constructor(e,t,i){super(t);this.str=e;this.dict=e.dict;this.cachedData=0;this.bitsCached=0;const a=4096,s={earlyChange:i,codeLength:9,nextCode:258,dictionaryValues:new Uint8Array(a),dictionaryLengths:new Uint16Array(a),dictionaryPrevCodes:new Uint16Array(a),currentSequence:new Uint8Array(a),currentSequenceLength:0};for(let e=0;e<256;++e){s.dictionaryValues[e]=e;s.dictionaryLengths[e]=1}this.lzwState=s}readBits(e){let t=this.bitsCached,i=this.cachedData;for(;t>>t&(1<0;if(e<256){l[0]=e;Q=1}else{if(!(e>=258)){if(256===e){C=9;n=258;Q=0;continue}this.eof=!0;delete this.lzwState;break}if(e=0;t--){l[t]=g[i];i=c[i]}}else l[Q++]=l[0]}if(s){c[n]=h;o[n]=o[h]+1;g[n]=l[0];n++;C=n+r&n+r-1?C:0|Math.min(Math.log(n+r)/.6931471805599453+1,12)}h=e;E+=Q;if(a15))throw new FormatError(`Unsupported predictor: ${a}`);this.readBlock=2===a?this.readBlockTiff:this.readBlockPng;this.str=e;this.dict=e.dict;const s=this.colors=i.get("Colors")||1,r=this.bits=i.get("BPC","BitsPerComponent")||8,n=this.columns=i.get("Columns")||1;this.pixBytes=s*r+7>>3;this.rowBytes=n*s*r+7>>3;return this}readBlockTiff(){const e=this.rowBytes,t=this.bufferLength,i=this.ensureBuffer(t+e),a=this.bits,s=this.colors,r=this.str.getBytes(e);this.eof=!r.length;if(this.eof)return;let n,g=0,o=0,c=0,C=0,h=t;if(1===a&&1===s)for(n=0;n>1;e^=e>>2;e^=e>>4;g=(1&e)<<7;i[h++]=e}else if(8===a){for(n=0;n>8&255;i[h++]=255&e}}else{const e=new Uint8Array(s+1),h=(1<>c-a)&h;c-=a;o=o<=8){i[Q++]=o>>C-8&255;C-=8}}C>0&&(i[Q++]=(o<<8-C)+(g&(1<<8-C)-1))}this.bufferLength+=e}readBlockPng(){const e=this.rowBytes,t=this.pixBytes,i=this.str.getByte(),a=this.str.getBytes(e);this.eof=!a.length;if(this.eof)return;const s=this.bufferLength,r=this.ensureBuffer(s+e);let n=r.subarray(s-e,s);0===n.length&&(n=new Uint8Array(e));let g,o,c,C=s;switch(i){case 0:for(g=0;g>1)+a[g];for(;g>1)+a[g]&255;C++}break;case 4:for(g=0;g0){const e=this.str.getBytes(a);t.set(e,i);i+=a}}else{a=257-a;const s=e[1];t=this.ensureBuffer(i+a+1);for(let e=0;e>")&&this.buf1!==Bt;){if(!(this.buf1 instanceof Name)){info("Malformed dictionary: key must be a name object");this.shift();continue}const t=this.buf1.name;this.shift();if(this.buf1===Bt)break;a.set(t,this.getObj(e))}if(this.buf1===Bt){if(this.recoveryMode)return a;throw new ParserEOFException("End of file inside dictionary.")}if(isCmd(this.buf2,"stream"))return this.allowStreams?this.makeStream(a,e):a;this.shift();return a;default:return t}if(Number.isInteger(t)){if(Number.isInteger(this.buf1)&&isCmd(this.buf2,"R")){const e=Ref.get(t,this.buf1);this.shift();this.shift();return e}return t}return"string"==typeof t&&e?e.decryptString(t):t}findDefaultInlineStreamEnd(e){const{knownCommands:t}=this.lexer,i=e.pos;let a,s,r=0;for(;-1!==(a=e.getByte());)if(0===r)r=69===a?1:0;else if(1===r)r=73===a?2:0;else if(32===a||10===a||13===a){s=e.pos;const i=e.peekBytes(15),n=i.length;if(0===n)break;for(let e=0;e127))){r=0;break}}if(2!==r)continue;if(!t){warn("findDefaultInlineStreamEnd - `lexer.knownCommands` is undefined.");continue}const g=new Lexer(new Stream(i.slice()),t);g._hexStringWarn=()=>{};let o=0;for(;;){const e=g.getObj();if(e===Bt){r=0;break}if(e instanceof Cmd){const i=t[e.cmd];if(!i){r=0;break}if(i.variableArgs?o<=i.numArgs:o===i.numArgs)break;o=0}else o++}if(2===r)break}else r=0;if(-1===a){warn("findDefaultInlineStreamEnd: Reached the end of the stream without finding a valid EI marker");if(s){warn('... trying to recover by using the last "EI" occurrence.');e.skip(-(e.pos-s))}}let n=4;e.skip(-n);a=e.peekByte();e.skip(n);isWhiteSpace(a)||n--;return e.pos-n-i}findDCTDecodeInlineStreamEnd(e){const t=e.pos;let i,a,s=!1;for(;-1!==(i=e.getByte());)if(255===i){switch(e.getByte()){case 0:break;case 255:e.skip(-1);break;case 217:s=!0;break;case 192:case 193:case 194:case 195:case 197:case 198:case 199:case 201:case 202:case 203:case 205:case 206:case 207:case 196:case 204:case 218:case 219:case 220:case 221:case 222:case 223:case 224:case 225:case 226:case 227:case 228:case 229:case 230:case 231:case 232:case 233:case 234:case 235:case 236:case 237:case 238:case 239:case 254:a=e.getUint16();a>2?e.skip(a-2):e.skip(-2)}if(s)break}const r=e.pos-t;if(-1===i){warn("Inline DCTDecode image stream: EOI marker not found, searching for /EI/ instead.");e.skip(-r);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return r}findASCII85DecodeInlineStreamEnd(e){const t=e.pos;let i;for(;-1!==(i=e.getByte());)if(126===i){const t=e.pos;i=e.peekByte();for(;isWhiteSpace(i);){e.skip();i=e.peekByte()}if(62===i){e.skip();break}if(e.pos>t){const t=e.peekBytes(2);if(69===t[0]&&73===t[1])break}}const a=e.pos-t;if(-1===i){warn("Inline ASCII85Decode image stream: EOD marker not found, searching for /EI/ instead.");e.skip(-a);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return a}findASCIIHexDecodeInlineStreamEnd(e){const t=e.pos;let i;for(;-1!==(i=e.getByte())&&62!==i;);const a=e.pos-t;if(-1===i){warn("Inline ASCIIHexDecode image stream: EOD marker not found, searching for /EI/ instead.");e.skip(-a);return this.findDefaultInlineStreamEnd(e)}this.inlineStreamSkipEI(e);return a}inlineStreamSkipEI(e){let t,i=0;for(;-1!==(t=e.getByte());)if(0===i)i=69===t?1:0;else if(1===i)i=73===t?2:0;else if(2===i)break}makeInlineImage(e){const t=this.lexer,i=t.stream,a=Object.create(null);let s;for(;!isCmd(this.buf1,"ID")&&this.buf1!==Bt;){if(!(this.buf1 instanceof Name))throw new FormatError("Dictionary key must be a name object");const t=this.buf1.name;this.shift();if(this.buf1===Bt)break;a[t]=this.getObj(e)}-1!==t.beginInlineImagePos&&(s=i.pos-t.beginInlineImagePos);const r=this.xref.fetchIfRef(a.F||a.Filter);let n;if(r instanceof Name)n=r.name;else if(Array.isArray(r)){const e=this.xref.fetchIfRef(r[0]);e instanceof Name&&(n=e.name)}const g=i.pos;let o,c;switch(n){case"DCT":case"DCTDecode":o=this.findDCTDecodeInlineStreamEnd(i);break;case"A85":case"ASCII85Decode":o=this.findASCII85DecodeInlineStreamEnd(i);break;case"AHx":case"ASCIIHexDecode":o=this.findASCIIHexDecodeInlineStreamEnd(i);break;default:o=this.findDefaultInlineStreamEnd(i)}if(o<1e3&&s>0){const e=i.pos;i.pos=t.beginInlineImagePos;c=function getInlineImageCacheKey(e){const t=[],i=e.length;let a=0;for(;a=a){let a=!1;for(const e of s){const t=e.length;let s=0;for(;s=r){a=!0;break}if(s>=t){if(isWhiteSpace(n[o+g+s])){info(`Found "${bytesToString([...i,...e])}" when searching for endstream command.`);a=!0}break}}if(a){t.pos+=o;return t.pos-e}}o++}t.pos+=g}return-1}makeStream(e,t){const i=this.lexer;let a=i.stream;i.skipToNextLine();const s=a.pos-1;let r=e.get("Length");if(!Number.isInteger(r)){info(`Bad length "${r&&r.toString()}" in stream.`);r=0}a.pos=s+r;i.nextChar();if(this.tryShift()&&isCmd(this.buf2,"endstream"))this.shift();else{r=this.#b(s);if(r<0)throw new FormatError("Missing endstream command.");i.nextChar();this.shift();this.shift()}this.shift();a=a.makeSubStream(s,r,e);t&&(a=t.createStream(a,r));a=this.filter(a,e,r);a.dict=e;return a}filter(e,t,i){let a=t.get("F","Filter"),s=t.get("DP","DecodeParms");if(a instanceof Name){Array.isArray(s)&&warn("/DecodeParms should not be an Array, when /Filter is a Name.");return this.makeFilter(e,a.name,i,s)}let r=i;if(Array.isArray(a)){const t=a,i=s;for(let n=0,g=t.length;n=48&&e<=57?15&e:e>=65&&e<=70||e>=97&&e<=102?9+(15&e):-1}class Lexer{constructor(e,t=null){this.stream=e;this.nextChar();this.strBuf=[];this.knownCommands=t;this._hexStringNumWarn=0;this.beginInlineImagePos=-1}nextChar(){return this.currentChar=this.stream.getByte()}peekChar(){return this.stream.peekByte()}getNumber(){let e=this.currentChar,t=!1,i=0,a=1;if(45===e){a=-1;e=this.nextChar();45===e&&(e=this.nextChar())}else 43===e&&(e=this.nextChar());if(10===e||13===e)do{e=this.nextChar()}while(10===e||13===e);if(46===e){i=10;e=this.nextChar()}if(e<48||e>57){const t=`Invalid number: ${String.fromCharCode(e)} (charCode ${e})`;if(isWhiteSpace(e)||-1===e){info(`Lexer.getNumber - "${t}".`);return 0}throw new FormatError(t)}let s=e-48,r=0,n=1;for(;(e=this.nextChar())>=0;)if(e>=48&&e<=57){const a=e-48;if(t)r=10*r+a;else{0!==i&&(i*=10);s=10*s+a}}else if(46===e){if(0!==i)break;i=1}else if(45===e)warn("Badly formatted number: minus sign in the middle");else{if(69!==e&&101!==e)break;e=this.peekChar();if(43===e||45===e){n=45===e?-1:1;this.nextChar()}else if(e<48||e>57)break;t=!0}0!==i&&(s/=i);t&&(s*=10**(n*r));return a*s}getString(){let e=1,t=!1;const i=this.strBuf;i.length=0;let a=this.nextChar();for(;;){let s=!1;switch(0|a){case-1:warn("Unterminated string");t=!0;break;case 40:++e;i.push("(");break;case 41:if(0==--e){this.nextChar();t=!0}else i.push(")");break;case 92:a=this.nextChar();switch(a){case-1:warn("Unterminated string");t=!0;break;case 110:i.push("\n");break;case 114:i.push("\r");break;case 116:i.push("\t");break;case 98:i.push("\b");break;case 102:i.push("\f");break;case 92:case 40:case 41:i.push(String.fromCharCode(a));break;case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:let e=15&a;a=this.nextChar();s=!0;if(a>=48&&a<=55){e=(e<<3)+(15&a);a=this.nextChar();if(a>=48&&a<=55){s=!1;e=(e<<3)+(15&a)}}i.push(String.fromCharCode(e));break;case 13:10===this.peekChar()&&this.nextChar();break;case 10:break;default:i.push(String.fromCharCode(a))}break;default:i.push(String.fromCharCode(a))}if(t)break;s||(a=this.nextChar())}return i.join("")}getName(){let e,t;const i=this.strBuf;i.length=0;for(;(e=this.nextChar())>=0&&!ai[e];)if(35===e){e=this.nextChar();if(ai[e]){warn("Lexer_getName: NUMBER SIGN (#) should be followed by a hexadecimal number.");i.push("#");break}const a=toHexDigit(e);if(-1!==a){t=e;e=this.nextChar();const s=toHexDigit(e);if(-1===s){warn(`Lexer_getName: Illegal digit (${String.fromCharCode(e)}) in hexadecimal number.`);i.push("#",String.fromCharCode(t));if(ai[e])break;i.push(String.fromCharCode(e));continue}i.push(String.fromCharCode(a<<4|s))}else i.push("#",String.fromCharCode(e))}else i.push(String.fromCharCode(e));i.length>127&&warn(`Name token is longer than allowed by the spec: ${i.length}`);return Name.get(i.join(""))}_hexStringWarn(e){5!=this._hexStringNumWarn++?this._hexStringNumWarn>5||warn(`getHexString - ignoring invalid character: ${e}`):warn("getHexString - ignoring additional invalid characters.")}getHexString(){const e=this.strBuf;e.length=0;let t=this.currentChar,i=-1,a=-1;this._hexStringNumWarn=0;for(;;){if(t<0){warn("Unterminated hex string");break}if(62===t){this.nextChar();break}if(1!==ai[t]){a=toHexDigit(t);if(-1===a)this._hexStringWarn(t);else if(-1===i)i=a;else{e.push(String.fromCharCode(i<<4|a));i=-1}t=this.nextChar()}else t=this.nextChar()}-1!==i&&e.push(String.fromCharCode(i<<4));return e.join("")}getObj(){let e=!1,t=this.currentChar;for(;;){if(t<0)return Bt;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(1!==ai[t])break;t=this.nextChar()}switch(0|t){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:case 43:case 45:case 46:return this.getNumber();case 40:return this.getString();case 47:return this.getName();case 91:this.nextChar();return Cmd.get("[");case 93:this.nextChar();return Cmd.get("]");case 60:t=this.nextChar();if(60===t){this.nextChar();return Cmd.get("<<")}return this.getHexString();case 62:t=this.nextChar();if(62===t){this.nextChar();return Cmd.get(">>")}return Cmd.get(">");case 123:this.nextChar();return Cmd.get("{");case 125:this.nextChar();return Cmd.get("}");case 41:this.nextChar();throw new FormatError(`Illegal character: ${t}`)}let i=String.fromCharCode(t);if(t<32||t>127){const e=this.peekChar();if(e>=32&&e<=127){this.nextChar();return Cmd.get(i)}}const a=this.knownCommands;let s=void 0!==a?.[i];for(;(t=this.nextChar())>=0&&!ai[t];){const e=i+String.fromCharCode(t);if(s&&void 0===a[e])break;if(128===i.length)throw new FormatError(`Command token too long: ${i.length}`);i=e;s=void 0!==a?.[i]}if("true"===i)return!0;if("false"===i)return!1;if("null"===i)return null;"BI"===i&&(this.beginInlineImagePos=this.stream.pos);return Cmd.get(i)}skipToNextLine(){let e=this.currentChar;for(;e>=0;){if(13===e){e=this.nextChar();10===e&&this.nextChar();break}if(10===e){this.nextChar();break}e=this.nextChar()}}}class Linearization{static create(e){function getInt(e,t,i=!1){const a=e.get(t);if(Number.isInteger(a)&&(i?a>=0:a>0))return a;throw new Error(`The "${t}" parameter in the linearization dictionary is invalid.`)}const t=new Parser({lexer:new Lexer(e),xref:null}),i=t.getObj(),a=t.getObj(),s=t.getObj(),r=t.getObj();let n,g;if(!(Number.isInteger(i)&&Number.isInteger(a)&&isCmd(s,"obj")&&r instanceof Dict&&"number"==typeof(n=r.get("Linearized"))&&n>0))return null;if((g=getInt(r,"L"))!==e.length)throw new Error('The "L" parameter in the linearization dictionary does not equal the stream length.');return{length:g,hints:function getHints(e){const t=e.get("H");let i;if(Array.isArray(t)&&(2===(i=t.length)||4===i)){for(let e=0;e0))throw new Error(`Hint (${e}) in the linearization dictionary is invalid.`)}return t}throw new Error("Hint array in the linearization dictionary is invalid.")}(r),objectNumberFirst:getInt(r,"O"),endFirst:getInt(r,"E"),numPages:getInt(r,"N"),mainXRefEntriesOffset:getInt(r,"T"),pageFirst:r.has("P")?getInt(r,"P",!0):0}}}const si=["Adobe-GB1-UCS2","Adobe-CNS1-UCS2","Adobe-Japan1-UCS2","Adobe-Korea1-UCS2","78-EUC-H","78-EUC-V","78-H","78-RKSJ-H","78-RKSJ-V","78-V","78ms-RKSJ-H","78ms-RKSJ-V","83pv-RKSJ-H","90ms-RKSJ-H","90ms-RKSJ-V","90msp-RKSJ-H","90msp-RKSJ-V","90pv-RKSJ-H","90pv-RKSJ-V","Add-H","Add-RKSJ-H","Add-RKSJ-V","Add-V","Adobe-CNS1-0","Adobe-CNS1-1","Adobe-CNS1-2","Adobe-CNS1-3","Adobe-CNS1-4","Adobe-CNS1-5","Adobe-CNS1-6","Adobe-GB1-0","Adobe-GB1-1","Adobe-GB1-2","Adobe-GB1-3","Adobe-GB1-4","Adobe-GB1-5","Adobe-Japan1-0","Adobe-Japan1-1","Adobe-Japan1-2","Adobe-Japan1-3","Adobe-Japan1-4","Adobe-Japan1-5","Adobe-Japan1-6","Adobe-Korea1-0","Adobe-Korea1-1","Adobe-Korea1-2","B5-H","B5-V","B5pc-H","B5pc-V","CNS-EUC-H","CNS-EUC-V","CNS1-H","CNS1-V","CNS2-H","CNS2-V","ETHK-B5-H","ETHK-B5-V","ETen-B5-H","ETen-B5-V","ETenms-B5-H","ETenms-B5-V","EUC-H","EUC-V","Ext-H","Ext-RKSJ-H","Ext-RKSJ-V","Ext-V","GB-EUC-H","GB-EUC-V","GB-H","GB-V","GBK-EUC-H","GBK-EUC-V","GBK2K-H","GBK2K-V","GBKp-EUC-H","GBKp-EUC-V","GBT-EUC-H","GBT-EUC-V","GBT-H","GBT-V","GBTpc-EUC-H","GBTpc-EUC-V","GBpc-EUC-H","GBpc-EUC-V","H","HKdla-B5-H","HKdla-B5-V","HKdlb-B5-H","HKdlb-B5-V","HKgccs-B5-H","HKgccs-B5-V","HKm314-B5-H","HKm314-B5-V","HKm471-B5-H","HKm471-B5-V","HKscs-B5-H","HKscs-B5-V","Hankaku","Hiragana","KSC-EUC-H","KSC-EUC-V","KSC-H","KSC-Johab-H","KSC-Johab-V","KSC-V","KSCms-UHC-H","KSCms-UHC-HW-H","KSCms-UHC-HW-V","KSCms-UHC-V","KSCpc-EUC-H","KSCpc-EUC-V","Katakana","NWP-H","NWP-V","RKSJ-H","RKSJ-V","Roman","UniCNS-UCS2-H","UniCNS-UCS2-V","UniCNS-UTF16-H","UniCNS-UTF16-V","UniCNS-UTF32-H","UniCNS-UTF32-V","UniCNS-UTF8-H","UniCNS-UTF8-V","UniGB-UCS2-H","UniGB-UCS2-V","UniGB-UTF16-H","UniGB-UTF16-V","UniGB-UTF32-H","UniGB-UTF32-V","UniGB-UTF8-H","UniGB-UTF8-V","UniJIS-UCS2-H","UniJIS-UCS2-HW-H","UniJIS-UCS2-HW-V","UniJIS-UCS2-V","UniJIS-UTF16-H","UniJIS-UTF16-V","UniJIS-UTF32-H","UniJIS-UTF32-V","UniJIS-UTF8-H","UniJIS-UTF8-V","UniJIS2004-UTF16-H","UniJIS2004-UTF16-V","UniJIS2004-UTF32-H","UniJIS2004-UTF32-V","UniJIS2004-UTF8-H","UniJIS2004-UTF8-V","UniJISPro-UCS2-HW-V","UniJISPro-UCS2-V","UniJISPro-UTF8-V","UniJISX0213-UTF32-H","UniJISX0213-UTF32-V","UniJISX02132004-UTF32-H","UniJISX02132004-UTF32-V","UniKS-UCS2-H","UniKS-UCS2-V","UniKS-UTF16-H","UniKS-UTF16-V","UniKS-UTF32-H","UniKS-UTF32-V","UniKS-UTF8-H","UniKS-UTF8-V","V","WP-Symbol"],ri=2**24-1;class CMap{constructor(e=!1){this.codespaceRanges=[[],[],[],[]];this.numCodespaceRanges=0;this._map=[];this.name="";this.vertical=!1;this.useCMap=null;this.builtInCMap=e}addCodespaceRange(e,t,i){this.codespaceRanges[e-1].push(t,i);this.numCodespaceRanges++}mapCidRange(e,t,i){if(t-e>ri)throw new Error("mapCidRange - ignoring data above MAX_MAP_RANGE.");for(;e<=t;)this._map[e++]=i++}mapBfRange(e,t,i){if(t-e>ri)throw new Error("mapBfRange - ignoring data above MAX_MAP_RANGE.");const a=i.length-1;for(;e<=t;){this._map[e++]=i;const t=i.charCodeAt(a)+1;t>255?i=i.substring(0,a-1)+String.fromCharCode(i.charCodeAt(a-1)+1)+"\0":i=i.substring(0,a)+String.fromCharCode(t)}}mapBfRangeToArray(e,t,i){if(t-e>ri)throw new Error("mapBfRangeToArray - ignoring data above MAX_MAP_RANGE.");const a=i.length;let s=0;for(;e<=t&&s>>0;const n=s[r];for(let e=0,t=n.length;e=t&&a<=s){i.charcode=a;i.length=r+1;return}}}i.charcode=0;i.length=1}getCharCodeLength(e){const t=this.codespaceRanges;for(let i=0,a=t.length;i=s&&e<=r)return i+1}}return 1}get length(){return this._map.length}get isIdentityCMap(){if("Identity-H"!==this.name&&"Identity-V"!==this.name)return!1;if(65536!==this._map.length)return!1;for(let e=0;e<65536;e++)if(this._map[e]!==e)return!1;return!0}}class IdentityCMap extends CMap{constructor(e,t){super();this.vertical=e;this.addCodespaceRange(t,0,65535)}mapCidRange(e,t,i){unreachable("should not call mapCidRange")}mapBfRange(e,t,i){unreachable("should not call mapBfRange")}mapBfRangeToArray(e,t,i){unreachable("should not call mapBfRangeToArray")}mapOne(e,t){unreachable("should not call mapCidOne")}lookup(e){return Number.isInteger(e)&&e<=65535?e:void 0}contains(e){return Number.isInteger(e)&&e<=65535}forEach(e){for(let t=0;t<=65535;t++)e(t,t)}charCodeOf(e){return Number.isInteger(e)&&e<=65535?e:-1}getMap(){const e=new Array(65536);for(let t=0;t<=65535;t++)e[t]=t;return e}get length(){return 65536}get isIdentityCMap(){unreachable("should not access .isIdentityCMap")}}function strToInt(e){let t=0;for(let i=0;i>>0}function expectString(e){if("string"!=typeof e)throw new FormatError("Malformed CMap: expected string.")}function expectInt(e){if(!Number.isInteger(e))throw new FormatError("Malformed CMap: expected int.")}function parseBfChar(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endbfchar"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectString(i);const s=i;e.mapOne(a,s)}}function parseBfRange(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endbfrange"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectString(i);const s=strToInt(i);i=t.getObj();if(Number.isInteger(i)||"string"==typeof i){const t=Number.isInteger(i)?String.fromCharCode(i):i;e.mapBfRange(a,s,t)}else{if(!isCmd(i,"["))break;{i=t.getObj();const r=[];for(;!isCmd(i,"]")&&i!==Bt;){r.push(i);i=t.getObj()}e.mapBfRangeToArray(a,s,r)}}}throw new FormatError("Invalid bf range.")}function parseCidChar(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endcidchar"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectInt(i);const s=i;e.mapOne(a,s)}}function parseCidRange(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endcidrange"))return;expectString(i);const a=strToInt(i);i=t.getObj();expectString(i);const s=strToInt(i);i=t.getObj();expectInt(i);const r=i;e.mapCidRange(a,s,r)}}function parseCodespaceRange(e,t){for(;;){let i=t.getObj();if(i===Bt)break;if(isCmd(i,"endcodespacerange"))return;if("string"!=typeof i)break;const a=strToInt(i);i=t.getObj();if("string"!=typeof i)break;const s=strToInt(i);e.addCodespaceRange(i.length,a,s)}throw new FormatError("Invalid codespace range.")}function parseWMode(e,t){const i=t.getObj();Number.isInteger(i)&&(e.vertical=!!i)}function parseCMapName(e,t){const i=t.getObj();i instanceof Name&&(e.name=i.name)}async function parseCMap(e,t,i,a){let s,r;A:for(;;)try{const i=t.getObj();if(i===Bt)break;if(i instanceof Name){"WMode"===i.name?parseWMode(e,t):"CMapName"===i.name&&parseCMapName(e,t);s=i}else if(i instanceof Cmd)switch(i.cmd){case"endcmap":break A;case"usecmap":s instanceof Name&&(r=s.name);break;case"begincodespacerange":parseCodespaceRange(e,t);break;case"beginbfchar":parseBfChar(e,t);break;case"begincidchar":parseCidChar(e,t);break;case"beginbfrange":parseBfRange(e,t);break;case"begincidrange":parseCidRange(e,t)}}catch(e){if(e instanceof MissingDataException)throw e;warn("Invalid cMap data: "+e);continue}!a&&r&&(a=r);return a?extendCMap(e,i,a):e}async function extendCMap(e,t,i){e.useCMap=await createBuiltInCMap(i,t);if(0===e.numCodespaceRanges){const t=e.useCMap.codespaceRanges;for(let i=0;iextendCMap(s,t,e)));const r=new Lexer(new Stream(i));return parseCMap(s,r,t,null)}class CMapFactory{static async create({encoding:e,fetchBuiltInCMap:t,useCMap:i}){if(e instanceof Name)return createBuiltInCMap(e.name,t);if(e instanceof BaseStream){const a=await parseCMap(new CMap,new Lexer(e),t,i);return a.isIdentityCMap?createBuiltInCMap(a.name,t):a}throw new Error("Encoding required.")}}const ni=[".notdef","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","endash","dagger","daggerdbl","periodcentered","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","questiondown","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","ring","cedilla","hungarumlaut","ogonek","caron","emdash","AE","ordfeminine","Lslash","Oslash","OE","ordmasculine","ae","dotlessi","lslash","oslash","oe","germandbls","onesuperior","logicalnot","mu","trademark","Eth","onehalf","plusminus","Thorn","onequarter","divide","brokenbar","degree","thorn","threequarters","twosuperior","registered","minus","eth","multiply","threesuperior","copyright","Aacute","Acircumflex","Adieresis","Agrave","Aring","Atilde","Ccedilla","Eacute","Ecircumflex","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Ntilde","Oacute","Ocircumflex","Odieresis","Ograve","Otilde","Scaron","Uacute","Ucircumflex","Udieresis","Ugrave","Yacute","Ydieresis","Zcaron","aacute","acircumflex","adieresis","agrave","aring","atilde","ccedilla","eacute","ecircumflex","edieresis","egrave","iacute","icircumflex","idieresis","igrave","ntilde","oacute","ocircumflex","odieresis","ograve","otilde","scaron","uacute","ucircumflex","udieresis","ugrave","yacute","ydieresis","zcaron"],gi=[".notdef","space","exclamsmall","Hungarumlautsmall","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","fi","fl","ffi","ffl","parenleftinferior","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","exclamdownsmall","centoldstyle","Lslashsmall","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","Dotaccentsmall","Macronsmall","figuredash","hypheninferior","Ogoneksmall","Ringsmall","Cedillasmall","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],oi=[".notdef","space","dollaroldstyle","dollarsuperior","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","fi","fl","ffi","ffl","parenleftinferior","parenrightinferior","hyphensuperior","colonmonetary","onefitted","rupiah","centoldstyle","figuredash","hypheninferior","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior"],Ii=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","commasuperior","threequartersemdash","periodsuperior","questionsmall","","asuperior","bsuperior","centsuperior","dsuperior","esuperior","","","","isuperior","","","lsuperior","msuperior","nsuperior","osuperior","","","rsuperior","ssuperior","tsuperior","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdownsmall","centoldstyle","Lslashsmall","","","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","","Dotaccentsmall","","","Macronsmall","","","figuredash","hypheninferior","","","Ogoneksmall","Ringsmall","Cedillasmall","","","","onequarter","onehalf","threequarters","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","zerosuperior","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall"],ci=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclamsmall","Hungarumlautsmall","centoldstyle","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","comma","hyphen","period","fraction","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","colon","semicolon","","threequartersemdash","","questionsmall","","","","","Ethsmall","","","onequarter","onehalf","threequarters","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","","","","","","","ff","fi","fl","ffi","ffl","parenleftinferior","","parenrightinferior","Circumflexsmall","hypheninferior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","","","asuperior","centsuperior","","","","","Aacutesmall","Agravesmall","Acircumflexsmall","Adieresissmall","Atildesmall","Aringsmall","Ccedillasmall","Eacutesmall","Egravesmall","Ecircumflexsmall","Edieresissmall","Iacutesmall","Igravesmall","Icircumflexsmall","Idieresissmall","Ntildesmall","Oacutesmall","Ogravesmall","Ocircumflexsmall","Odieresissmall","Otildesmall","Uacutesmall","Ugravesmall","Ucircumflexsmall","Udieresissmall","","eightsuperior","fourinferior","threeinferior","sixinferior","eightinferior","seveninferior","Scaronsmall","","centinferior","twoinferior","","Dieresissmall","","Caronsmall","osuperior","fiveinferior","","commainferior","periodinferior","Yacutesmall","","dollarinferior","","","Thornsmall","","nineinferior","zeroinferior","Zcaronsmall","AEsmall","Oslashsmall","questiondownsmall","oneinferior","Lslashsmall","","","","","","","Cedillasmall","","","","","","OEsmall","figuredash","hyphensuperior","","","","","exclamdownsmall","","Ydieresissmall","","onesuperior","twosuperior","threesuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","ninesuperior","zerosuperior","","esuperior","rsuperior","tsuperior","","","isuperior","ssuperior","dsuperior","","","","","","lsuperior","Ogoneksmall","Brevesmall","Macronsmall","bsuperior","nsuperior","msuperior","commasuperior","periodsuperior","Dotaccentsmall","Ringsmall","","","",""],Ci=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","space","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron"],hi=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","","endash","dagger","daggerdbl","periodcentered","","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","","questiondown","","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","","ring","cedilla","","hungarumlaut","ogonek","caron","emdash","","","","","","","","","","","","","","","","","AE","","ordfeminine","","","","","Lslash","Oslash","OE","ordmasculine","","","","","","ae","","","","dotlessi","","","lslash","oslash","oe","germandbls","","","",""],li=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","bullet","Euro","bullet","quotesinglbase","florin","quotedblbase","ellipsis","dagger","daggerdbl","circumflex","perthousand","Scaron","guilsinglleft","OE","bullet","Zcaron","bullet","bullet","quoteleft","quoteright","quotedblleft","quotedblright","bullet","endash","emdash","tilde","trademark","scaron","guilsinglright","oe","bullet","zcaron","Ydieresis","space","exclamdown","cent","sterling","currency","yen","brokenbar","section","dieresis","copyright","ordfeminine","guillemotleft","logicalnot","hyphen","registered","macron","degree","plusminus","twosuperior","threesuperior","acute","mu","paragraph","periodcentered","cedilla","onesuperior","ordmasculine","guillemotright","onequarter","onehalf","threequarters","questiondown","Agrave","Aacute","Acircumflex","Atilde","Adieresis","Aring","AE","Ccedilla","Egrave","Eacute","Ecircumflex","Edieresis","Igrave","Iacute","Icircumflex","Idieresis","Eth","Ntilde","Ograve","Oacute","Ocircumflex","Otilde","Odieresis","multiply","Oslash","Ugrave","Uacute","Ucircumflex","Udieresis","Yacute","Thorn","germandbls","agrave","aacute","acircumflex","atilde","adieresis","aring","ae","ccedilla","egrave","eacute","ecircumflex","edieresis","igrave","iacute","icircumflex","idieresis","eth","ntilde","ograve","oacute","ocircumflex","otilde","odieresis","divide","oslash","ugrave","uacute","ucircumflex","udieresis","yacute","thorn","ydieresis"],Bi=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","exclam","universal","numbersign","existential","percent","ampersand","suchthat","parenleft","parenright","asteriskmath","plus","comma","minus","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","congruent","Alpha","Beta","Chi","Delta","Epsilon","Phi","Gamma","Eta","Iota","theta1","Kappa","Lambda","Mu","Nu","Omicron","Pi","Theta","Rho","Sigma","Tau","Upsilon","sigma1","Omega","Xi","Psi","Zeta","bracketleft","therefore","bracketright","perpendicular","underscore","radicalex","alpha","beta","chi","delta","epsilon","phi","gamma","eta","iota","phi1","kappa","lambda","mu","nu","omicron","pi","theta","rho","sigma","tau","upsilon","omega1","omega","xi","psi","zeta","braceleft","bar","braceright","similar","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","Euro","Upsilon1","minute","lessequal","fraction","infinity","florin","club","diamond","heart","spade","arrowboth","arrowleft","arrowup","arrowright","arrowdown","degree","plusminus","second","greaterequal","multiply","proportional","partialdiff","bullet","divide","notequal","equivalence","approxequal","ellipsis","arrowvertex","arrowhorizex","carriagereturn","aleph","Ifraktur","Rfraktur","weierstrass","circlemultiply","circleplus","emptyset","intersection","union","propersuperset","reflexsuperset","notsubset","propersubset","reflexsubset","element","notelement","angle","gradient","registerserif","copyrightserif","trademarkserif","product","radical","dotmath","logicalnot","logicaland","logicalor","arrowdblboth","arrowdblleft","arrowdblup","arrowdblright","arrowdbldown","lozenge","angleleft","registersans","copyrightsans","trademarksans","summation","parenlefttp","parenleftex","parenleftbt","bracketlefttp","bracketleftex","bracketleftbt","bracelefttp","braceleftmid","braceleftbt","braceex","","angleright","integral","integraltp","integralex","integralbt","parenrighttp","parenrightex","parenrightbt","bracketrighttp","bracketrightex","bracketrightbt","bracerighttp","bracerightmid","bracerightbt",""],Qi=["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","space","a1","a2","a202","a3","a4","a5","a119","a118","a117","a11","a12","a13","a14","a15","a16","a105","a17","a18","a19","a20","a21","a22","a23","a24","a25","a26","a27","a28","a6","a7","a8","a9","a10","a29","a30","a31","a32","a33","a34","a35","a36","a37","a38","a39","a40","a41","a42","a43","a44","a45","a46","a47","a48","a49","a50","a51","a52","a53","a54","a55","a56","a57","a58","a59","a60","a61","a62","a63","a64","a65","a66","a67","a68","a69","a70","a71","a72","a73","a74","a203","a75","a204","a76","a77","a78","a79","a81","a82","a83","a84","a97","a98","a99","a100","","a89","a90","a93","a94","a91","a92","a205","a85","a206","a86","a87","a88","a95","a96","","","","","","","","","","","","","","","","","","","","a101","a102","a103","a104","a106","a107","a108","a112","a111","a110","a109","a120","a121","a122","a123","a124","a125","a126","a127","a128","a129","a130","a131","a132","a133","a134","a135","a136","a137","a138","a139","a140","a141","a142","a143","a144","a145","a146","a147","a148","a149","a150","a151","a152","a153","a154","a155","a156","a157","a158","a159","a160","a161","a163","a164","a196","a165","a192","a166","a167","a168","a169","a170","a171","a172","a173","a162","a174","a175","a176","a177","a178","a179","a193","a180","a199","a181","a200","a182","","a201","a183","a184","a197","a185","a194","a198","a186","a195","a187","a188","a189","a190","a191",""];function getEncoding(e){switch(e){case"WinAnsiEncoding":return li;case"StandardEncoding":return hi;case"MacRomanEncoding":return Ci;case"SymbolSetEncoding":return Bi;case"ZapfDingbatsEncoding":return Qi;case"ExpertEncoding":return Ii;case"MacExpertEncoding":return ci;default:return null}}const Ei=[".notdef","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quoteright","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","quoteleft","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","exclamdown","cent","sterling","fraction","yen","florin","section","currency","quotesingle","quotedblleft","guillemotleft","guilsinglleft","guilsinglright","fi","fl","endash","dagger","daggerdbl","periodcentered","paragraph","bullet","quotesinglbase","quotedblbase","quotedblright","guillemotright","ellipsis","perthousand","questiondown","grave","acute","circumflex","tilde","macron","breve","dotaccent","dieresis","ring","cedilla","hungarumlaut","ogonek","caron","emdash","AE","ordfeminine","Lslash","Oslash","OE","ordmasculine","ae","dotlessi","lslash","oslash","oe","germandbls","onesuperior","logicalnot","mu","trademark","Eth","onehalf","plusminus","Thorn","onequarter","divide","brokenbar","degree","thorn","threequarters","twosuperior","registered","minus","eth","multiply","threesuperior","copyright","Aacute","Acircumflex","Adieresis","Agrave","Aring","Atilde","Ccedilla","Eacute","Ecircumflex","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Ntilde","Oacute","Ocircumflex","Odieresis","Ograve","Otilde","Scaron","Uacute","Ucircumflex","Udieresis","Ugrave","Yacute","Ydieresis","Zcaron","aacute","acircumflex","adieresis","agrave","aring","atilde","ccedilla","eacute","ecircumflex","edieresis","egrave","iacute","icircumflex","idieresis","igrave","ntilde","oacute","ocircumflex","odieresis","ograve","otilde","scaron","uacute","ucircumflex","udieresis","ugrave","yacute","ydieresis","zcaron","exclamsmall","Hungarumlautsmall","dollaroldstyle","dollarsuperior","ampersandsmall","Acutesmall","parenleftsuperior","parenrightsuperior","twodotenleader","onedotenleader","zerooldstyle","oneoldstyle","twooldstyle","threeoldstyle","fouroldstyle","fiveoldstyle","sixoldstyle","sevenoldstyle","eightoldstyle","nineoldstyle","commasuperior","threequartersemdash","periodsuperior","questionsmall","asuperior","bsuperior","centsuperior","dsuperior","esuperior","isuperior","lsuperior","msuperior","nsuperior","osuperior","rsuperior","ssuperior","tsuperior","ff","ffi","ffl","parenleftinferior","parenrightinferior","Circumflexsmall","hyphensuperior","Gravesmall","Asmall","Bsmall","Csmall","Dsmall","Esmall","Fsmall","Gsmall","Hsmall","Ismall","Jsmall","Ksmall","Lsmall","Msmall","Nsmall","Osmall","Psmall","Qsmall","Rsmall","Ssmall","Tsmall","Usmall","Vsmall","Wsmall","Xsmall","Ysmall","Zsmall","colonmonetary","onefitted","rupiah","Tildesmall","exclamdownsmall","centoldstyle","Lslashsmall","Scaronsmall","Zcaronsmall","Dieresissmall","Brevesmall","Caronsmall","Dotaccentsmall","Macronsmall","figuredash","hypheninferior","Ogoneksmall","Ringsmall","Cedillasmall","questiondownsmall","oneeighth","threeeighths","fiveeighths","seveneighths","onethird","twothirds","zerosuperior","foursuperior","fivesuperior","sixsuperior","sevensuperior","eightsuperior","ninesuperior","zeroinferior","oneinferior","twoinferior","threeinferior","fourinferior","fiveinferior","sixinferior","seveninferior","eightinferior","nineinferior","centinferior","dollarinferior","periodinferior","commainferior","Agravesmall","Aacutesmall","Acircumflexsmall","Atildesmall","Adieresissmall","Aringsmall","AEsmall","Ccedillasmall","Egravesmall","Eacutesmall","Ecircumflexsmall","Edieresissmall","Igravesmall","Iacutesmall","Icircumflexsmall","Idieresissmall","Ethsmall","Ntildesmall","Ogravesmall","Oacutesmall","Ocircumflexsmall","Otildesmall","Odieresissmall","OEsmall","Oslashsmall","Ugravesmall","Uacutesmall","Ucircumflexsmall","Udieresissmall","Yacutesmall","Thornsmall","Ydieresissmall","001.000","001.001","001.002","001.003","Black","Bold","Book","Light","Medium","Regular","Roman","Semibold"],ui=391,di=[null,{id:"hstem",min:2,stackClearing:!0,stem:!0},null,{id:"vstem",min:2,stackClearing:!0,stem:!0},{id:"vmoveto",min:1,stackClearing:!0},{id:"rlineto",min:2,resetStack:!0},{id:"hlineto",min:1,resetStack:!0},{id:"vlineto",min:1,resetStack:!0},{id:"rrcurveto",min:6,resetStack:!0},null,{id:"callsubr",min:1,undefStack:!0},{id:"return",min:0,undefStack:!0},null,null,{id:"endchar",min:0,stackClearing:!0},null,null,null,{id:"hstemhm",min:2,stackClearing:!0,stem:!0},{id:"hintmask",min:0,stackClearing:!0},{id:"cntrmask",min:0,stackClearing:!0},{id:"rmoveto",min:2,stackClearing:!0},{id:"hmoveto",min:1,stackClearing:!0},{id:"vstemhm",min:2,stackClearing:!0,stem:!0},{id:"rcurveline",min:8,resetStack:!0},{id:"rlinecurve",min:8,resetStack:!0},{id:"vvcurveto",min:4,resetStack:!0},{id:"hhcurveto",min:4,resetStack:!0},null,{id:"callgsubr",min:1,undefStack:!0},{id:"vhcurveto",min:4,resetStack:!0},{id:"hvcurveto",min:4,resetStack:!0}],fi=[null,null,null,{id:"and",min:2,stackDelta:-1},{id:"or",min:2,stackDelta:-1},{id:"not",min:1,stackDelta:0},null,null,null,{id:"abs",min:1,stackDelta:0},{id:"add",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]+e[t-1]}},{id:"sub",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]-e[t-1]}},{id:"div",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]/e[t-1]}},null,{id:"neg",min:1,stackDelta:0,stackFn(e,t){e[t-1]=-e[t-1]}},{id:"eq",min:2,stackDelta:-1},null,null,{id:"drop",min:1,stackDelta:-1},null,{id:"put",min:2,stackDelta:-2},{id:"get",min:1,stackDelta:0},{id:"ifelse",min:4,stackDelta:-3},{id:"random",min:0,stackDelta:1},{id:"mul",min:2,stackDelta:-1,stackFn(e,t){e[t-2]=e[t-2]*e[t-1]}},null,{id:"sqrt",min:1,stackDelta:0},{id:"dup",min:1,stackDelta:1},{id:"exch",min:2,stackDelta:0},{id:"index",min:2,stackDelta:0},{id:"roll",min:3,stackDelta:-2},null,null,null,{id:"hflex",min:7,resetStack:!0},{id:"flex",min:13,resetStack:!0},{id:"hflex1",min:9,resetStack:!0},{id:"flex1",min:11,resetStack:!0}];class CFFParser{constructor(e,t,i){this.bytes=e.getBytes();this.properties=t;this.seacAnalysisEnabled=!!i}parse(){const e=this.properties,t=new CFF;this.cff=t;const i=this.parseHeader(),a=this.parseIndex(i.endPos),s=this.parseIndex(a.endPos),r=this.parseIndex(s.endPos),n=this.parseIndex(r.endPos),g=this.parseDict(s.obj.get(0)),o=this.createDict(CFFTopDict,g,t.strings);t.header=i.obj;t.names=this.parseNameIndex(a.obj);t.strings=this.parseStringIndex(r.obj);t.topDict=o;t.globalSubrIndex=n.obj;this.parsePrivateDict(t.topDict);t.isCIDFont=o.hasName("ROS");const c=o.getByName("CharStrings"),C=this.parseIndex(c).obj,h=o.getByName("FontMatrix");h&&(e.fontMatrix=h);const l=o.getByName("FontBBox");if(l){e.ascent=Math.max(l[3],l[1]);e.descent=Math.min(l[1],l[3]);e.ascentScaled=!0}let Q,E;if(t.isCIDFont){const e=this.parseIndex(o.getByName("FDArray")).obj;for(let i=0,a=e.count;i=t)throw new FormatError("Invalid CFF header");if(0!==i){info("cff data is shifted");e=e.subarray(i);this.bytes=e}const a=e[0],s=e[1],r=e[2],n=e[3];return{obj:new CFFHeader(a,s,r,n),endPos:r}}parseDict(e){let t=0;function parseOperand(){let i=e[t++];if(30===i)return function parseFloatOperand(){let i="";const a=15,s=["0","1","2","3","4","5","6","7","8","9",".","E","E-",null,"-"],r=e.length;for(;t>4,g=15&r;if(n===a)break;i+=s[n];if(g===a)break;i+=s[g]}return parseFloat(i)}();if(28===i){i=e[t++];i=(i<<24|e[t++]<<16)>>16;return i}if(29===i){i=e[t++];i=i<<8|e[t++];i=i<<8|e[t++];i=i<<8|e[t++];return i}if(i>=32&&i<=246)return i-139;if(i>=247&&i<=250)return 256*(i-247)+e[t++]+108;if(i>=251&&i<=254)return-256*(i-251)-e[t++]-108;warn('CFFParser_parseDict: "'+i+'" is a reserved command.');return NaN}let i=[];const a=[];t=0;const s=e.length;for(;t10)return!1;let s=e.stackSize;const r=e.stack;let n=t.length;for(let g=0;g>16;g+=2;s++}else if(14===o){if(s>=4){s-=4;if(this.seacAnalysisEnabled){e.seac=r.slice(s,s+4);return!1}}c=di[o]}else if(o>=32&&o<=246){r[s]=o-139;s++}else if(o>=247&&o<=254){r[s]=o<251?(o-247<<8)+t[g]+108:-(o-251<<8)-t[g]-108;g++;s++}else if(255===o){r[s]=(t[g]<<24|t[g+1]<<16|t[g+2]<<8|t[g+3])/65536;g+=4;s++}else if(19===o||20===o){e.hints+=s>>1;if(0===e.hints){t.copyWithin(g-1,g,-1);g-=1;n-=1;continue}g+=e.hints+7>>3;s%=2;c=di[o]}else{if(10===o||29===o){const t=10===o?i:a;if(!t){c=di[o];warn("Missing subrsIndex for "+c.id);return!1}let n=32768;t.count<1240?n=107:t.count<33900&&(n=1131);const g=r[--s]+n;if(g<0||g>=t.count||isNaN(g)){c=di[o];warn("Out of bounds subrIndex for "+c.id);return!1}e.stackSize=s;e.callDepth++;if(!this.parseCharString(e,t.get(g),i,a))return!1;e.callDepth--;s=e.stackSize;continue}if(11===o){e.stackSize=s;return!0}if(0===o&&g===t.length){t[g-1]=14;c=di[14]}else{if(9===o){t.copyWithin(g-1,g,-1);g-=1;n-=1;continue}c=di[o]}}if(c){if(c.stem){e.hints+=s>>1;if(3===o||23===o)e.hasVStems=!0;else if(e.hasVStems&&(1===o||18===o)){warn("CFF stem hints are in wrong order");t[g-1]=1===o?3:23}}if("min"in c&&!e.undefStack&&s=2&&c.stem?s%=2:s>1&&warn("Found too many parameters for stack-clearing command");s>0&&(e.width=r[s-1])}if("stackDelta"in c){"stackFn"in c&&c.stackFn(r,s);s+=c.stackDelta}else if(c.stackClearing)s=0;else if(c.resetStack){s=0;e.undefStack=!1}else if(c.undefStack){s=0;e.undefStack=!0;e.firstStackClearing=!1}}}n=s.length){warn("Invalid fd index for glyph index.");h=!1}if(h){Q=s[e].privateDict;l=Q.subrsIndex}}else t&&(l=t);h&&(h=this.parseCharString(C,o,l,i));if(null!==C.width){const e=Q.getByName("nominalWidthX");g[c]=e+C.width}else{const e=Q.getByName("defaultWidthX");g[c]=e}null!==C.seac&&(n[c]=C.seac);h||e.set(c,new Uint8Array([14]))}return{charStrings:e,seacs:n,widths:g}}emptyPrivateDictionary(e){const t=this.createDict(CFFPrivateDict,[],e.strings);e.setByKey(18,[0,0]);e.privateDict=t}parsePrivateDict(e){if(!e.hasName("Private")){this.emptyPrivateDictionary(e);return}const t=e.getByName("Private");if(!Array.isArray(t)||2!==t.length){e.removeByName("Private");return}const i=t[0],a=t[1];if(0===i||a>=this.bytes.length){this.emptyPrivateDictionary(e);return}const s=a+i,r=this.bytes.subarray(a,s),n=this.parseDict(r),g=this.createDict(CFFPrivateDict,n,e.strings);e.privateDict=g;0===g.getByName("ExpansionFactor")&&g.setByName("ExpansionFactor",.06);if(!g.getByName("Subrs"))return;const o=g.getByName("Subrs"),c=a+o;if(0===o||c>=this.bytes.length){this.emptyPrivateDictionary(e);return}const C=this.parseIndex(c);g.subrsIndex=C.obj}parseCharsets(e,t,i,a){if(0===e)return new CFFCharset(!0,yi.ISO_ADOBE,ni);if(1===e)return new CFFCharset(!0,yi.EXPERT,gi);if(2===e)return new CFFCharset(!0,yi.EXPERT_SUBSET,oi);const s=this.bytes,r=e,n=s[e++],g=[a?0:".notdef"];let o,c,C;t-=1;switch(n){case 0:for(C=0;C=65535){warn("Not enough space in charstrings to duplicate first glyph.");return}const e=this.charStrings.get(0);this.charStrings.add(e);this.isCIDFont&&this.fdSelect.fdSelect.push(this.fdSelect.fdSelect[0])}hasGlyphId(e){if(e<0||e>=this.charStrings.count)return!1;return this.charStrings.get(e).length>0}}class CFFHeader{constructor(e,t,i,a){this.major=e;this.minor=t;this.hdrSize=i;this.offSize=a}}class CFFStrings{constructor(){this.strings=[]}get(e){return e>=0&&e<=390?Ei[e]:e-ui<=this.strings.length?this.strings[e-ui]:Ei[0]}getSID(e){let t=Ei.indexOf(e);if(-1!==t)return t;t=this.strings.indexOf(e);return-1!==t?t+ui:-1}add(e){this.strings.push(e)}get count(){return this.strings.length}}class CFFIndex{constructor(){this.objects=[];this.length=0}add(e){this.length+=e.length;this.objects.push(e)}set(e,t){this.length+=t.length-this.objects[e].length;this.objects[e]=t}get(e){return this.objects[e]}get count(){return this.objects.length}}class CFFDict{constructor(e,t){this.keyToNameMap=e.keyToNameMap;this.nameToKeyMap=e.nameToKeyMap;this.defaults=e.defaults;this.types=e.types;this.opcodes=e.opcodes;this.order=e.order;this.strings=t;this.values=Object.create(null)}setByKey(e,t){if(!(e in this.keyToNameMap))return!1;if(0===t.length)return!0;for(const i of t)if(isNaN(i)){warn(`Invalid CFFDict value: "${t}" for key "${e}".`);return!0}const i=this.types[e];"num"!==i&&"sid"!==i&&"offset"!==i||(t=t[0]);this.values[e]=t;return!0}setByName(e,t){if(!(e in this.nameToKeyMap))throw new FormatError(`Invalid dictionary name "${e}"`);this.values[this.nameToKeyMap[e]]=t}hasName(e){return this.nameToKeyMap[e]in this.values}getByName(e){if(!(e in this.nameToKeyMap))throw new FormatError(`Invalid dictionary name ${e}"`);const t=this.nameToKeyMap[e];return t in this.values?this.values[t]:this.defaults[t]}removeByName(e){delete this.values[this.nameToKeyMap[e]]}static createTables(e){const t={keyToNameMap:{},nameToKeyMap:{},defaults:{},types:{},opcodes:{},order:[]};for(const i of e){const e=Array.isArray(i[0])?(i[0][0]<<8)+i[0][1]:i[0];t.keyToNameMap[e]=i[1];t.nameToKeyMap[i[1]]=e;t.types[e]=i[2];t.defaults[e]=i[3];t.opcodes[e]=Array.isArray(i[0])?i[0]:[i[0]];t.order.push(e)}return t}}const pi=[[[12,30],"ROS",["sid","sid","num"],null],[[12,20],"SyntheticBase","num",null],[0,"version","sid",null],[1,"Notice","sid",null],[[12,0],"Copyright","sid",null],[2,"FullName","sid",null],[3,"FamilyName","sid",null],[4,"Weight","sid",null],[[12,1],"isFixedPitch","num",0],[[12,2],"ItalicAngle","num",0],[[12,3],"UnderlinePosition","num",-100],[[12,4],"UnderlineThickness","num",50],[[12,5],"PaintType","num",0],[[12,6],"CharstringType","num",2],[[12,7],"FontMatrix",["num","num","num","num","num","num"],[.001,0,0,.001,0,0]],[13,"UniqueID","num",null],[5,"FontBBox",["num","num","num","num"],[0,0,0,0]],[[12,8],"StrokeWidth","num",0],[14,"XUID","array",null],[15,"charset","offset",0],[16,"Encoding","offset",0],[17,"CharStrings","offset",0],[18,"Private",["offset","offset"],null],[[12,21],"PostScript","sid",null],[[12,22],"BaseFontName","sid",null],[[12,23],"BaseFontBlend","delta",null],[[12,31],"CIDFontVersion","num",0],[[12,32],"CIDFontRevision","num",0],[[12,33],"CIDFontType","num",0],[[12,34],"CIDCount","num",8720],[[12,35],"UIDBase","num",null],[[12,37],"FDSelect","offset",null],[[12,36],"FDArray","offset",null],[[12,38],"FontName","sid",null]];class CFFTopDict extends CFFDict{static get tables(){return shadow(this,"tables",this.createTables(pi))}constructor(e){super(CFFTopDict.tables,e);this.privateDict=null}}const mi=[[6,"BlueValues","delta",null],[7,"OtherBlues","delta",null],[8,"FamilyBlues","delta",null],[9,"FamilyOtherBlues","delta",null],[[12,9],"BlueScale","num",.039625],[[12,10],"BlueShift","num",7],[[12,11],"BlueFuzz","num",1],[10,"StdHW","num",null],[11,"StdVW","num",null],[[12,12],"StemSnapH","delta",null],[[12,13],"StemSnapV","delta",null],[[12,14],"ForceBold","num",0],[[12,17],"LanguageGroup","num",0],[[12,18],"ExpansionFactor","num",.06],[[12,19],"initialRandomSeed","num",0],[20,"defaultWidthX","num",0],[21,"nominalWidthX","num",0],[19,"Subrs","offset",null]];class CFFPrivateDict extends CFFDict{static get tables(){return shadow(this,"tables",this.createTables(mi))}constructor(e){super(CFFPrivateDict.tables,e);this.subrsIndex=null}}const yi={ISO_ADOBE:0,EXPERT:1,EXPERT_SUBSET:2};class CFFCharset{constructor(e,t,i,a){this.predefined=e;this.format=t;this.charset=i;this.raw=a}}class CFFEncoding{constructor(e,t,i,a){this.predefined=e;this.format=t;this.encoding=i;this.raw=a}}class CFFFDSelect{constructor(e,t){this.format=e;this.fdSelect=t}getFDIndex(e){return e<0||e>=this.fdSelect.length?-1:this.fdSelect[e]}}class CFFOffsetTracker{constructor(){this.offsets=Object.create(null)}isTracking(e){return e in this.offsets}track(e,t){if(e in this.offsets)throw new FormatError(`Already tracking location of ${e}`);this.offsets[e]=t}offset(e){for(const t in this.offsets)this.offsets[t]+=e}setEntryLocation(e,t,i){if(!(e in this.offsets))throw new FormatError(`Not tracking location of ${e}`);const a=i.data,s=this.offsets[e];for(let e=0,i=t.length;e>24&255;a[n]=c>>16&255;a[g]=c>>8&255;a[o]=255&c}}}class CFFCompiler{constructor(e){this.cff=e}compile(){const e=this.cff,t={data:[],length:0,add(e){try{this.data.push(...e)}catch{this.data=this.data.concat(e)}this.length=this.data.length}},i=this.compileHeader(e.header);t.add(i);const a=this.compileNameIndex(e.names);t.add(a);if(e.isCIDFont&&e.topDict.hasName("FontMatrix")){const t=e.topDict.getByName("FontMatrix");e.topDict.removeByName("FontMatrix");for(const i of e.fdArray){let e=t.slice(0);i.hasName("FontMatrix")&&(e=Util.transform(e,i.getByName("FontMatrix")));i.setByName("FontMatrix",e)}}const s=e.topDict.getByName("XUID");s?.length>16&&e.topDict.removeByName("XUID");e.topDict.setByName("charset",0);let r=this.compileTopDicts([e.topDict],t.length,e.isCIDFont);t.add(r.output);const n=r.trackers[0],g=this.compileStringIndex(e.strings.strings);t.add(g);const o=this.compileIndex(e.globalSubrIndex);t.add(o);if(e.encoding&&e.topDict.hasName("Encoding"))if(e.encoding.predefined)n.setEntryLocation("Encoding",[e.encoding.format],t);else{const i=this.compileEncoding(e.encoding);n.setEntryLocation("Encoding",[t.length],t);t.add(i)}const c=this.compileCharset(e.charset,e.charStrings.count,e.strings,e.isCIDFont);n.setEntryLocation("charset",[t.length],t);t.add(c);const C=this.compileCharStrings(e.charStrings);n.setEntryLocation("CharStrings",[t.length],t);t.add(C);if(e.isCIDFont){n.setEntryLocation("FDSelect",[t.length],t);const i=this.compileFDSelect(e.fdSelect);t.add(i);r=this.compileTopDicts(e.fdArray,t.length,!0);n.setEntryLocation("FDArray",[t.length],t);t.add(r.output);const a=r.trackers;this.compilePrivateDicts(e.fdArray,a,t)}this.compilePrivateDicts([e.topDict],[n],t);t.add([0]);return t.data}encodeNumber(e){return Number.isInteger(e)?this.encodeInteger(e):this.encodeFloat(e)}static get EncodeFloatRegExp(){return shadow(this,"EncodeFloatRegExp",/\.(\d*?)(?:9{5,20}|0{5,20})\d{0,2}(?:e(.+)|$)/)}encodeFloat(e){let t=e.toString();const i=CFFCompiler.EncodeFloatRegExp.exec(t);if(i){const a=parseFloat("1e"+((i[2]?+i[2]:0)+i[1].length));t=(Math.round(e*a)/a).toString()}let a,s,r="";for(a=0,s=t.length;a=-107&&e<=107?[e+139]:e>=108&&e<=1131?[247+((e-=108)>>8),255&e]:e>=-1131&&e<=-108?[251+((e=-e-108)>>8),255&e]:e>=-32768&&e<=32767?[28,e>>8&255,255&e]:[29,e>>24&255,e>>16&255,e>>8&255,255&e];return t}compileHeader(e){return[e.major,e.minor,4,e.offSize]}compileNameIndex(e){const t=new CFFIndex;for(const i of e){const e=Math.min(i.length,127);let a=new Array(e);for(let t=0;t"~"||"["===e||"]"===e||"("===e||")"===e||"{"===e||"}"===e||"<"===e||">"===e||"/"===e||"%"===e)&&(e="_");a[t]=e}a=a.join("");""===a&&(a="Bad_Font_Name");t.add(stringToBytes(a))}return this.compileIndex(t)}compileTopDicts(e,t,i){const a=[];let s=new CFFIndex;for(const r of e){if(i){r.removeByName("CIDFontVersion");r.removeByName("CIDFontRevision");r.removeByName("CIDFontType");r.removeByName("CIDCount");r.removeByName("UIDBase")}const e=new CFFOffsetTracker,n=this.compileDict(r,e);a.push(e);s.add(n);e.offset(t)}s=this.compileIndex(s,a);return{trackers:a,output:s}}compilePrivateDicts(e,t,i){for(let a=0,s=e.length;a>8&255,255&r]);else{s=new Uint8Array(1+2*r);s[0]=0;let t=0;const a=e.charset.length;let n=!1;for(let r=1;r>8&255;s[r+1]=255&g}}return this.compileTypedArray(s)}compileEncoding(e){return this.compileTypedArray(e.raw)}compileFDSelect(e){const t=e.format;let i,a;switch(t){case 0:i=new Uint8Array(1+e.fdSelect.length);i[0]=t;for(a=0;a>8&255,255&s,r];for(a=1;a>8&255,255&a,t);r=t}}const g=(n.length-3)/3;n[1]=g>>8&255;n[2]=255&g;n.push(a>>8&255,255&a);i=new Uint8Array(n)}return this.compileTypedArray(i)}compileTypedArray(e){return Array.from(e)}compileIndex(e,t=[]){const i=e.objects,a=i.length;if(0===a)return[0,0];const s=[a>>8&255,255&a];let r,n,g=1;for(r=0;r>8&255,255&o):3===n?s.push(o>>16&255,o>>8&255,255&o):s.push(o>>>24&255,o>>16&255,o>>8&255,255&o);i[r]&&(o+=i[r].length)}for(r=0;r=5&&t<=7))return-1;a=e.substring(1)}if(a===a.toUpperCase()){i=parseInt(a,16);if(i>=0)return i}}return-1}const Fi=[[0,127],[128,255],[256,383],[384,591],[592,687,7424,7551,7552,7615],[688,767,42752,42783],[768,879,7616,7679],[880,1023],[11392,11519],[1024,1279,1280,1327,11744,11775,42560,42655],[1328,1423],[1424,1535],[42240,42559],[1536,1791,1872,1919],[1984,2047],[2304,2431],[2432,2559],[2560,2687],[2688,2815],[2816,2943],[2944,3071],[3072,3199],[3200,3327],[3328,3455],[3584,3711],[3712,3839],[4256,4351,11520,11567],[6912,7039],[4352,4607],[7680,7935,11360,11391,42784,43007],[7936,8191],[8192,8303,11776,11903],[8304,8351],[8352,8399],[8400,8447],[8448,8527],[8528,8591],[8592,8703,10224,10239,10496,10623,11008,11263],[8704,8959,10752,11007,10176,10223,10624,10751],[8960,9215],[9216,9279],[9280,9311],[9312,9471],[9472,9599],[9600,9631],[9632,9727],[9728,9983],[9984,10175],[12288,12351],[12352,12447],[12448,12543,12784,12799],[12544,12591,12704,12735],[12592,12687],[43072,43135],[12800,13055],[13056,13311],[44032,55215],[55296,57343],[67840,67871],[19968,40959,11904,12031,12032,12255,12272,12287,13312,19903,131072,173791,12688,12703],[57344,63743],[12736,12783,63744,64255,194560,195103],[64256,64335],[64336,65023],[65056,65071],[65040,65055],[65104,65135],[65136,65279],[65280,65519],[65520,65535],[3840,4095],[1792,1871],[1920,1983],[3456,3583],[4096,4255],[4608,4991,4992,5023,11648,11743],[5024,5119],[5120,5759],[5760,5791],[5792,5887],[6016,6143],[6144,6319],[10240,10495],[40960,42127],[5888,5919,5920,5951,5952,5983,5984,6015],[66304,66351],[66352,66383],[66560,66639],[118784,119039,119040,119295,119296,119375],[119808,120831],[1044480,1048573],[65024,65039,917760,917999],[917504,917631],[6400,6479],[6480,6527],[6528,6623],[6656,6687],[11264,11359],[11568,11647],[19904,19967],[43008,43055],[65536,65663,65664,65791,65792,65855],[65856,65935],[66432,66463],[66464,66527],[66640,66687],[66688,66735],[67584,67647],[68096,68191],[119552,119647],[73728,74751,74752,74879],[119648,119679],[7040,7103],[7168,7247],[7248,7295],[43136,43231],[43264,43311],[43312,43359],[43520,43615],[65936,65999],[66e3,66047],[66208,66271,66176,66207,67872,67903],[127024,127135,126976,127023]];function getUnicodeRangeFor(e,t=-1){if(-1!==t){const i=Fi[t];for(let a=0,s=i.length;a=i[a]&&e<=i[a+1])return t}for(let t=0,i=Fi.length;t=i[a]&&e<=i[a+1])return t}return-1}const Si=new RegExp("^(\\s)|(\\p{Mn})|(\\p{Cf})$","u"),ki=new Map;const Ri=!0,Ni=1,Gi=2,Mi=4,xi=32,Hi=[".notdef",".null","nonmarkingreturn","space","exclam","quotedbl","numbersign","dollar","percent","ampersand","quotesingle","parenleft","parenright","asterisk","plus","comma","hyphen","period","slash","zero","one","two","three","four","five","six","seven","eight","nine","colon","semicolon","less","equal","greater","question","at","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","bracketleft","backslash","bracketright","asciicircum","underscore","grave","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","braceleft","bar","braceright","asciitilde","Adieresis","Aring","Ccedilla","Eacute","Ntilde","Odieresis","Udieresis","aacute","agrave","acircumflex","adieresis","atilde","aring","ccedilla","eacute","egrave","ecircumflex","edieresis","iacute","igrave","icircumflex","idieresis","ntilde","oacute","ograve","ocircumflex","odieresis","otilde","uacute","ugrave","ucircumflex","udieresis","dagger","degree","cent","sterling","section","bullet","paragraph","germandbls","registered","copyright","trademark","acute","dieresis","notequal","AE","Oslash","infinity","plusminus","lessequal","greaterequal","yen","mu","partialdiff","summation","product","pi","integral","ordfeminine","ordmasculine","Omega","ae","oslash","questiondown","exclamdown","logicalnot","radical","florin","approxequal","Delta","guillemotleft","guillemotright","ellipsis","nonbreakingspace","Agrave","Atilde","Otilde","OE","oe","endash","emdash","quotedblleft","quotedblright","quoteleft","quoteright","divide","lozenge","ydieresis","Ydieresis","fraction","currency","guilsinglleft","guilsinglright","fi","fl","daggerdbl","periodcentered","quotesinglbase","quotedblbase","perthousand","Acircumflex","Ecircumflex","Aacute","Edieresis","Egrave","Iacute","Icircumflex","Idieresis","Igrave","Oacute","Ocircumflex","apple","Ograve","Uacute","Ucircumflex","Ugrave","dotlessi","circumflex","tilde","macron","breve","dotaccent","ring","cedilla","hungarumlaut","ogonek","caron","Lslash","lslash","Scaron","scaron","Zcaron","zcaron","brokenbar","Eth","eth","Yacute","yacute","Thorn","thorn","minus","multiply","onesuperior","twosuperior","threesuperior","onehalf","onequarter","threequarters","franc","Gbreve","gbreve","Idotaccent","Scedilla","scedilla","Cacute","cacute","Ccaron","ccaron","dcroat"];function recoverGlyphName(e,t){if(void 0!==t[e])return e;const i=getUnicodeForGlyph(e,t);if(-1!==i)for(const e in t)if(t[e]===i)return e;info("Unable to recover a standard glyph name for: "+e);return e}function type1FontGlyphMapping(e,t,i){const a=Object.create(null);let s,r,n;const g=!!(e.flags&Mi);if(e.isInternalFont){n=t;for(r=0;r=0?s:0}}else if(e.baseEncodingName){n=getEncoding(e.baseEncodingName);for(r=0;r=0?s:0}}else if(g)for(r in t)a[r]=t[r];else{n=hi;for(r=0;r=0?s:0}}const o=e.differences;let c;if(o)for(r in o){const e=o[r];s=i.indexOf(e);if(-1===s){c||(c=wi());const t=recoverGlyphName(e,c);t!==e&&(s=i.indexOf(t))}a[r]=s>=0?s:0}return a}function normalizeFontName(e){return e.replaceAll(/[,_]/g,"-").replaceAll(/\s/g,"")}const Ji=getLookupTableFactory((e=>{e[8211]=65074;e[8212]=65073;e[8229]=65072;e[8230]=65049;e[12289]=65041;e[12290]=65042;e[12296]=65087;e[12297]=65088;e[12298]=65085;e[12299]=65086;e[12300]=65089;e[12301]=65090;e[12302]=65091;e[12303]=65092;e[12304]=65083;e[12305]=65084;e[12308]=65081;e[12309]=65082;e[12310]=65047;e[12311]=65048;e[65103]=65076;e[65281]=65045;e[65288]=65077;e[65289]=65078;e[65292]=65040;e[65306]=65043;e[65307]=65044;e[65311]=65046;e[65339]=65095;e[65341]=65096;e[65343]=65075;e[65371]=65079;e[65373]=65080})),Yi=getLookupTableFactory((function(e){e["Times-Roman"]="Times-Roman";e.Helvetica="Helvetica";e.Courier="Courier";e.Symbol="Symbol";e["Times-Bold"]="Times-Bold";e["Helvetica-Bold"]="Helvetica-Bold";e["Courier-Bold"]="Courier-Bold";e.ZapfDingbats="ZapfDingbats";e["Times-Italic"]="Times-Italic";e["Helvetica-Oblique"]="Helvetica-Oblique";e["Courier-Oblique"]="Courier-Oblique";e["Times-BoldItalic"]="Times-BoldItalic";e["Helvetica-BoldOblique"]="Helvetica-BoldOblique";e["Courier-BoldOblique"]="Courier-BoldOblique";e.ArialNarrow="Helvetica";e["ArialNarrow-Bold"]="Helvetica-Bold";e["ArialNarrow-BoldItalic"]="Helvetica-BoldOblique";e["ArialNarrow-Italic"]="Helvetica-Oblique";e.ArialBlack="Helvetica";e["ArialBlack-Bold"]="Helvetica-Bold";e["ArialBlack-BoldItalic"]="Helvetica-BoldOblique";e["ArialBlack-Italic"]="Helvetica-Oblique";e["Arial-Black"]="Helvetica";e["Arial-Black-Bold"]="Helvetica-Bold";e["Arial-Black-BoldItalic"]="Helvetica-BoldOblique";e["Arial-Black-Italic"]="Helvetica-Oblique";e.Arial="Helvetica";e["Arial-Bold"]="Helvetica-Bold";e["Arial-BoldItalic"]="Helvetica-BoldOblique";e["Arial-Italic"]="Helvetica-Oblique";e.ArialMT="Helvetica";e["Arial-BoldItalicMT"]="Helvetica-BoldOblique";e["Arial-BoldMT"]="Helvetica-Bold";e["Arial-ItalicMT"]="Helvetica-Oblique";e["Arial-BoldItalicMT-BoldItalic"]="Helvetica-BoldOblique";e["Arial-BoldMT-Bold"]="Helvetica-Bold";e["Arial-ItalicMT-Italic"]="Helvetica-Oblique";e.ArialUnicodeMS="Helvetica";e["ArialUnicodeMS-Bold"]="Helvetica-Bold";e["ArialUnicodeMS-BoldItalic"]="Helvetica-BoldOblique";e["ArialUnicodeMS-Italic"]="Helvetica-Oblique";e["Courier-BoldItalic"]="Courier-BoldOblique";e["Courier-Italic"]="Courier-Oblique";e.CourierNew="Courier";e["CourierNew-Bold"]="Courier-Bold";e["CourierNew-BoldItalic"]="Courier-BoldOblique";e["CourierNew-Italic"]="Courier-Oblique";e["CourierNewPS-BoldItalicMT"]="Courier-BoldOblique";e["CourierNewPS-BoldMT"]="Courier-Bold";e["CourierNewPS-ItalicMT"]="Courier-Oblique";e.CourierNewPSMT="Courier";e["Helvetica-BoldItalic"]="Helvetica-BoldOblique";e["Helvetica-Italic"]="Helvetica-Oblique";e["HelveticaLTStd-Bold"]="Helvetica-Bold";e["Symbol-Bold"]="Symbol";e["Symbol-BoldItalic"]="Symbol";e["Symbol-Italic"]="Symbol";e.TimesNewRoman="Times-Roman";e["TimesNewRoman-Bold"]="Times-Bold";e["TimesNewRoman-BoldItalic"]="Times-BoldItalic";e["TimesNewRoman-Italic"]="Times-Italic";e.TimesNewRomanPS="Times-Roman";e["TimesNewRomanPS-Bold"]="Times-Bold";e["TimesNewRomanPS-BoldItalic"]="Times-BoldItalic";e["TimesNewRomanPS-BoldItalicMT"]="Times-BoldItalic";e["TimesNewRomanPS-BoldMT"]="Times-Bold";e["TimesNewRomanPS-Italic"]="Times-Italic";e["TimesNewRomanPS-ItalicMT"]="Times-Italic";e.TimesNewRomanPSMT="Times-Roman";e["TimesNewRomanPSMT-Bold"]="Times-Bold";e["TimesNewRomanPSMT-BoldItalic"]="Times-BoldItalic";e["TimesNewRomanPSMT-Italic"]="Times-Italic"})),vi=getLookupTableFactory((function(e){e.Courier="FoxitFixed.pfb";e["Courier-Bold"]="FoxitFixedBold.pfb";e["Courier-BoldOblique"]="FoxitFixedBoldItalic.pfb";e["Courier-Oblique"]="FoxitFixedItalic.pfb";e.Helvetica="LiberationSans-Regular.ttf";e["Helvetica-Bold"]="LiberationSans-Bold.ttf";e["Helvetica-BoldOblique"]="LiberationSans-BoldItalic.ttf";e["Helvetica-Oblique"]="LiberationSans-Italic.ttf";e["Times-Roman"]="FoxitSerif.pfb";e["Times-Bold"]="FoxitSerifBold.pfb";e["Times-BoldItalic"]="FoxitSerifBoldItalic.pfb";e["Times-Italic"]="FoxitSerifItalic.pfb";e.Symbol="FoxitSymbol.pfb";e.ZapfDingbats="FoxitDingbats.pfb";e["LiberationSans-Regular"]="LiberationSans-Regular.ttf";e["LiberationSans-Bold"]="LiberationSans-Bold.ttf";e["LiberationSans-Italic"]="LiberationSans-Italic.ttf";e["LiberationSans-BoldItalic"]="LiberationSans-BoldItalic.ttf"})),Ki=getLookupTableFactory((function(e){e.Calibri="Helvetica";e["Calibri-Bold"]="Helvetica-Bold";e["Calibri-BoldItalic"]="Helvetica-BoldOblique";e["Calibri-Italic"]="Helvetica-Oblique";e.CenturyGothic="Helvetica";e["CenturyGothic-Bold"]="Helvetica-Bold";e["CenturyGothic-BoldItalic"]="Helvetica-BoldOblique";e["CenturyGothic-Italic"]="Helvetica-Oblique";e.ComicSansMS="Comic Sans MS";e["ComicSansMS-Bold"]="Comic Sans MS-Bold";e["ComicSansMS-BoldItalic"]="Comic Sans MS-BoldItalic";e["ComicSansMS-Italic"]="Comic Sans MS-Italic";e.GillSansMT="Helvetica";e["GillSansMT-Bold"]="Helvetica-Bold";e["GillSansMT-BoldItalic"]="Helvetica-BoldOblique";e["GillSansMT-Italic"]="Helvetica-Oblique";e.Impact="Helvetica";e["ItcSymbol-Bold"]="Helvetica-Bold";e["ItcSymbol-BoldItalic"]="Helvetica-BoldOblique";e["ItcSymbol-Book"]="Helvetica";e["ItcSymbol-BookItalic"]="Helvetica-Oblique";e["ItcSymbol-Medium"]="Helvetica";e["ItcSymbol-MediumItalic"]="Helvetica-Oblique";e.LucidaConsole="Courier";e["LucidaConsole-Bold"]="Courier-Bold";e["LucidaConsole-BoldItalic"]="Courier-BoldOblique";e["LucidaConsole-Italic"]="Courier-Oblique";e["LucidaSans-Demi"]="Helvetica-Bold";e["MS-Gothic"]="MS Gothic";e["MS-Gothic-Bold"]="MS Gothic-Bold";e["MS-Gothic-BoldItalic"]="MS Gothic-BoldItalic";e["MS-Gothic-Italic"]="MS Gothic-Italic";e["MS-Mincho"]="MS Mincho";e["MS-Mincho-Bold"]="MS Mincho-Bold";e["MS-Mincho-BoldItalic"]="MS Mincho-BoldItalic";e["MS-Mincho-Italic"]="MS Mincho-Italic";e["MS-PGothic"]="MS PGothic";e["MS-PGothic-Bold"]="MS PGothic-Bold";e["MS-PGothic-BoldItalic"]="MS PGothic-BoldItalic";e["MS-PGothic-Italic"]="MS PGothic-Italic";e["MS-PMincho"]="MS PMincho";e["MS-PMincho-Bold"]="MS PMincho-Bold";e["MS-PMincho-BoldItalic"]="MS PMincho-BoldItalic";e["MS-PMincho-Italic"]="MS PMincho-Italic";e.NuptialScript="Times-Italic";e.SegoeUISymbol="Helvetica"})),Ti=getLookupTableFactory((function(e){e["Adobe Jenson"]=!0;e["Adobe Text"]=!0;e.Albertus=!0;e.Aldus=!0;e.Alexandria=!0;e.Algerian=!0;e["American Typewriter"]=!0;e.Antiqua=!0;e.Apex=!0;e.Arno=!0;e.Aster=!0;e.Aurora=!0;e.Baskerville=!0;e.Bell=!0;e.Bembo=!0;e["Bembo Schoolbook"]=!0;e.Benguiat=!0;e["Berkeley Old Style"]=!0;e["Bernhard Modern"]=!0;e["Berthold City"]=!0;e.Bodoni=!0;e["Bauer Bodoni"]=!0;e["Book Antiqua"]=!0;e.Bookman=!0;e["Bordeaux Roman"]=!0;e["Californian FB"]=!0;e.Calisto=!0;e.Calvert=!0;e.Capitals=!0;e.Cambria=!0;e.Cartier=!0;e.Caslon=!0;e.Catull=!0;e.Centaur=!0;e["Century Old Style"]=!0;e["Century Schoolbook"]=!0;e.Chaparral=!0;e["Charis SIL"]=!0;e.Cheltenham=!0;e["Cholla Slab"]=!0;e.Clarendon=!0;e.Clearface=!0;e.Cochin=!0;e.Colonna=!0;e["Computer Modern"]=!0;e["Concrete Roman"]=!0;e.Constantia=!0;e["Cooper Black"]=!0;e.Corona=!0;e.Ecotype=!0;e.Egyptienne=!0;e.Elephant=!0;e.Excelsior=!0;e.Fairfield=!0;e["FF Scala"]=!0;e.Folkard=!0;e.Footlight=!0;e.FreeSerif=!0;e["Friz Quadrata"]=!0;e.Garamond=!0;e.Gentium=!0;e.Georgia=!0;e.Gloucester=!0;e["Goudy Old Style"]=!0;e["Goudy Schoolbook"]=!0;e["Goudy Pro Font"]=!0;e.Granjon=!0;e["Guardian Egyptian"]=!0;e.Heather=!0;e.Hercules=!0;e["High Tower Text"]=!0;e.Hiroshige=!0;e["Hoefler Text"]=!0;e["Humana Serif"]=!0;e.Imprint=!0;e["Ionic No. 5"]=!0;e.Janson=!0;e.Joanna=!0;e.Korinna=!0;e.Lexicon=!0;e.LiberationSerif=!0;e["Liberation Serif"]=!0;e["Linux Libertine"]=!0;e.Literaturnaya=!0;e.Lucida=!0;e["Lucida Bright"]=!0;e.Melior=!0;e.Memphis=!0;e.Miller=!0;e.Minion=!0;e.Modern=!0;e["Mona Lisa"]=!0;e["Mrs Eaves"]=!0;e["MS Serif"]=!0;e["Museo Slab"]=!0;e["New York"]=!0;e["Nimbus Roman"]=!0;e["NPS Rawlinson Roadway"]=!0;e.NuptialScript=!0;e.Palatino=!0;e.Perpetua=!0;e.Plantin=!0;e["Plantin Schoolbook"]=!0;e.Playbill=!0;e["Poor Richard"]=!0;e["Rawlinson Roadway"]=!0;e.Renault=!0;e.Requiem=!0;e.Rockwell=!0;e.Roman=!0;e["Rotis Serif"]=!0;e.Sabon=!0;e.Scala=!0;e.Seagull=!0;e.Sistina=!0;e.Souvenir=!0;e.STIX=!0;e["Stone Informal"]=!0;e["Stone Serif"]=!0;e.Sylfaen=!0;e.Times=!0;e.Trajan=!0;e["Trinité"]=!0;e["Trump Mediaeval"]=!0;e.Utopia=!0;e["Vale Type"]=!0;e["Bitstream Vera"]=!0;e["Vera Serif"]=!0;e.Versailles=!0;e.Wanted=!0;e.Weiss=!0;e["Wide Latin"]=!0;e.Windsor=!0;e.XITS=!0})),qi=getLookupTableFactory((function(e){e.Dingbats=!0;e.Symbol=!0;e.ZapfDingbats=!0;e.Wingdings=!0;e["Wingdings-Bold"]=!0;e["Wingdings-Regular"]=!0})),Oi=getLookupTableFactory((function(e){e[2]=10;e[3]=32;e[4]=33;e[5]=34;e[6]=35;e[7]=36;e[8]=37;e[9]=38;e[10]=39;e[11]=40;e[12]=41;e[13]=42;e[14]=43;e[15]=44;e[16]=45;e[17]=46;e[18]=47;e[19]=48;e[20]=49;e[21]=50;e[22]=51;e[23]=52;e[24]=53;e[25]=54;e[26]=55;e[27]=56;e[28]=57;e[29]=58;e[30]=894;e[31]=60;e[32]=61;e[33]=62;e[34]=63;e[35]=64;e[36]=65;e[37]=66;e[38]=67;e[39]=68;e[40]=69;e[41]=70;e[42]=71;e[43]=72;e[44]=73;e[45]=74;e[46]=75;e[47]=76;e[48]=77;e[49]=78;e[50]=79;e[51]=80;e[52]=81;e[53]=82;e[54]=83;e[55]=84;e[56]=85;e[57]=86;e[58]=87;e[59]=88;e[60]=89;e[61]=90;e[62]=91;e[63]=92;e[64]=93;e[65]=94;e[66]=95;e[67]=96;e[68]=97;e[69]=98;e[70]=99;e[71]=100;e[72]=101;e[73]=102;e[74]=103;e[75]=104;e[76]=105;e[77]=106;e[78]=107;e[79]=108;e[80]=109;e[81]=110;e[82]=111;e[83]=112;e[84]=113;e[85]=114;e[86]=115;e[87]=116;e[88]=117;e[89]=118;e[90]=119;e[91]=120;e[92]=121;e[93]=122;e[94]=123;e[95]=124;e[96]=125;e[97]=126;e[98]=196;e[99]=197;e[100]=199;e[101]=201;e[102]=209;e[103]=214;e[104]=220;e[105]=225;e[106]=224;e[107]=226;e[108]=228;e[109]=227;e[110]=229;e[111]=231;e[112]=233;e[113]=232;e[114]=234;e[115]=235;e[116]=237;e[117]=236;e[118]=238;e[119]=239;e[120]=241;e[121]=243;e[122]=242;e[123]=244;e[124]=246;e[125]=245;e[126]=250;e[127]=249;e[128]=251;e[129]=252;e[130]=8224;e[131]=176;e[132]=162;e[133]=163;e[134]=167;e[135]=8226;e[136]=182;e[137]=223;e[138]=174;e[139]=169;e[140]=8482;e[141]=180;e[142]=168;e[143]=8800;e[144]=198;e[145]=216;e[146]=8734;e[147]=177;e[148]=8804;e[149]=8805;e[150]=165;e[151]=181;e[152]=8706;e[153]=8721;e[154]=8719;e[156]=8747;e[157]=170;e[158]=186;e[159]=8486;e[160]=230;e[161]=248;e[162]=191;e[163]=161;e[164]=172;e[165]=8730;e[166]=402;e[167]=8776;e[168]=8710;e[169]=171;e[170]=187;e[171]=8230;e[179]=8220;e[180]=8221;e[181]=8216;e[182]=8217;e[200]=193;e[203]=205;e[207]=211;e[210]=218;e[223]=711;e[224]=321;e[225]=322;e[226]=352;e[227]=353;e[228]=381;e[229]=382;e[233]=221;e[234]=253;e[252]=263;e[253]=268;e[254]=269;e[258]=258;e[260]=260;e[261]=261;e[265]=280;e[266]=281;e[267]=282;e[268]=283;e[269]=313;e[275]=323;e[276]=324;e[278]=328;e[283]=344;e[284]=345;e[285]=346;e[286]=347;e[292]=367;e[295]=377;e[296]=378;e[298]=380;e[305]=963;e[306]=964;e[307]=966;e[308]=8215;e[309]=8252;e[310]=8319;e[311]=8359;e[312]=8592;e[313]=8593;e[337]=9552;e[493]=1039;e[494]=1040;e[672]=1488;e[673]=1489;e[674]=1490;e[675]=1491;e[676]=1492;e[677]=1493;e[678]=1494;e[679]=1495;e[680]=1496;e[681]=1497;e[682]=1498;e[683]=1499;e[684]=1500;e[685]=1501;e[686]=1502;e[687]=1503;e[688]=1504;e[689]=1505;e[690]=1506;e[691]=1507;e[692]=1508;e[693]=1509;e[694]=1510;e[695]=1511;e[696]=1512;e[697]=1513;e[698]=1514;e[705]=1524;e[706]=8362;e[710]=64288;e[711]=64298;e[759]=1617;e[761]=1776;e[763]=1778;e[775]=1652;e[777]=1764;e[778]=1780;e[779]=1781;e[780]=1782;e[782]=771;e[783]=64726;e[786]=8363;e[788]=8532;e[790]=768;e[791]=769;e[792]=768;e[795]=803;e[797]=64336;e[798]=64337;e[799]=64342;e[800]=64343;e[801]=64344;e[802]=64345;e[803]=64362;e[804]=64363;e[805]=64364;e[2424]=7821;e[2425]=7822;e[2426]=7823;e[2427]=7824;e[2428]=7825;e[2429]=7826;e[2430]=7827;e[2433]=7682;e[2678]=8045;e[2679]=8046;e[2830]=1552;e[2838]=686;e[2840]=751;e[2842]=753;e[2843]=754;e[2844]=755;e[2846]=757;e[2856]=767;e[2857]=848;e[2858]=849;e[2862]=853;e[2863]=854;e[2864]=855;e[2865]=861;e[2866]=862;e[2906]=7460;e[2908]=7462;e[2909]=7463;e[2910]=7464;e[2912]=7466;e[2913]=7467;e[2914]=7468;e[2916]=7470;e[2917]=7471;e[2918]=7472;e[2920]=7474;e[2921]=7475;e[2922]=7476;e[2924]=7478;e[2925]=7479;e[2926]=7480;e[2928]=7482;e[2929]=7483;e[2930]=7484;e[2932]=7486;e[2933]=7487;e[2934]=7488;e[2936]=7490;e[2937]=7491;e[2938]=7492;e[2940]=7494;e[2941]=7495;e[2942]=7496;e[2944]=7498;e[2946]=7500;e[2948]=7502;e[2950]=7504;e[2951]=7505;e[2952]=7506;e[2954]=7508;e[2955]=7509;e[2956]=7510;e[2958]=7512;e[2959]=7513;e[2960]=7514;e[2962]=7516;e[2963]=7517;e[2964]=7518;e[2966]=7520;e[2967]=7521;e[2968]=7522;e[2970]=7524;e[2971]=7525;e[2972]=7526;e[2974]=7528;e[2975]=7529;e[2976]=7530;e[2978]=1537;e[2979]=1538;e[2980]=1539;e[2982]=1549;e[2983]=1551;e[2984]=1552;e[2986]=1554;e[2987]=1555;e[2988]=1556;e[2990]=1623;e[2991]=1624;e[2995]=1775;e[2999]=1791;e[3002]=64290;e[3003]=64291;e[3004]=64292;e[3006]=64294;e[3007]=64295;e[3008]=64296;e[3011]=1900;e[3014]=8223;e[3015]=8244;e[3017]=7532;e[3018]=7533;e[3019]=7534;e[3075]=7590;e[3076]=7591;e[3079]=7594;e[3080]=7595;e[3083]=7598;e[3084]=7599;e[3087]=7602;e[3088]=7603;e[3091]=7606;e[3092]=7607;e[3095]=7610;e[3096]=7611;e[3099]=7614;e[3100]=7615;e[3103]=7618;e[3104]=7619;e[3107]=8337;e[3108]=8338;e[3116]=1884;e[3119]=1885;e[3120]=1885;e[3123]=1886;e[3124]=1886;e[3127]=1887;e[3128]=1887;e[3131]=1888;e[3132]=1888;e[3135]=1889;e[3136]=1889;e[3139]=1890;e[3140]=1890;e[3143]=1891;e[3144]=1891;e[3147]=1892;e[3148]=1892;e[3153]=580;e[3154]=581;e[3157]=584;e[3158]=585;e[3161]=588;e[3162]=589;e[3165]=891;e[3166]=892;e[3169]=1274;e[3170]=1275;e[3173]=1278;e[3174]=1279;e[3181]=7622;e[3182]=7623;e[3282]=11799;e[3316]=578;e[3379]=42785;e[3393]=1159;e[3416]=8377})),Pi=getLookupTableFactory((function(e){e[227]=322;e[264]=261;e[291]=346})),Wi=getLookupTableFactory((function(e){e[1]=32;e[4]=65;e[5]=192;e[6]=193;e[9]=196;e[17]=66;e[18]=67;e[21]=268;e[24]=68;e[28]=69;e[29]=200;e[30]=201;e[32]=282;e[38]=70;e[39]=71;e[44]=72;e[47]=73;e[48]=204;e[49]=205;e[58]=74;e[60]=75;e[62]=76;e[68]=77;e[69]=78;e[75]=79;e[76]=210;e[80]=214;e[87]=80;e[89]=81;e[90]=82;e[92]=344;e[94]=83;e[97]=352;e[100]=84;e[104]=85;e[109]=220;e[115]=86;e[116]=87;e[121]=88;e[122]=89;e[124]=221;e[127]=90;e[129]=381;e[258]=97;e[259]=224;e[260]=225;e[263]=228;e[268]=261;e[271]=98;e[272]=99;e[273]=263;e[275]=269;e[282]=100;e[286]=101;e[287]=232;e[288]=233;e[290]=283;e[295]=281;e[296]=102;e[336]=103;e[346]=104;e[349]=105;e[350]=236;e[351]=237;e[361]=106;e[364]=107;e[367]=108;e[371]=322;e[373]=109;e[374]=110;e[381]=111;e[382]=242;e[383]=243;e[386]=246;e[393]=112;e[395]=113;e[396]=114;e[398]=345;e[400]=115;e[401]=347;e[403]=353;e[410]=116;e[437]=117;e[442]=252;e[448]=118;e[449]=119;e[454]=120;e[455]=121;e[457]=253;e[460]=122;e[462]=382;e[463]=380;e[853]=44;e[855]=58;e[856]=46;e[876]=47;e[878]=45;e[882]=45;e[894]=40;e[895]=41;e[896]=91;e[897]=93;e[923]=64;e[1004]=48;e[1005]=49;e[1006]=50;e[1007]=51;e[1008]=52;e[1009]=53;e[1010]=54;e[1011]=55;e[1012]=56;e[1013]=57;e[1081]=37;e[1085]=43;e[1086]=45}));function getStandardFontName(e){const t=normalizeFontName(e);return Yi()[t]}function isKnownFontName(e){const t=normalizeFontName(e);return!!(Yi()[t]||Ki()[t]||Ti()[t]||qi()[t])}class ToUnicodeMap{constructor(e=[]){this._map=e}get length(){return this._map.length}forEach(e){for(const t in this._map)e(t,this._map[t].codePointAt(0))}has(e){return void 0!==this._map[e]}get(e){return this._map[e]}charCodeOf(e){const t=this._map;if(t.length<=65536)return t.indexOf(e);for(const i in t)if(t[i]===e)return 0|i;return-1}amend(e){for(const t in e)this._map[t]=e[t]}}class IdentityToUnicodeMap{constructor(e,t){this.firstChar=e;this.lastChar=t}get length(){return this.lastChar+1-this.firstChar}forEach(e){for(let t=this.firstChar,i=this.lastChar;t<=i;t++)e(t,t)}has(e){return this.firstChar<=e&&e<=this.lastChar}get(e){if(this.firstChar<=e&&e<=this.lastChar)return String.fromCharCode(e)}charCodeOf(e){return Number.isInteger(e)&&e>=this.firstChar&&e<=this.lastChar?e:-1}amend(e){unreachable("Should not call amend()")}}class CFFFont{constructor(e,t){this.properties=t;const i=new CFFParser(e,t,Ri);this.cff=i.parse();this.cff.duplicateFirstGlyph();const a=new CFFCompiler(this.cff);this.seacs=this.cff.seacs;try{this.data=a.compile()}catch{warn("Failed to compile font "+t.loadedName);this.data=e}this._createBuiltInEncoding()}get numGlyphs(){return this.cff.charStrings.count}getCharset(){return this.cff.charset.charset}getGlyphMapping(){const e=this.cff,t=this.properties,{cidToGidMap:i,cMap:a}=t,s=e.charset.charset;let r,n;if(t.composite){let t,g;if(i?.length>0){t=Object.create(null);for(let e=0,a=i.length;e=0){const a=i[t];a&&(s[e]=a)}}s.length>0&&(this.properties.builtInEncoding=s)}}function getUint32(e,t){return(e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3])>>>0}function getUint16(e,t){return e[t]<<8|e[t+1]}function getInt16(e,t){return(e[t]<<24|e[t+1]<<16)>>16}function getInt8(e,t){return e[t]<<24>>24}function getFloat214(e,t){return getInt16(e,t)/16384}function getSubroutineBias(e){const t=e.length;let i=32768;t<1240?i=107:t<33900&&(i=1131);return i}function parseCmap(e,t,i){const a=1===getUint16(e,t+2)?getUint32(e,t+8):getUint32(e,t+16),s=getUint16(e,t+a);let r,n,g;if(4===s){getUint16(e,t+a+2);const i=getUint16(e,t+a+6)>>1;n=t+a+14;r=[];for(g=0;g>1;i0;)C.push({flags:r})}for(i=0;i>1;p=!0;break;case 4:n+=s.pop();moveTo(r,n);p=!0;break;case 5:for(;s.length>0;){r+=s.shift();n+=s.shift();lineTo(r,n)}break;case 6:for(;s.length>0;){r+=s.shift();lineTo(r,n);if(0===s.length)break;n+=s.shift();lineTo(r,n)}break;case 7:for(;s.length>0;){n+=s.shift();lineTo(r,n);if(0===s.length)break;r+=s.shift();lineTo(r,n)}break;case 8:for(;s.length>0;){c=r+s.shift();h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+s.shift();bezierCurveTo(c,h,C,l,r,n)}break;case 10:d=s.pop();f=null;if(i.isCFFCIDFont){const e=i.fdSelect.getFDIndex(a);if(e>=0&&eMath.abs(n-t)?r+=s.shift():n+=s.shift();bezierCurveTo(c,h,C,l,r,n);break;default:throw new FormatError(`unknown operator: 12 ${m}`)}break;case 14:if(s.length>=4){const e=s.pop(),a=s.pop();n=s.pop();r=s.pop();t.save();t.translate(r,n);let g=lookupCmap(i.cmap,String.fromCharCode(i.glyphNameMap[hi[e]]));compileCharString(i.glyphs[g.glyphId],t,i,g.glyphId);t.restore();g=lookupCmap(i.cmap,String.fromCharCode(i.glyphNameMap[hi[a]]));compileCharString(i.glyphs[g.glyphId],t,i,g.glyphId)}return;case 19:case 20:g+=s.length>>1;o+=g+7>>3;p=!0;break;case 21:n+=s.pop();r+=s.pop();moveTo(r,n);p=!0;break;case 22:r+=s.pop();moveTo(r,n);p=!0;break;case 24:for(;s.length>2;){c=r+s.shift();h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+s.shift();bezierCurveTo(c,h,C,l,r,n)}r+=s.shift();n+=s.shift();lineTo(r,n);break;case 25:for(;s.length>6;){r+=s.shift();n+=s.shift();lineTo(r,n)}c=r+s.shift();h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+s.shift();bezierCurveTo(c,h,C,l,r,n);break;case 26:s.length%2&&(r+=s.shift());for(;s.length>0;){c=r;h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C;n=l+s.shift();bezierCurveTo(c,h,C,l,r,n)}break;case 27:s.length%2&&(n+=s.shift());for(;s.length>0;){c=r+s.shift();h=n;C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l;bezierCurveTo(c,h,C,l,r,n)}break;case 28:s.push((e[o]<<24|e[o+1]<<16)>>16);o+=2;break;case 29:d=s.pop()+i.gsubrsBias;f=i.gsubrs[d];f&&parse(f);break;case 30:for(;s.length>0;){c=r;h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n);if(0===s.length)break;c=r+s.shift();h=n;C=c+s.shift();l=h+s.shift();n=l+s.shift();r=C+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n)}break;case 31:for(;s.length>0;){c=r+s.shift();h=n;C=c+s.shift();l=h+s.shift();n=l+s.shift();r=C+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n);if(0===s.length)break;c=r;h=n+s.shift();C=c+s.shift();l=h+s.shift();r=C+s.shift();n=l+(1===s.length?s.shift():0);bezierCurveTo(c,h,C,l,r,n)}break;default:if(m<32)throw new FormatError(`unknown operator: ${m}`);if(m<247)s.push(m-139);else if(m<251)s.push(256*(m-247)+e[o++]+108);else if(m<255)s.push(256*-(m-251)-e[o++]-108);else{s.push((e[o]<<24|e[o+1]<<16|e[o+2]<<8|e[o+3])/65536);o+=4}}p&&(s.length=0)}}(e)}class Commands{cmds=[];transformStack=[];currentTransform=[1,0,0,1,0,0];add(e,t){if(t){const[i,a,s,r,n,g]=this.currentTransform;for(let e=0,o=t.length;e=0&&e2*getUint16(e,t)}const r=[];let n=s(t,0);for(let i=a;ie+(t.getSize()+3&-4)),0)}write(){const e=this.getSize(),t=new DataView(new ArrayBuffer(e)),i=e>131070,a=i?4:2,s=new DataView(new ArrayBuffer((this.glyphs.length+1)*a));i?s.setUint32(0,0):s.setUint16(0,0);let r=0,n=0;for(const e of this.glyphs){r+=e.write(r,t);r=r+3&-4;n+=a;i?s.setUint32(n,r):s.setUint16(n,r>>1)}return{isLocationLong:i,loca:new Uint8Array(s.buffer),glyf:new Uint8Array(t.buffer)}}scale(e){for(let t=0,i=this.glyphs.length;te+t.getSize()),0);return this.header.getSize()+e}write(e,t){if(!this.header)return 0;const i=e;e+=this.header.write(e,t);if(this.simple)e+=this.simple.write(e,t);else for(const i of this.composites)e+=i.write(e,t);return e-i}scale(e){if(!this.header)return;const t=(this.header.xMin+this.header.xMax)/2;this.header.scale(t,e);if(this.simple)this.simple.scale(t,e);else for(const i of this.composites)i.scale(t,e)}}class GlyphHeader{constructor({numberOfContours:e,xMin:t,yMin:i,xMax:a,yMax:s}){this.numberOfContours=e;this.xMin=t;this.yMin=i;this.xMax=a;this.yMax=s}static parse(e,t){return[10,new GlyphHeader({numberOfContours:t.getInt16(e),xMin:t.getInt16(e+2),yMin:t.getInt16(e+4),xMax:t.getInt16(e+6),yMax:t.getInt16(e+8)})]}getSize(){return 10}write(e,t){t.setInt16(e,this.numberOfContours);t.setInt16(e+2,this.xMin);t.setInt16(e+4,this.yMin);t.setInt16(e+6,this.xMax);t.setInt16(e+8,this.yMax);return 10}scale(e,t){this.xMin=Math.round(e+(this.xMin-e)*t);this.xMax=Math.round(e+(this.xMax-e)*t)}}class Contour{constructor({flags:e,xCoordinates:t,yCoordinates:i}){this.xCoordinates=t;this.yCoordinates=i;this.flags=e}}class SimpleGlyph{constructor({contours:e,instructions:t}){this.contours=e;this.instructions=t}static parse(e,t,i){const a=[];for(let s=0;s255?e+=2:g>0&&(e+=1);t=r;g=Math.abs(n-i);g>255?e+=2:g>0&&(e+=1);i=n}}return e}write(e,t){const i=e,a=[],s=[],r=[];let n=0,g=0;for(const i of this.contours){for(let e=0,t=i.xCoordinates.length;e=0?18:2;a.push(e)}else a.push(c)}n=o;const C=i.yCoordinates[e];c=C-g;if(0===c){t|=32;s.push(0)}else{const e=Math.abs(c);if(e<=255){t|=c>=0?36:4;s.push(e)}else s.push(c)}g=C;r.push(t)}t.setUint16(e,a.length-1);e+=2}t.setUint16(e,this.instructions.length);e+=2;if(this.instructions.length){new Uint8Array(t.buffer,0,t.buffer.byteLength).set(this.instructions,e);e+=this.instructions.length}for(const i of r)t.setUint8(e++,i);for(let i=0,s=a.length;i=-128&&this.argument1<=127&&this.argument2>=-128&&this.argument2<=127||(e+=2):this.argument1>=0&&this.argument1<=255&&this.argument2>=0&&this.argument2<=255||(e+=2);return e}write(e,t){const i=e;2&this.flags?this.argument1>=-128&&this.argument1<=127&&this.argument2>=-128&&this.argument2<=127||(this.flags|=1):this.argument1>=0&&this.argument1<=255&&this.argument2>=0&&this.argument2<=255||(this.flags|=1);t.setUint16(e,this.flags);t.setUint16(e+2,this.glyphIndex);e+=4;if(1&this.flags){if(2&this.flags){t.setInt16(e,this.argument1);t.setInt16(e+2,this.argument2)}else{t.setUint16(e,this.argument1);t.setUint16(e+2,this.argument2)}e+=4}else{t.setUint8(e,this.argument1);t.setUint8(e+1,this.argument2);e+=2}if(256&this.flags){t.setUint16(e,this.instructions.length);e+=2;if(this.instructions.length){new Uint8Array(t.buffer,0,t.buffer.byteLength).set(this.instructions,e);e+=this.instructions.length}}return e-i}scale(e,t){}}function writeInt16(e,t,i){e[t]=i>>8&255;e[t+1]=255&i}function writeInt32(e,t,i){e[t]=i>>24&255;e[t+1]=i>>16&255;e[t+2]=i>>8&255;e[t+3]=255&i}function writeData(e,t,i){if(i instanceof Uint8Array)e.set(i,t);else if("string"==typeof i)for(let a=0,s=i.length;ai;){i<<=1;a++}const s=i*t;return{range:s,entry:a,rangeShift:t*e-s}}toArray(){let e=this.sfnt;const t=this.tables,i=Object.keys(t);i.sort();const a=i.length;let s,r,n,g,o,c=12+16*a;const C=[c];for(s=0;s>>0;C.push(c)}const h=new Uint8Array(c);for(s=0;s>>0}writeInt32(h,c+4,e);writeInt32(h,c+8,C[s]);writeInt32(h,c+12,t[o].length);c+=16}return h}addTable(e,t){if(e in this.tables)throw new Error("Table "+e+" already exists");this.tables[e]=t}}const Zi=[4],Vi=[5],zi=[6],_i=[7],$i=[8],Aa=[12,35],ea=[14],ta=[21],ia=[22],aa=[30],sa=[31];class Type1CharString{constructor(){this.width=0;this.lsb=0;this.flexing=!1;this.output=[];this.stack=[]}convert(e,t,i){const a=e.length;let s,r,n,g=!1;for(let o=0;oa)return!0;const s=a-e;for(let e=s;e>8&255,255&t);else{t=65536*t|0;this.output.push(255,t>>24&255,t>>16&255,t>>8&255,255&t)}}this.output.push(...t);i?this.stack.splice(s,e):this.stack.length=0;return!1}}function isHexDigit(e){return e>=48&&e<=57||e>=65&&e<=70||e>=97&&e<=102}function decrypt(e,t,i){if(i>=e.length)return new Uint8Array(0);let a,s,r=0|t;for(a=0;a>8;r=52845*(t+r)+22719&65535}return g}function isSpecial(e){return 47===e||91===e||93===e||123===e||125===e||40===e||41===e}class Type1Parser{constructor(e,t,i){if(t){const t=e.getBytes(),i=!((isHexDigit(t[0])||isWhiteSpace(t[0]))&&isHexDigit(t[1])&&isHexDigit(t[2])&&isHexDigit(t[3])&&isHexDigit(t[4])&&isHexDigit(t[5])&&isHexDigit(t[6])&&isHexDigit(t[7]));e=new Stream(i?decrypt(t,55665,4):function decryptAscii(e,t,i){let a=0|t;const s=e.length,r=new Uint8Array(s>>>1);let n,g;for(n=0,g=0;n>8;a=52845*(e+a)+22719&65535}}return r.slice(i,g)}(t,55665,4))}this.seacAnalysisEnabled=!!i;this.stream=e;this.nextChar()}readNumberArray(){this.getToken();const e=[];for(;;){const t=this.getToken();if(null===t||"]"===t||"}"===t)break;e.push(parseFloat(t||0))}return e}readNumber(){const e=this.getToken();return parseFloat(e||0)}readInt(){const e=this.getToken();return 0|parseInt(e||0,10)}readBoolean(){return"true"===this.getToken()?1:0}nextChar(){return this.currentChar=this.stream.getByte()}prevChar(){this.stream.skip(-2);return this.currentChar=this.stream.getByte()}getToken(){let e=!1,t=this.currentChar;for(;;){if(-1===t)return null;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(!isWhiteSpace(t))break;t=this.nextChar()}if(isSpecial(t)){this.nextChar();return String.fromCharCode(t)}let i="";do{i+=String.fromCharCode(t);t=this.nextChar()}while(t>=0&&!isWhiteSpace(t)&&!isSpecial(t));return i}readCharStrings(e,t){return-1===t?e:decrypt(e,4330,t)}extractFontProgram(e){const t=this.stream,i=[],a=[],s=Object.create(null);s.lenIV=4;const r={subrs:[],charstrings:[],properties:{privateData:s}};let n,g,o,c;for(;null!==(n=this.getToken());)if("/"===n){n=this.getToken();switch(n){case"CharStrings":this.getToken();this.getToken();this.getToken();this.getToken();for(;;){n=this.getToken();if(null===n||"end"===n)break;if("/"!==n)continue;const e=this.getToken();g=this.readInt();this.getToken();o=g>0?t.getBytes(g):new Uint8Array(0);c=r.properties.privateData.lenIV;const i=this.readCharStrings(o,c);this.nextChar();n=this.getToken();"noaccess"===n?this.getToken():"/"===n&&this.prevChar();a.push({glyph:e,encoded:i})}break;case"Subrs":this.readInt();this.getToken();for(;"dup"===this.getToken();){const e=this.readInt();g=this.readInt();this.getToken();o=g>0?t.getBytes(g):new Uint8Array(0);c=r.properties.privateData.lenIV;const a=this.readCharStrings(o,c);this.nextChar();n=this.getToken();"noaccess"===n&&this.getToken();i[e]=a}break;case"BlueValues":case"OtherBlues":case"FamilyBlues":case"FamilyOtherBlues":const e=this.readNumberArray();e.length>0&&e.length,0;break;case"StemSnapH":case"StemSnapV":r.properties.privateData[n]=this.readNumberArray();break;case"StdHW":case"StdVW":r.properties.privateData[n]=this.readNumberArray()[0];break;case"BlueShift":case"lenIV":case"BlueFuzz":case"BlueScale":case"LanguageGroup":r.properties.privateData[n]=this.readNumber();break;case"ExpansionFactor":r.properties.privateData[n]=this.readNumber()||.06;break;case"ForceBold":r.properties.privateData[n]=this.readBoolean()}}for(const{encoded:t,glyph:s}of a){const a=new Type1CharString,n=a.convert(t,i,this.seacAnalysisEnabled);let g=a.output;n&&(g=[14]);const o={glyphName:s,charstring:g,width:a.width,lsb:a.lsb,seac:a.seac};".notdef"===s?r.charstrings.unshift(o):r.charstrings.push(o);if(e.builtInEncoding){const t=e.builtInEncoding.indexOf(s);t>-1&&void 0===e.widths[t]&&t>=e.firstChar&&t<=e.lastChar&&(e.widths[t]=a.width)}}return r}extractFontHeader(e){let t;for(;null!==(t=this.getToken());)if("/"===t){t=this.getToken();switch(t){case"FontMatrix":const i=this.readNumberArray();e.fontMatrix=i;break;case"Encoding":const a=this.getToken();let s;if(/^\d+$/.test(a)){s=[];const e=0|parseInt(a,10);this.getToken();for(let i=0;i=s){n+=i;for(;n=0&&(a[e]=s)}}return type1FontGlyphMapping(e,a,i)}hasGlyphId(e){if(e<0||e>=this.numGlyphs)return!1;if(0===e)return!0;return this.charstrings[e-1].charstring.length>0}getSeacs(e){const t=[];for(let i=0,a=e.length;i0;e--)t[e]-=t[e-1];Q.setByName(e,t)}r.topDict.privateDict=Q;const u=new CFFIndex;for(C=0,h=a.length;C0&&e.toUnicode.amend(t)}class fonts_Glyph{constructor(e,t,i,a,s,r,n,g,o){this.originalCharCode=e;this.fontChar=t;this.unicode=i;this.accent=a;this.width=s;this.vmetric=r;this.operatorListId=n;this.isSpace=g;this.isInFont=o}get category(){return shadow(this,"category",function getCharUnicodeCategory(e){const t=ki.get(e);if(t)return t;const i=e.match(Si),a={isWhitespace:!!i?.[1],isZeroWidthDiacritic:!!i?.[2],isInvisibleFormatMark:!!i?.[3]};ki.set(e,a);return a}(this.unicode),!0)}}function int16(e,t){return(e<<8)+t}function writeSignedInt16(e,t,i){e[t+1]=i;e[t]=i>>>8}function signedInt16(e,t){const i=(e<<8)+t;return 32768&i?i-65536:i}function string16(e){return String.fromCharCode(e>>8&255,255&e)}function safeString16(e){e>32767?e=32767:e<-32768&&(e=-32768);return String.fromCharCode(e>>8&255,255&e)}function isTrueTypeCollectionFile(e){return"ttcf"===bytesToString(e.peekBytes(4))}function getFontFileType(e,{type:t,subtype:i,composite:a}){let s,r;if(function isTrueTypeFile(e){const t=e.peekBytes(4);return 65536===readUint32(t,0)||"true"===bytesToString(t)}(e)||isTrueTypeCollectionFile(e))s=a?"CIDFontType2":"TrueType";else if(function isOpenTypeFile(e){return"OTTO"===bytesToString(e.peekBytes(4))}(e))s=a?"CIDFontType2":"OpenType";else if(function isType1File(e){const t=e.peekBytes(2);return 37===t[0]&&33===t[1]||128===t[0]&&1===t[1]}(e))s=a?"CIDFontType0":"MMType1"===t?"MMType1":"Type1";else if(function isCFFFile(e){const t=e.peekBytes(4);return t[0]>=1&&t[3]>=1&&t[3]<=4}(e))if(a){s="CIDFontType0";r="CIDFontType0C"}else{s="MMType1"===t?"MMType1":"Type1";r="Type1C"}else{warn("getFontFileType: Unable to detect correct font file Type/Subtype.");s=t;r=i}return[s,r]}function applyStandardFontGlyphMap(e,t){for(const i in t)e[+i]=t[i]}function buildToFontChar(e,t,i){const a=[];let s;for(let i=0,r=e.length;iC){o++;if(o>=ra.length){warn("Ran out of space in font private use area.");break}c=ra[o][0];C=ra[o][1]}const E=c++;0===Q&&(Q=i);let u=a.get(l);"string"==typeof u&&(u=u.codePointAt(0));if(u&&!(h=u,ra[0][0]<=h&&h<=ra[0][1]||ra[1][0]<=h&&h<=ra[1][1])&&!g.has(Q)){r.set(u,Q);g.add(Q)}s[E]=Q;n[l]=E}var h;return{toFontChar:n,charCodeToGlyphId:s,toUnicodeExtraMap:r,nextAvailableFontCharCode:c}}function createCmapTable(e,t,i){const a=function getRanges(e,t,i){const a=[];for(const t in e)e[t]>=i||a.push({fontCharCode:0|t,glyphId:e[t]});if(t)for(const[e,s]of t)s>=i||a.push({fontCharCode:e,glyphId:s});0===a.length&&a.push({fontCharCode:0,glyphId:0});a.sort((function fontGetRangesSort(e,t){return e.fontCharCode-t.fontCharCode}));const s=[],r=a.length;for(let e=0;e65535?2:1;let r,n,g,o,c="\0\0"+string16(s)+"\0\0"+string32(4+8*s);for(r=a.length-1;r>=0&&!(a[r][0]<=65535);--r);const C=r+1;a[r][0]<65535&&65535===a[r][1]&&(a[r][1]=65534);const h=a[r][1]<65535?1:0,l=C+h,Q=OpenTypeFileBuilder.getSearchParams(l,2);let E,u,d,f,p="",m="",y="",w="",D="",b=0;for(r=0,n=C;r0){m+="ÿÿ";p+="ÿÿ";y+="\0";w+="\0\0"}const F="\0\0"+string16(2*l)+string16(Q.range)+string16(Q.entry)+string16(Q.rangeShift)+m+"\0\0"+p+y+w+D;let S="",k="";if(s>1){c+="\0\0\n"+string32(4+8*s+4+F.length);S="";for(r=0,n=a.length;re||!g)&&(g=e);o 123 are reserved for internal usage");n|=1<65535&&(o=65535)}else{g=0;o=255}const C=e.bbox||[0,0,0,0],h=i.unitsPerEm||(e.fontMatrix?1/Math.max(...e.fontMatrix.slice(0,4).map(Math.abs)):1e3),l=e.ascentScaled?1:h/na,Q=i.ascent||Math.round(l*(e.ascent||C[3]));let E=i.descent||Math.round(l*(e.descent||C[1]));E>0&&e.descent>0&&C[1]<0&&(E=-E);const u=i.yMax||Q,d=-i.yMin||-E;return"\0$ô\0\0\0Š»\0\0\0ŒŠ»\0\0ß\x001\0\0\0\0"+String.fromCharCode(e.fixedPitch?9:0)+"\0\0\0\0\0\0"+string32(a)+string32(s)+string32(r)+string32(n)+"*21*"+string16(e.italicAngle?1:0)+string16(g||e.firstChar)+string16(o||e.lastChar)+string16(Q)+string16(E)+"\0d"+string16(u)+string16(d)+"\0\0\0\0\0\0\0\0"+string16(e.xHeight)+string16(e.capHeight)+string16(0)+string16(g||e.firstChar)+"\0"}function createPostTable(e){return"\0\0\0"+string32(Math.floor(65536*e.italicAngle))+"\0\0\0\0"+string32(e.fixedPitch?1:0)+"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}function createPostscriptName(e){return e.replaceAll(/[^\x21-\x7E]|[[\](){}<>/%]/g,"").slice(0,63)}function createNameTable(e,t){t||(t=[[],[]]);const i=[t[0][0]||"Original licence",t[0][1]||e,t[0][2]||"Unknown",t[0][3]||"uniqueID",t[0][4]||e,t[0][5]||"Version 0.11",t[0][6]||createPostscriptName(e),t[0][7]||"Unknown",t[0][8]||"Unknown",t[0][9]||"Unknown"],a=[];let s,r,n,g,o;for(s=0,r=i.length;s0;if((n||g)&&"CIDFontType2"===i&&this.cidEncoding.startsWith("Identity-")){const i=e.cidToGidMap,a=[];applyStandardFontGlyphMap(a,Oi());/Arial-?Black/i.test(t)?applyStandardFontGlyphMap(a,Pi()):/Calibri/i.test(t)&&applyStandardFontGlyphMap(a,Wi());if(i){for(const e in a){const t=a[e];void 0!==i[t]&&(a[+e]=i[t])}i.length!==this.toUnicode.length&&e.hasIncludedToUnicodeMap&&this.toUnicode instanceof IdentityToUnicodeMap&&this.toUnicode.forEach((function(e,t){const s=a[e];void 0===i[s]&&(a[+e]=t)}))}this.toUnicode instanceof IdentityToUnicodeMap||this.toUnicode.forEach((function(e,t){a[+e]=t}));this.toFontChar=a;this.toUnicode=new ToUnicodeMap(a)}else if(/Symbol/i.test(a))this.toFontChar=buildToFontChar(Bi,wi(),this.differences);else if(/Dingbats/i.test(a))this.toFontChar=buildToFontChar(Qi,Di(),this.differences);else if(n||g){const e=buildToFontChar(this.defaultEncoding,wi(),this.differences);"CIDFontType2"!==i||this.cidEncoding.startsWith("Identity-")||this.toUnicode instanceof IdentityToUnicodeMap||this.toUnicode.forEach((function(t,i){e[+t]=i}));this.toFontChar=e}else{const e=wi(),i=[];this.toUnicode.forEach(((t,a)=>{if(!this.composite){const i=getUnicodeForGlyph(this.differences[t]||this.defaultEncoding[t],e);-1!==i&&(a=i)}i[+t]=a}));this.composite&&this.toUnicode instanceof IdentityToUnicodeMap&&/Tahoma|Verdana/i.test(t)&&applyStandardFontGlyphMap(i,Oi());this.toFontChar=i}amendFallbackToUnicode(e);this.loadedName=a.split("-",1)[0]}checkAndRepair(e,t,i){const a=["OS/2","cmap","head","hhea","hmtx","maxp","name","post","loca","glyf","fpgm","prep","cvt ","CFF "];function readTables(e,t){const i=Object.create(null);i["OS/2"]=null;i.cmap=null;i.head=null;i.hhea=null;i.hmtx=null;i.maxp=null;i.name=null;i.post=null;for(let s=0;s>>0,a=e.getInt32()>>>0,s=e.getInt32()>>>0,r=e.pos;e.pos=e.start||0;e.skip(a);const n=e.getBytes(s);e.pos=r;if("head"===t){n[8]=n[9]=n[10]=n[11]=0;n[17]|=32}return{tag:t,checksum:i,length:s,offset:a,data:n}}function readOpenTypeHeader(e){return{version:e.getString(4),numTables:e.getUint16(),searchRange:e.getUint16(),entrySelector:e.getUint16(),rangeShift:e.getUint16()}}function sanitizeGlyph(e,t,i,a,s,r){const n={length:0,sizeOfInstructions:0};if(t<0||t>=e.length||i>e.length||i-t<=12)return n;const g=e.subarray(t,i),o=signedInt16(g[2],g[3]),c=signedInt16(g[4],g[5]),C=signedInt16(g[6],g[7]),h=signedInt16(g[8],g[9]);if(o>C){writeSignedInt16(g,2,C);writeSignedInt16(g,6,o)}if(c>h){writeSignedInt16(g,4,h);writeSignedInt16(g,8,c)}const l=signedInt16(g[0],g[1]);if(l<0){if(l<-1)return n;a.set(g,s);n.length=g.length;return n}let Q,E=10,u=0;for(Q=0;Qg.length)return n;if(!r&&f>0){a.set(g.subarray(0,d),s);a.set([0,0],s+d);a.set(g.subarray(p,y),s+d+2);y-=f;g.length-y>3&&(y=y+3&-4);n.length=y;return n}if(g.length-y>3){y=y+3&-4;a.set(g.subarray(0,y),s);n.length=y;return n}a.set(g,s);n.length=g.length;return n}function readNameTable(e){const i=(t.start||0)+e.offset;t.pos=i;const a=[[],[]],s=[],r=e.length,n=i+r;if(0!==t.getUint16()||r<6)return[a,s];const g=t.getUint16(),o=t.getUint16();let c,C;for(c=0;cn)continue;t.pos=r;const g=e.name;if(e.encoding){let i="";for(let a=0,s=e.length;a0&&(c+=e-1)}}else{if(d||p){warn("TT: nested FDEFs not allowed");u=!0}d=!0;h=c;n=l.pop();t.functionsDefined[n]={data:o,i:c}}else if(!d&&!p){n=l.at(-1);if(isNaN(n))info("TT: CALL empty stack (or invalid entry).");else{t.functionsUsed[n]=!0;if(n in t.functionsStackDeltas){const e=l.length+t.functionsStackDeltas[n];if(e<0){warn("TT: CALL invalid functions stack delta.");t.hintsValid=!1;return}l.length=e}else if(n in t.functionsDefined&&!E.includes(n)){Q.push({data:o,i:c,stackTop:l.length-1});E.push(n);g=t.functionsDefined[n];if(!g){warn("TT: CALL non-existent function");t.hintsValid=!1;return}o=g.data;c=g.i}}}if(!d&&!p){let t=0;e<=142?t=s[e]:e>=192&&e<=223?t=-1:e>=224&&(t=-2);if(e>=113&&e<=117){a=l.pop();isNaN(a)||(t=2*-a)}for(;t<0&&l.length>0;){l.pop();t++}for(;t>0;){l.push(NaN);t--}}}t.tooComplexToFollowFunctions=u;const m=[o];c>o.length&&m.push(new Uint8Array(c-o.length));if(h>C){warn("TT: complementing a missing function tail");m.push(new Uint8Array([34,45]))}!function foldTTTable(e,t){if(t.length>1){let i,a,s=0;for(i=0,a=t.length;i>>0,r=[];for(let t=0;t>>0);const n={ttcTag:t,majorVersion:i,minorVersion:a,numFonts:s,offsetTable:r};switch(i){case 1:return n;case 2:n.dsigTag=e.getInt32()>>>0;n.dsigLength=e.getInt32()>>>0;n.dsigOffset=e.getInt32()>>>0;return n}throw new FormatError(`Invalid TrueType Collection majorVersion: ${i}.`)}(e),s=t.split("+");let r;for(let n=0;n0||!(i.cMap instanceof IdentityCMap));if("OTTO"===r.version&&!t||!n.head||!n.hhea||!n.maxp||!n.post){o=new Stream(n["CFF "].data);g=new CFFFont(o,i);adjustWidths(i);return this.convert(e,g,i)}delete n.glyf;delete n.loca;delete n.fpgm;delete n.prep;delete n["cvt "];this.isOpenType=!0}if(!n.maxp)throw new FormatError('Required "maxp" table is not found');t.pos=(t.start||0)+n.maxp.offset;let C=t.getInt32();const h=t.getUint16();if(65536!==C&&20480!==C){if(6===n.maxp.length)C=20480;else{if(!(n.maxp.length>=32))throw new FormatError('"maxp" table has a wrong version number');C=65536}!function writeUint32(e,t,i){e[t+3]=255&i;e[t+2]=i>>>8;e[t+1]=i>>>16;e[t]=i>>>24}(n.maxp.data,0,C)}if(i.scaleFactors?.length===h&&c){const{scaleFactors:e}=i,t=int16(n.head.data[50],n.head.data[51]),a=new GlyfTable({glyfTable:n.glyf.data,isGlyphLocationsLong:t,locaTable:n.loca.data,numGlyphs:h});a.scale(e);const{glyf:s,loca:r,isLocationLong:g}=a.write();n.glyf.data=s;n.loca.data=r;if(g!==!!t){n.head.data[50]=0;n.head.data[51]=g?1:0}const o=n.hmtx.data;for(let t=0;t>8&255;o[i+1]=255&a;writeSignedInt16(o,i+2,Math.round(e[t]*signedInt16(o[i+2],o[i+3])))}}let l=h+1,Q=!0;if(l>65535){Q=!1;l=h;warn("Not enough space in glyfs to duplicate first glyph.")}let E=0,u=0;if(C>=65536&&n.maxp.length>=32){t.pos+=8;if(t.getUint16()>2){n.maxp.data[14]=0;n.maxp.data[15]=2}t.pos+=4;E=t.getUint16();t.pos+=4;u=t.getUint16()}n.maxp.data[4]=l>>8;n.maxp.data[5]=255&l;const d=function sanitizeTTPrograms(e,t,i,a){const s={functionsDefined:[],functionsUsed:[],functionsStackDeltas:[],tooComplexToFollowFunctions:!1,hintsValid:!0};e&&sanitizeTTProgram(e,s);t&&sanitizeTTProgram(t,s);e&&function checkInvalidFunctions(e,t){if(!e.tooComplexToFollowFunctions)if(e.functionsDefined.length>t){warn("TT: more functions defined than expected");e.hintsValid=!1}else for(let i=0,a=e.functionsUsed.length;it){warn("TT: invalid function id: "+i);e.hintsValid=!1;return}if(e.functionsUsed[i]&&!e.functionsDefined[i]){warn("TT: undefined function: "+i);e.hintsValid=!1;return}}}(s,a);if(i&&1&i.length){const e=new Uint8Array(i.length+1);e.set(i.data);i.data=e}return s.hintsValid}(n.fpgm,n.prep,n["cvt "],E);if(!d){delete n.fpgm;delete n.prep;delete n["cvt "]}!function sanitizeMetrics(e,t,i,a,s,r){if(!t){i&&(i.data=null);return}e.pos=(e.start||0)+t.offset;e.pos+=4;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;e.pos+=2;const n=e.getUint16();e.pos+=8;e.pos+=2;let g=e.getUint16();if(0!==n){if(!(2&int16(a.data[44],a.data[45]))){t.data[22]=0;t.data[23]=0}}if(g>s){info(`The numOfMetrics (${g}) should not be greater than the numGlyphs (${s}).`);g=s;t.data[34]=(65280&g)>>8;t.data[35]=255&g}const o=s-g-(i.length-4*g>>1);if(o>0){const e=new Uint8Array(i.length+2*o);e.set(i.data);if(r){e[i.length]=i.data[2];e[i.length+1]=i.data[3]}i.data=e}}(t,n.hhea,n.hmtx,n.head,l,Q);if(!n.head)throw new FormatError('Required "head" table is not found');!function sanitizeHead(e,t,i){const a=e.data,s=function int32(e,t,i,a){return(e<<24)+(t<<16)+(i<<8)+a}(a[0],a[1],a[2],a[3]);if(s>>16!=1){info("Attempting to fix invalid version in head table: "+s);a[0]=0;a[1]=1;a[2]=0;a[3]=0}const r=int16(a[50],a[51]);if(r<0||r>1){info("Attempting to fix invalid indexToLocFormat in head table: "+r);const e=t+1;if(i===e<<1){a[50]=0;a[51]=0}else{if(i!==e<<2)throw new FormatError("Could not fix indexToLocFormat: "+r);a[50]=0;a[51]=1}}}(n.head,h,c?n.loca.length:0);let f=Object.create(null);if(c){const e=int16(n.head.data[50],n.head.data[51]),t=function sanitizeGlyphLocations(e,t,i,a,s,r,n){let g,o,c;if(a){g=4;o=function fontItemDecodeLong(e,t){return e[t]<<24|e[t+1]<<16|e[t+2]<<8|e[t+3]};c=function fontItemEncodeLong(e,t,i){e[t]=i>>>24&255;e[t+1]=i>>16&255;e[t+2]=i>>8&255;e[t+3]=255&i}}else{g=2;o=function fontItemDecode(e,t){return e[t]<<9|e[t+1]<<1};c=function fontItemEncode(e,t,i){e[t]=i>>9&255;e[t+1]=i>>1&255}}const C=r?i+1:i,h=g*(1+C),l=new Uint8Array(h);l.set(e.data.subarray(0,h));e.data=l;const Q=t.data,E=Q.length,u=new Uint8Array(E);let d,f;const p=[];for(d=0,f=0;dE&&(e=E);p.push({index:d,offset:e,endOffset:0})}p.sort(((e,t)=>e.offset-t.offset));for(d=0;de.index-t.index));for(d=0;dn&&(n=e.sizeOfInstructions);w+=t;c(l,f,w)}if(0===w){const e=new Uint8Array([0,1,0,0,0,0,0,0,0,0,0,0,0,0,49,0]);for(d=0,f=g;di+w)t.data=u.subarray(0,i+w);else{t.data=new Uint8Array(i+w);t.data.set(u.subarray(0,w))}t.data.set(u.subarray(0,i),w);c(e.data,l.length-g,w+i)}else t.data=u.subarray(0,w);return{missingGlyphs:y,maxSizeOfInstructions:n}}(n.loca,n.glyf,h,e,d,Q,u);f=t.missingGlyphs;if(C>=65536&&n.maxp.length>=32){n.maxp.data[26]=t.maxSizeOfInstructions>>8;n.maxp.data[27]=255&t.maxSizeOfInstructions}}if(!n.hhea)throw new FormatError('Required "hhea" table is not found');if(0===n.hhea.data[10]&&0===n.hhea.data[11]){n.hhea.data[10]=255;n.hhea.data[11]=255}const p={unitsPerEm:int16(n.head.data[18],n.head.data[19]),yMax:signedInt16(n.head.data[42],n.head.data[43]),yMin:signedInt16(n.head.data[38],n.head.data[39]),ascent:signedInt16(n.hhea.data[4],n.hhea.data[5]),descent:signedInt16(n.hhea.data[6],n.hhea.data[7]),lineGap:signedInt16(n.hhea.data[8],n.hhea.data[9])};this.ascent=p.ascent/p.unitsPerEm;this.descent=p.descent/p.unitsPerEm;this.lineGap=p.lineGap/p.unitsPerEm;if(this.cssFontInfo?.lineHeight){this.lineHeight=this.cssFontInfo.metrics.lineHeight;this.lineGap=this.cssFontInfo.metrics.lineGap}else this.lineHeight=this.ascent-this.descent+this.lineGap;n.post&&function readPostScriptTable(e,i,a){const s=(t.start||0)+e.offset;t.pos=s;const r=s+e.length,n=t.getInt32();t.skip(28);let g,o,c=!0;switch(n){case 65536:g=Hi;break;case 131072:const e=t.getUint16();if(e!==a){c=!1;break}const s=[];for(o=0;o=32768){c=!1;break}s.push(e)}if(!c)break;const C=[],h=[];for(;t.pos65535)throw new FormatError("Max size of CID is 65,535");let s=-1;t?s=a:void 0!==e[a]&&(s=e[a]);s>=0&&s>>0;let C=!1;if(g?.platformId!==s||g?.encodingId!==r){if(0!==s||0!==r&&1!==r&&3!==r)if(1===s&&0===r)C=!0;else if(3!==s||1!==r||!a&&g){if(i&&3===s&&0===r){C=!0;let i=!0;if(e>3;e.push(a);i=Math.max(a,i)}const a=[];for(let e=0;e<=i;e++)a.push({firstCode:t.getUint16(),entryCount:t.getUint16(),idDelta:signedInt16(t.getByte(),t.getByte()),idRangePos:t.pos+t.getUint16()});for(let i=0;i<256;i++)if(0===e[i]){t.pos=a[0].idRangePos+2*i;Q=t.getUint16();h.push({charCode:i,glyphId:Q})}else{const s=a[e[i]];for(l=0;l>1;t.skip(6);const i=[];let a;for(a=0;a>1)-(e-a);s.offsetIndex=n;g=Math.max(g,n+s.end-s.start+1)}else s.offsetIndex=-1}const o=[];for(l=0;l>>0;for(l=0;l>>0,i=t.getInt32()>>>0;let a=t.getInt32()>>>0;for(let t=e;t<=i;t++)h.push({charCode:t,glyphId:a++})}}}h.sort((function(e,t){return e.charCode-t.charCode}));for(let e=1;e=61440&&t<=61695&&(t&=255);m[t]=e.glyphId}else for(const e of r)m[e.charCode]=e.glyphId;if(i.glyphNames&&(g.length||this.differences.length))for(let e=0;e<256;++e){if(!o&&void 0!==m[e])continue;const t=this.differences[e]||g[e];if(!t)continue;const a=i.glyphNames.indexOf(t);a>0&&hasGlyph(a)&&(m[e]=a)}}0===m.length&&(m[0]=0);let y=l-1;Q||(y=0);if(!i.cssFontInfo){const e=adjustMapping(m,hasGlyph,y,this.toUnicode);this.toFontChar=e.toFontChar;n.cmap={tag:"cmap",data:createCmapTable(e.charCodeToGlyphId,e.toUnicodeExtraMap,l)};n["OS/2"]&&function validateOS2Table(e,t){t.pos=(t.start||0)+e.offset;const i=t.getUint16();t.skip(60);const a=t.getUint16();if(i<4&&768&a)return!1;if(t.getUint16()>t.getUint16())return!1;t.skip(6);if(0===t.getUint16())return!1;e.data[8]=e.data[9]=0;return!0}(n["OS/2"],t)||(n["OS/2"]={tag:"OS/2",data:createOS2Table(i,e.charCodeToGlyphId,p)})}if(!c)try{o=new Stream(n["CFF "].data);g=new CFFParser(o,i,Ri).parse();g.duplicateFirstGlyph();const e=new CFFCompiler(g);n["CFF "].data=e.compile()}catch{warn("Failed to compile font "+i.loadedName)}if(n.name){const[t,a]=readNameTable(n.name);n.name.data=createNameTable(e,t);this.psName=t[0][6]||null;i.composite||function adjustTrueTypeToUnicode(e,t,i){if(e.isInternalFont)return;if(e.hasIncludedToUnicodeMap)return;if(e.hasEncoding)return;if(e.toUnicode instanceof IdentityToUnicodeMap)return;if(!t)return;if(0===i.length)return;if(e.defaultEncoding===li)return;for(const e of i)if(!isWinNameRecord(e))return;const a=li,s=[],r=wi();for(const e in a){const t=a[e];if(""===t)continue;const i=r[t];void 0!==i&&(s[e]=String.fromCharCode(i))}s.length>0&&e.toUnicode.amend(s)}(i,this.isSymbolicFont,a)}else n.name={tag:"name",data:createNameTable(this.name)};const w=new OpenTypeFileBuilder(r.version);for(const e in n)w.addTable(e,n[e].data);return w.toArray()}convert(e,t,i){i.fixedPitch=!1;i.builtInEncoding&&function adjustType1ToUnicode(e,t){if(e.isInternalFont)return;if(e.hasIncludedToUnicodeMap)return;if(t===e.defaultEncoding)return;if(e.toUnicode instanceof IdentityToUnicodeMap)return;const i=[],a=wi();for(const s in t){if(e.hasEncoding&&(e.baseEncodingName||void 0!==e.differences[s]))continue;const r=getUnicodeForGlyph(t[s],a);-1!==r&&(i[s]=String.fromCharCode(r))}i.length>0&&e.toUnicode.amend(i)}(i,i.builtInEncoding);let s=1;t instanceof CFFFont&&(s=t.numGlyphs-1);const r=t.getGlyphMapping(i);let n=null,g=r,o=null;if(!i.cssFontInfo){n=adjustMapping(r,t.hasGlyphId.bind(t),s,this.toUnicode);this.toFontChar=n.toFontChar;g=n.charCodeToGlyphId;o=n.toUnicodeExtraMap}const c=t.numGlyphs;function getCharCodes(e,t){let i=null;for(const a in e)t===e[a]&&(i||=[]).push(0|a);return i}function createCharCode(e,t){for(const i in e)if(t===e[i])return 0|i;n.charCodeToGlyphId[n.nextAvailableFontCharCode]=t;return n.nextAvailableFontCharCode++}const C=t.seacs;if(n&&C?.length){const e=i.fontMatrix||a,s=t.getCharset(),g=Object.create(null);for(let t in C){t|=0;const i=C[t],a=hi[i[2]],o=hi[i[3]],c=s.indexOf(a),h=s.indexOf(o);if(c<0||h<0)continue;const l={x:i[0]*e[0]+i[1]*e[2]+e[4],y:i[0]*e[1]+i[1]*e[3]+e[5]},Q=getCharCodes(r,t);if(Q)for(const e of Q){const t=n.charCodeToGlyphId,i=createCharCode(t,c),a=createCharCode(t,h);g[e]={baseFontCharCode:i,accentFontCharCode:a,accentOffset:l}}}i.seacMap=g}const h=i.fontMatrix?1/Math.max(...i.fontMatrix.slice(0,4).map(Math.abs)):1e3,l=new OpenTypeFileBuilder("OTTO");l.addTable("CFF ",t.data);l.addTable("OS/2",createOS2Table(i,g));l.addTable("cmap",createCmapTable(g,o,c));l.addTable("head","\0\0\0\0\0\0\0\0\0\0_<õ\0\0"+safeString16(h)+"\0\0\0\0ž\v~'\0\0\0\0ž\v~'\0\0"+safeString16(i.descent)+"ÿ"+safeString16(i.ascent)+string16(i.italicAngle?2:0)+"\0\0\0\0\0\0\0");l.addTable("hhea","\0\0\0"+safeString16(i.ascent)+safeString16(i.descent)+"\0\0ÿÿ\0\0\0\0\0\0"+safeString16(i.capHeight)+safeString16(Math.tan(i.italicAngle)*i.xHeight)+"\0\0\0\0\0\0\0\0\0\0\0\0"+string16(c));l.addTable("hmtx",function fontFieldsHmtx(){const e=t.charstrings,i=t.cff?t.cff.widths:null;let a="\0\0\0\0";for(let t=1,s=c;t=65520&&e<=65535?0:e>=62976&&e<=63743?bi()[e]||e:173===e?45:e}(i)}this.isType3Font&&(s=i);let C=null;if(this.seacMap?.[e]){c=!0;const t=this.seacMap[e];i=t.baseFontCharCode;C={fontChar:String.fromCodePoint(t.accentFontCharCode),offset:t.accentOffset}}let h="";"number"==typeof i&&(i<=1114111?h=String.fromCodePoint(i):warn(`charToGlyph - invalid fontCharCode: ${i}`));if(this.missingFile&&this.vertical&&1===h.length){const e=Ji()[h.charCodeAt(0)];e&&(h=o=String.fromCharCode(e))}r=new fonts_Glyph(e,h,o,C,a,g,s,t,c);return this._glyphCache[e]=r}charsToGlyphs(e){let t=this._charsCache[e];if(t)return t;t=[];if(this.cMap){const i=Object.create(null),a=e.length;let s=0;for(;st.length%2==1,a=this.toUnicode instanceof IdentityToUnicodeMap?e=>this.toUnicode.charCodeOf(e):e=>this.toUnicode.charCodeOf(String.fromCodePoint(e));for(let s=0,r=e.length;s55295&&(r<57344||r>65533)&&s++;if(this.toUnicode){const e=a(r);if(-1!==e){if(hasCurrentBufErrors()){t.push(i.join(""));i.length=0}for(let t=(this.cMap?this.cMap.getCharCodeLength(e):1)-1;t>=0;t--)i.push(String.fromCharCode(e>>8*t&255));continue}}if(!hasCurrentBufErrors()){t.push(i.join(""));i.length=0}i.push(String.fromCodePoint(r))}t.push(i.join(""));return t}}class ErrorFont{constructor(e){this.error=e;this.loadedName="g_font_error";this.missingFile=!0}charsToGlyphs(){return[]}encodeString(e){return[e]}exportData(e=!1){return{error:this.error}}}const Ia=2,ca=3,Ca=4,ha=5,la=6,Ba=7;class Pattern{constructor(){unreachable("Cannot initialize Pattern.")}static parseShading(e,t,i,a,s){const r=e instanceof BaseStream?e.dict:e,n=r.get("ShadingType");try{switch(n){case Ia:case ca:return new RadialAxialShading(r,t,i,a,s);case Ca:case ha:case la:case Ba:return new MeshShading(e,t,i,a,s);default:throw new FormatError("Unsupported ShadingType: "+n)}}catch(e){if(e instanceof MissingDataException)throw e;warn(e);return new DummyShading}}}class BaseShading{static SMALL_NUMBER=1e-6;getIR(){unreachable("Abstract method `getIR` called.")}}class RadialAxialShading extends BaseShading{constructor(e,t,i,a,s){super();this.shadingType=e.get("ShadingType");let r=0;this.shadingType===Ia?r=4:this.shadingType===ca&&(r=6);this.coordsArr=e.getArray("Coords");if(!isNumberArray(this.coordsArr,r))throw new FormatError("RadialAxialShading: Invalid /Coords array.");const n=ColorSpace.parse({cs:e.getRaw("CS")||e.getRaw("ColorSpace"),xref:t,resources:i,pdfFunctionFactory:a,localColorSpaceCache:s});this.bbox=lookupNormalRect(e.getArray("BBox"),null);let g=0,o=1;const c=e.getArray("Domain");isNumberArray(c,2)&&([g,o]=c);let C=!1,h=!1;const l=e.getArray("Extend");(function isBooleanArray(e,t){return Array.isArray(e)&&(null===t||e.length===t)&&e.every((e=>"boolean"==typeof e))})(l,2)&&([C,h]=l);if(!(this.shadingType!==ca||C&&h)){const[e,t,i,a,s,r]=this.coordsArr,n=Math.hypot(e-a,t-s);i<=r+n&&r<=i+n&&warn("Unsupported radial gradient.")}this.extendStart=C;this.extendEnd=h;const Q=e.getRaw("Function"),E=a.createFromArray(Q),u=(o-g)/840,d=this.colorStops=[];if(g>=o||u<=0){info("Bad shading domain.");return}const f=new Float32Array(n.numComps),p=new Float32Array(1);let m,y=0;p[0]=g;E(p,0,f,0);let w=n.getRgb(f,0);const D=Util.makeHexColor(w[0],w[1],w[2]);d.push([0,D]);let b=1;p[0]=g+u;E(p,0,f,0);let F=n.getRgb(f,0),S=F[0]-w[0]+1,k=F[1]-w[1]+1,R=F[2]-w[2]+1,N=F[0]-w[0]-1,G=F[1]-w[1]-1,M=F[2]-w[2]-1;for(let e=2;e<840;e++){p[0]=g+e*u;E(p,0,f,0);m=n.getRgb(f,0);const t=e-y;S=Math.min(S,(m[0]-w[0]+1)/t);k=Math.min(k,(m[1]-w[1]+1)/t);R=Math.min(R,(m[2]-w[2]+1)/t);N=Math.max(N,(m[0]-w[0]-1)/t);G=Math.max(G,(m[1]-w[1]-1)/t);M=Math.max(M,(m[2]-w[2]-1)/t);if(!(N<=S&&G<=k&&M<=R)){const e=Util.makeHexColor(F[0],F[1],F[2]);d.push([b/840,e]);S=m[0]-F[0]+1;k=m[1]-F[1]+1;R=m[2]-F[2]+1;N=m[0]-F[0]-1;G=m[1]-F[1]-1;M=m[2]-F[2]-1;y=b;w=F}b=e;F=m}const U=Util.makeHexColor(F[0],F[1],F[2]);d.push([1,U]);let x="transparent";if(e.has("Background")){m=n.getRgb(e.get("Background"),0);x=Util.makeHexColor(m[0],m[1],m[2])}if(!C){d.unshift([0,x]);d[1][0]+=BaseShading.SMALL_NUMBER}if(!h){d.at(-1)[0]-=BaseShading.SMALL_NUMBER;d.push([1,x])}this.colorStops=d}getIR(){const{coordsArr:e,shadingType:t}=this;let i,a,s,r,n;if(t===Ia){a=[e[0],e[1]];s=[e[2],e[3]];r=null;n=null;i="axial"}else if(t===ca){a=[e[0],e[1]];s=[e[3],e[4]];r=e[2];n=e[5];i="radial"}else unreachable(`getPattern type unknown: ${t}`);return["RadialAxial",i,this.bbox,this.colorStops,a,s,r,n]}}class MeshStreamReader{constructor(e,t){this.stream=e;this.context=t;this.buffer=0;this.bufferLength=0;const i=t.numComps;this.tmpCompsBuf=new Float32Array(i);const a=t.colorSpace.numComps;this.tmpCsCompsBuf=t.colorFn?new Float32Array(a):this.tmpCompsBuf}get hasData(){if(this.stream.end)return this.stream.pos0)return!0;const e=this.stream.getByte();if(e<0)return!1;this.buffer=e;this.bufferLength=8;return!0}readBits(e){let t=this.buffer,i=this.bufferLength;if(32===e){if(0===i)return(this.stream.getByte()<<24|this.stream.getByte()<<16|this.stream.getByte()<<8|this.stream.getByte())>>>0;t=t<<24|this.stream.getByte()<<16|this.stream.getByte()<<8|this.stream.getByte();const e=this.stream.getByte();this.buffer=e&(1<>i)>>>0}if(8===e&&0===i)return this.stream.getByte();for(;i>i}align(){this.buffer=0;this.bufferLength=0}readFlag(){return this.readBits(this.context.bitsPerFlag)}readCoordinate(){const e=this.context.bitsPerCoordinate,t=this.readBits(e),i=this.readBits(e),a=this.context.decode,s=e<32?1/((1<r?r:e;t=t>n?n:t;i=ie*s[t])):i;let n,g=-2;const o=[];for(const[e,t]of a.map(((e,t)=>[e,t])).sort((([e],[t])=>e-t)))if(-1!==e)if(e===g+1){n.push(r[t]);g+=1}else{g=e;n=[r[t]];o.push(e,n)}return o}(e),i=new Dict(null);i.set("BaseFont",Name.get(e));i.set("Type",Name.get("Font"));i.set("Subtype",Name.get("CIDFontType2"));i.set("Encoding",Name.get("Identity-H"));i.set("CIDToGIDMap",Name.get("Identity"));i.set("W",t);i.set("FirstChar",t[0]);i.set("LastChar",t.at(-2)+t.at(-1).length-1);const a=new Dict(null);i.set("FontDescriptor",a);const s=new Dict(null);s.set("Ordering","Identity");s.set("Registry","Adobe");s.set("Supplement",0);i.set("CIDSystemInfo",s);return i}class PostScriptParser{constructor(e){this.lexer=e;this.operators=[];this.token=null;this.prev=null}nextToken(){this.prev=this.token;this.token=this.lexer.getToken()}accept(e){if(this.token.type===e){this.nextToken();return!0}return!1}expect(e){if(this.accept(e))return!0;throw new FormatError(`Unexpected symbol: found ${this.token.type} expected ${e}.`)}parse(){this.nextToken();this.expect(as.LBRACE);this.parseBlock();this.expect(as.RBRACE);return this.operators}parseBlock(){for(;;)if(this.accept(as.NUMBER))this.operators.push(this.prev.value);else if(this.accept(as.OPERATOR))this.operators.push(this.prev.value);else{if(!this.accept(as.LBRACE))return;this.parseCondition()}}parseCondition(){const e=this.operators.length;this.operators.push(null,null);this.parseBlock();this.expect(as.RBRACE);if(this.accept(as.IF)){this.operators[e]=this.operators.length;this.operators[e+1]="jz"}else{if(!this.accept(as.LBRACE))throw new FormatError("PS Function: error parsing conditional.");{const t=this.operators.length;this.operators.push(null,null);const i=this.operators.length;this.parseBlock();this.expect(as.RBRACE);this.expect(as.IFELSE);this.operators[t]=this.operators.length;this.operators[t+1]="j";this.operators[e]=i;this.operators[e+1]="jz"}}}}const as={LBRACE:0,RBRACE:1,NUMBER:2,OPERATOR:3,IF:4,IFELSE:5};class PostScriptToken{static get opCache(){return shadow(this,"opCache",Object.create(null))}constructor(e,t){this.type=e;this.value=t}static getOperator(e){return PostScriptToken.opCache[e]||=new PostScriptToken(as.OPERATOR,e)}static get LBRACE(){return shadow(this,"LBRACE",new PostScriptToken(as.LBRACE,"{"))}static get RBRACE(){return shadow(this,"RBRACE",new PostScriptToken(as.RBRACE,"}"))}static get IF(){return shadow(this,"IF",new PostScriptToken(as.IF,"IF"))}static get IFELSE(){return shadow(this,"IFELSE",new PostScriptToken(as.IFELSE,"IFELSE"))}}class PostScriptLexer{constructor(e){this.stream=e;this.nextChar();this.strBuf=[]}nextChar(){return this.currentChar=this.stream.getByte()}getToken(){let e=!1,t=this.currentChar;for(;;){if(t<0)return Bt;if(e)10!==t&&13!==t||(e=!1);else if(37===t)e=!0;else if(!isWhiteSpace(t))break;t=this.nextChar()}switch(0|t){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:case 43:case 45:case 46:return new PostScriptToken(as.NUMBER,this.getNumber());case 123:this.nextChar();return PostScriptToken.LBRACE;case 125:this.nextChar();return PostScriptToken.RBRACE}const i=this.strBuf;i.length=0;i[0]=String.fromCharCode(t);for(;(t=this.nextChar())>=0&&(t>=65&&t<=90||t>=97&&t<=122);)i.push(String.fromCharCode(t));const a=i.join("");switch(a.toLowerCase()){case"if":return PostScriptToken.IF;case"ifelse":return PostScriptToken.IFELSE;default:return PostScriptToken.getOperator(a)}}getNumber(){let e=this.currentChar;const t=this.strBuf;t.length=0;t[0]=String.fromCharCode(e);for(;(e=this.nextChar())>=0&&(e>=48&&e<=57||45===e||46===e);)t.push(String.fromCharCode(e));const i=parseFloat(t.join(""));if(isNaN(i))throw new FormatError(`Invalid floating point number: ${i}`);return i}}class BaseLocalCache{constructor(e){this._onlyRefs=!0===e?.onlyRefs;if(!this._onlyRefs){this._nameRefMap=new Map;this._imageMap=new Map}this._imageCache=new RefSetCache}getByName(e){this._onlyRefs&&unreachable("Should not call `getByName` method.");const t=this._nameRefMap.get(e);return t?this.getByRef(t):this._imageMap.get(e)||null}getByRef(e){return this._imageCache.get(e)||null}set(e,t,i){unreachable("Abstract method `set` called.")}}class LocalImageCache extends BaseLocalCache{set(e,t=null,i){if("string"!=typeof e)throw new Error('LocalImageCache.set - expected "name" argument.');if(t){if(this._imageCache.has(t))return;this._nameRefMap.set(e,t);this._imageCache.put(t,i)}else this._imageMap.has(e)||this._imageMap.set(e,i)}}class LocalColorSpaceCache extends BaseLocalCache{set(e=null,t=null,i){if("string"!=typeof e&&!t)throw new Error('LocalColorSpaceCache.set - expected "name" and/or "ref" argument.');if(t){if(this._imageCache.has(t))return;null!==e&&this._nameRefMap.set(e,t);this._imageCache.put(t,i)}else this._imageMap.has(e)||this._imageMap.set(e,i)}}class LocalFunctionCache extends BaseLocalCache{constructor(e){super({onlyRefs:!0})}set(e=null,t,i){if(!t)throw new Error('LocalFunctionCache.set - expected "ref" argument.');this._imageCache.has(t)||this._imageCache.put(t,i)}}class LocalGStateCache extends BaseLocalCache{set(e,t=null,i){if("string"!=typeof e)throw new Error('LocalGStateCache.set - expected "name" argument.');if(t){if(this._imageCache.has(t))return;this._nameRefMap.set(e,t);this._imageCache.put(t,i)}else this._imageMap.has(e)||this._imageMap.set(e,i)}}class LocalTilingPatternCache extends BaseLocalCache{constructor(e){super({onlyRefs:!0})}set(e=null,t,i){if(!t)throw new Error('LocalTilingPatternCache.set - expected "ref" argument.');this._imageCache.has(t)||this._imageCache.put(t,i)}}class RegionalImageCache extends BaseLocalCache{constructor(e){super({onlyRefs:!0})}set(e=null,t,i){if(!t)throw new Error('RegionalImageCache.set - expected "ref" argument.');this._imageCache.has(t)||this._imageCache.put(t,i)}}class GlobalImageCache{static NUM_PAGES_THRESHOLD=2;static MIN_IMAGES_TO_CACHE=10;static MAX_BYTE_SIZE=5e7;#F=new RefSet;constructor(){this._refCache=new RefSetCache;this._imageCache=new RefSetCache}get#S(){let e=0;for(const t of this._imageCache)e+=t.byteSize;return e}get#k(){return!(this._imageCache.size+e)):null}class PDFFunction{static getSampleArray(e,t,i,a){let s,r,n=1;for(s=0,r=e.length;s>o)*C;c&=(1<i?e=i:e0&&(l=r[h-1]);let Q=a[1];h>1,c=s.length>>1,C=new PostScriptEvaluator(g),h=Object.create(null);let l=8192;const Q=new Float32Array(c);return function constructPostScriptFn(e,t,i,a){let s,n,g="";const E=Q;for(s=0;se&&(n=e)}d[s]=n}if(l>0){l--;h[g]=d}i.set(d,a)}}}function isPDFFunction(e){let t;if(e instanceof Dict)t=e;else{if(!(e instanceof BaseStream))return!1;t=e.dict}return t.has("FunctionType")}class PostScriptStack{static MAX_STACK_SIZE=100;constructor(e){this.stack=e?Array.from(e):[]}push(e){if(this.stack.length>=PostScriptStack.MAX_STACK_SIZE)throw new Error("PostScript function stack overflow.");this.stack.push(e)}pop(){if(this.stack.length<=0)throw new Error("PostScript function stack underflow.");return this.stack.pop()}copy(e){if(this.stack.length+e>=PostScriptStack.MAX_STACK_SIZE)throw new Error("PostScript function stack overflow.");const t=this.stack;for(let i=t.length-e,a=e-1;a>=0;a--,i++)t.push(t[i])}index(e){this.push(this.stack[this.stack.length-e-1])}roll(e,t){const i=this.stack,a=i.length-e,s=i.length-1,r=a+(t-Math.floor(t/e)*e);for(let e=a,t=s;e0?t.push(n<>g);break;case"ceiling":n=t.pop();t.push(Math.ceil(n));break;case"copy":n=t.pop();t.copy(n);break;case"cos":n=t.pop();t.push(Math.cos(n%360/180*Math.PI));break;case"cvi":n=0|t.pop();t.push(n);break;case"cvr":break;case"div":g=t.pop();n=t.pop();t.push(n/g);break;case"dup":t.copy(1);break;case"eq":g=t.pop();n=t.pop();t.push(n===g);break;case"exch":t.roll(2,1);break;case"exp":g=t.pop();n=t.pop();t.push(n**g);break;case"false":t.push(!1);break;case"floor":n=t.pop();t.push(Math.floor(n));break;case"ge":g=t.pop();n=t.pop();t.push(n>=g);break;case"gt":g=t.pop();n=t.pop();t.push(n>g);break;case"idiv":g=t.pop();n=t.pop();t.push(n/g|0);break;case"index":n=t.pop();t.index(n);break;case"le":g=t.pop();n=t.pop();t.push(n<=g);break;case"ln":n=t.pop();t.push(Math.log(n));break;case"log":n=t.pop();t.push(Math.log10(n));break;case"lt":g=t.pop();n=t.pop();t.push(n=t?new AstLiteral(t):e.max<=t?e:new AstMin(e,t)}class PostScriptCompiler{compile(e,t,i){const a=[],s=[],r=t.length>>1,n=i.length>>1;let g,o,c,C,h,l,Q,E,u=0;for(let e=0;et.min){g.unshift("Math.max(",r,", ");g.push(")")}if(n4){a=!0;t=0}else{a=!1;t=1}const o=[];for(r=0;r=0&&"ET"===gs[e];--e)gs[e]="EN";for(let e=r+1;e0&&(t=gs[r-1]);let i=h;e+1E&&isOdd(E)&&(d=E)}for(E=u;E>=d;--E){let e=-1;for(r=0,n=o.length;r=0){reverseValues(ns,e,r);e=-1}}else e<0&&(e=r);e>=0&&reverseValues(ns,e,o.length)}for(r=0,n=ns.length;r"!==e||(ns[r]="")}return createBidiText(ns.join(""),a)}const os={style:"normal",weight:"normal"},Is={style:"normal",weight:"bold"},cs={style:"italic",weight:"normal"},Cs={style:"italic",weight:"bold"},hs=new Map([["Times-Roman",{local:["Times New Roman","Times-Roman","Times","Liberation Serif","Nimbus Roman","Nimbus Roman L","Tinos","Thorndale","TeX Gyre Termes","FreeSerif","Linux Libertine O","Libertinus Serif","DejaVu Serif","Bitstream Vera Serif","Ubuntu"],style:os,ultimate:"serif"}],["Times-Bold",{alias:"Times-Roman",style:Is,ultimate:"serif"}],["Times-Italic",{alias:"Times-Roman",style:cs,ultimate:"serif"}],["Times-BoldItalic",{alias:"Times-Roman",style:Cs,ultimate:"serif"}],["Helvetica",{local:["Helvetica","Helvetica Neue","Arial","Arial Nova","Liberation Sans","Arimo","Nimbus Sans","Nimbus Sans L","A030","TeX Gyre Heros","FreeSans","DejaVu Sans","Albany","Bitstream Vera Sans","Arial Unicode MS","Microsoft Sans Serif","Apple Symbols","Cantarell"],path:"LiberationSans-Regular.ttf",style:os,ultimate:"sans-serif"}],["Helvetica-Bold",{alias:"Helvetica",path:"LiberationSans-Bold.ttf",style:Is,ultimate:"sans-serif"}],["Helvetica-Oblique",{alias:"Helvetica",path:"LiberationSans-Italic.ttf",style:cs,ultimate:"sans-serif"}],["Helvetica-BoldOblique",{alias:"Helvetica",path:"LiberationSans-BoldItalic.ttf",style:Cs,ultimate:"sans-serif"}],["Courier",{local:["Courier","Courier New","Liberation Mono","Nimbus Mono","Nimbus Mono L","Cousine","Cumberland","TeX Gyre Cursor","FreeMono","Linux Libertine Mono O","Libertinus Mono"],style:os,ultimate:"monospace"}],["Courier-Bold",{alias:"Courier",style:Is,ultimate:"monospace"}],["Courier-Oblique",{alias:"Courier",style:cs,ultimate:"monospace"}],["Courier-BoldOblique",{alias:"Courier",style:Cs,ultimate:"monospace"}],["ArialBlack",{local:["Arial Black"],style:{style:"normal",weight:"900"},fallback:"Helvetica-Bold"}],["ArialBlack-Bold",{alias:"ArialBlack"}],["ArialBlack-Italic",{alias:"ArialBlack",style:{style:"italic",weight:"900"},fallback:"Helvetica-BoldOblique"}],["ArialBlack-BoldItalic",{alias:"ArialBlack-Italic"}],["ArialNarrow",{local:["Arial Narrow","Liberation Sans Narrow","Helvetica Condensed","Nimbus Sans Narrow","TeX Gyre Heros Cn"],style:os,fallback:"Helvetica"}],["ArialNarrow-Bold",{alias:"ArialNarrow",style:Is,fallback:"Helvetica-Bold"}],["ArialNarrow-Italic",{alias:"ArialNarrow",style:cs,fallback:"Helvetica-Oblique"}],["ArialNarrow-BoldItalic",{alias:"ArialNarrow",style:Cs,fallback:"Helvetica-BoldOblique"}],["Calibri",{local:["Calibri","Carlito"],style:os,fallback:"Helvetica"}],["Calibri-Bold",{alias:"Calibri",style:Is,fallback:"Helvetica-Bold"}],["Calibri-Italic",{alias:"Calibri",style:cs,fallback:"Helvetica-Oblique"}],["Calibri-BoldItalic",{alias:"Calibri",style:Cs,fallback:"Helvetica-BoldOblique"}],["Wingdings",{local:["Wingdings","URW Dingbats"],style:os}],["Wingdings-Regular",{alias:"Wingdings"}],["Wingdings-Bold",{alias:"Wingdings"}]]),ls=new Map([["Arial-Black","ArialBlack"]]);function getFamilyName(e){const t=new Set(["thin","extralight","ultralight","demilight","semilight","light","book","regular","normal","medium","demibold","semibold","bold","extrabold","ultrabold","black","heavy","extrablack","ultrablack","roman","italic","oblique","ultracondensed","extracondensed","condensed","semicondensed","normal","semiexpanded","expanded","extraexpanded","ultraexpanded","bolditalic"]);return e.split(/[- ,+]+/g).filter((e=>!t.has(e.toLowerCase()))).join(" ")}function generateFont({alias:e,local:t,path:i,fallback:a,style:s,ultimate:r},n,g,o=!0,c=!0,C=""){const h={style:null,ultimate:null};if(t){const e=C?` ${C}`:"";for(const i of t)n.push(`local(${i}${e})`)}if(e){const t=hs.get(e),r=C||function getStyleToAppend(e){switch(e){case Is:return"Bold";case cs:return"Italic";case Cs:return"Bold Italic";default:if("bold"===e?.weight)return"Bold";if("italic"===e?.style)return"Italic"}return""}(s);Object.assign(h,generateFont(t,n,g,o&&!a,c&&!i,r))}s&&(h.style=s);r&&(h.ultimate=r);if(o&&a){const e=hs.get(a),{ultimate:t}=generateFont(e,n,g,o,c&&!i,C);h.ultimate||=t}c&&i&&g&&n.push(`url(${g}${i})`);return h}function getFontSubstitution(e,t,i,a,s,r){if(a.startsWith("InvalidPDFjsFont_"))return null;"TrueType"!==r&&"Type1"!==r||!/^[A-Z]{6}\+/.test(a)||(a=a.slice(7));const n=a=normalizeFontName(a);let g=e.get(n);if(g)return g;let o=hs.get(a);if(!o)for(const[e,t]of ls)if(a.startsWith(e)){a=`${t}${a.substring(e.length)}`;o=hs.get(a);break}let c=!1;if(!o){o=hs.get(s);c=!0}const C=`${t.getDocId()}_s${t.createFontId()}`;if(!o){if(!validateFontName(a)){warn(`Cannot substitute the font because of its name: ${a}`);e.set(n,null);return null}const t=/bold/gi.test(a),i=/oblique|italic/gi.test(a),s=t&&i&&Cs||t&&Is||i&&cs||os;g={css:`"${getFamilyName(a)}",${C}`,guessFallback:!0,loadedName:C,baseFontName:a,src:`local(${a})`,style:s};e.set(n,g);return g}const h=[];c&&validateFontName(a)&&h.push(`local(${a})`);const{style:l,ultimate:Q}=generateFont(o,h,i),E=null===Q,u=E?"":`,${Q}`;g={css:`"${getFamilyName(a)}",${C}${u}`,guessFallback:E,loadedName:C,baseFontName:a,src:h.join(","),style:l};e.set(n,g);return g}class ImageResizer{static#R=2048;static#y=FeatureTest.isImageDecoderSupported;constructor(e,t){this._imgData=e;this._isMask=t}static get canUseImageDecoder(){return shadow(this,"canUseImageDecoder",this.#y?ImageDecoder.isTypeSupported("image/bmp"):Promise.resolve(!1))}static needsToBeResized(e,t){if(e<=this.#R&&t<=this.#R)return!1;const{MAX_DIM:i}=this;if(e>i||t>i)return!0;const a=e*t;if(this._hasMaxArea)return a>this.MAX_AREA;if(a(this.MAX_AREA=this.#R**2)}static get MAX_DIM(){return shadow(this,"MAX_DIM",this._guessMax(2048,65537,0,1))}static get MAX_AREA(){this._hasMaxArea=!0;return shadow(this,"MAX_AREA",this._guessMax(this.#R,this.MAX_DIM,128,0)**2)}static set MAX_AREA(e){if(e>=0){this._hasMaxArea=!0;shadow(this,"MAX_AREA",e)}}static setOptions({canvasMaxAreaInBytes:e=-1,isImageDecoderSupported:t=!1}){this._hasMaxArea||(this.MAX_AREA=e>>2);this.#y=t}static _areGoodDims(e,t){try{const i=new OffscreenCanvas(e,t),a=i.getContext("2d");a.fillRect(0,0,1,1);const s=a.getImageData(0,0,1,1).data[3];i.width=i.height=1;return 0!==s}catch{return!1}}static _guessMax(e,t,i,a){for(;e+i+1pt){const e=this.#N();if(e)return e}const a=this._encodeBMP();let s,r;if(await ImageResizer.canUseImageDecoder){s=new ImageDecoder({data:a,type:"image/bmp",preferAnimation:!1,transfer:[a.buffer]});r=s.decode().catch((e=>{warn(`BMP image decoding failed: ${e}`);return createImageBitmap(new Blob([this._encodeBMP().buffer],{type:"image/bmp"}))})).finally((()=>{s.close()}))}else r=createImageBitmap(new Blob([a.buffer],{type:"image/bmp"}));const{MAX_AREA:n,MAX_DIM:g}=ImageResizer,o=Math.max(t/g,i/g,Math.sqrt(t*i/n)),c=Math.max(o,2),C=Math.round(10*(o+1.25))/10/c,h=Math.floor(Math.log2(C)),l=new Array(h+2).fill(2);l[0]=c;l.splice(-1,1,C/(1<>n,o=a>>n;let c,C=a;try{c=new Uint8Array(r)}catch{let e=Math.floor(Math.log2(r+1));for(;;)try{c=new Uint8Array(2**e-1);break}catch{e-=1}C=Math.floor((2**e-1)/(4*i));const t=i*C*4;t>n;e>3,n=i+3&-4;if(i!==n){const e=new Uint8Array(n*t);let a=0;for(let r=0,g=t*i;r>>8;t[i++]=255&s}}}else{if(!ArrayBuffer.isView(e))throw new Error("Invalid data format, must be a string or TypedArray.");t=e.slice();i=t.byteLength}const a=i>>2,s=i-4*a,r=new Uint32Array(t.buffer,0,a);let n=0,g=0,o=this.h1,c=this.h2;const C=3432918353,h=461845907,l=11601,Q=13715;for(let e=0;e>>17;n=n*h&Qs|n*Q&Es;o^=n;o=o<<13|o>>>19;o=5*o+3864292196}else{g=r[e];g=g*C&Qs|g*l&Es;g=g<<15|g>>>17;g=g*h&Qs|g*Q&Es;c^=g;c=c<<13|c>>>19;c=5*c+3864292196}n=0;switch(s){case 3:n^=t[4*a+2]<<16;case 2:n^=t[4*a+1]<<8;case 1:n^=t[4*a];n=n*C&Qs|n*l&Es;n=n<<15|n>>>17;n=n*h&Qs|n*Q&Es;1&a?o^=n:c^=n}this.h1=o;this.h2=c}hexdigest(){let e=this.h1,t=this.h2;e^=t>>>1;e=3981806797*e&Qs|36045*e&Es;t=4283543511*t&Qs|(2950163797*(t<<16|e>>>16)&Qs)>>>16;e^=t>>>1;e=444984403*e&Qs|60499*e&Es;t=3301882366*t&Qs|(3120437893*(t<<16|e>>>16)&Qs)>>>16;e^=t>>>1;return(e>>>0).toString(16).padStart(8,"0")+(t>>>0).toString(16).padStart(8,"0")}}function addState(e,t,i,a,s){let r=e;for(let e=0,i=t.length-1;e1e3){c=Math.max(c,l);Q+=h+2;l=0;h=0}C.push({transform:t,x:l,y:Q,w:i.width,h:i.height});l+=i.width+2;h=Math.max(h,i.height)}const E=Math.max(c,l)+1,u=Q+h+1,d=new Uint8Array(E*u*4),f=E<<2;for(let e=0;e=0;){t[r-4]=t[r];t[r-3]=t[r+1];t[r-2]=t[r+2];t[r-1]=t[r+3];t[r+i]=t[r+i-4];t[r+i+1]=t[r+i-3];t[r+i+2]=t[r+i-2];t[r+i+3]=t[r+i-1];r-=f}}const p={width:E,height:u};if(e.isOffscreenCanvasSupported){const e=new OffscreenCanvas(E,u);e.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(d.buffer),E,u),0,0);p.bitmap=e.transferToImageBitmap();p.data=null}else{p.kind=S;p.data=d}i.splice(r,4*o,$e);a.splice(r,4*o,[p,C]);return r+1}));addState(us,[MA,xA,Ze,UA],null,(function iterateImageMaskGroup(e,t){const i=e.fnArray,a=(t-(e.iCurr-3))%4;switch(a){case 0:return i[t]===MA;case 1:return i[t]===xA;case 2:return i[t]===Ze;case 3:return i[t]===UA}throw new Error(`iterateImageMaskGroup - invalid pos: ${a}`)}),(function foundImageMaskGroup(e,t){const i=e.fnArray,a=e.argsArray,s=e.iCurr,r=s-3,n=s-2,g=s-1;let o=Math.floor((t-r)/4);if(o<10)return t-(t-r)%4;let c,C,h=!1;const l=a[g][0],Q=a[n][0],E=a[n][1],u=a[n][2],d=a[n][3];if(E===u){h=!0;c=n+4;let e=g+4;for(let t=1;t=4&&i[r-4]===i[n]&&i[r-3]===i[g]&&i[r-2]===i[o]&&i[r-1]===i[c]&&a[r-4][0]===C&&a[r-4][1]===h){l++;Q-=5}let E=Q+4;for(let e=1;e=i)break}a=(a||us)[e[t]];if(a&&!Array.isArray(a)){r.iCurr=t;t++;if(!a.checkFn||(0,a.checkFn)(r)){s=a;a=null}else a=null}else t++}this.state=a;this.match=s;this.lastProcessed=t}flush(){for(;this.match;){const e=this.queue.fnArray.length;this.lastProcessed=(0,this.match.processFn)(this.context,e);this.match=null;this.state=null;this._optimize()}}reset(){this.state=null;this.match=null;this.lastProcessed=0}}class OperatorList{static CHUNK_SIZE=1e3;static CHUNK_SIZE_ABOUT=this.CHUNK_SIZE-5;constructor(e=0,t){this._streamSink=t;this.fnArray=[];this.argsArray=[];this.optimizer=!t||e&E?new NullOptimizer(this):new QueueOptimizer(this);this.dependencies=new Set;this._totalLength=0;this.weight=0;this._resolved=t?null:Promise.resolve()}set isOffscreenCanvasSupported(e){this.optimizer.isOffscreenCanvasSupported=e}get length(){return this.argsArray.length}get ready(){return this._resolved||this._streamSink.ready}get totalLength(){return this._totalLength+this.length}addOp(e,t){this.optimizer.push(e,t);this.weight++;this._streamSink&&(this.weight>=OperatorList.CHUNK_SIZE||this.weight>=OperatorList.CHUNK_SIZE_ABOUT&&(e===UA||e===ee))&&this.flush()}addImageOps(e,t,i,a=!1){if(a){this.addOp(MA);this.addOp(GA,[[["SMask",!1]]])}void 0!==i&&this.addOp(Ye,["OC",i]);this.addOp(e,t);void 0!==i&&this.addOp(ve,[]);a&&this.addOp(UA)}addDependency(e){if(!this.dependencies.has(e)){this.dependencies.add(e);this.addOp(wA,[e])}}addDependencies(e){for(const t of e)this.addDependency(t)}addOpList(e){if(e instanceof OperatorList){for(const t of e.dependencies)this.dependencies.add(t);for(let t=0,i=e.length;ta&&(e=a);return e}function resizeImageMask(e,t,i,a,s,r){const n=s*r;let g;g=t<=8?new Uint8Array(n):t<=16?new Uint16Array(n):new Uint32Array(n);const o=i/s,c=a/r;let C,h,l,Q,E=0;const u=new Uint16Array(s),d=i;for(C=0;C0&&Number.isInteger(i.height)&&i.height>0&&(i.width!==l||i.height!==Q)){warn("PDFImage - using the Width/Height of the image data, rather than the image dictionary.");l=i.width;Q=i.height}if(l<1||Q<1)throw new FormatError(`Invalid image width: ${l} or height: ${Q}`);this.width=l;this.height=Q;this.interpolate=c.get("I","Interpolate");this.imageMask=c.get("IM","ImageMask")||!1;this.matte=c.get("Matte")||!1;let E=i.bitsPerComponent;if(!E){E=c.get("BPC","BitsPerComponent");if(!E){if(!this.imageMask)throw new FormatError(`Bits per component missing in image: ${this.imageMask}`);E=1}}this.bpc=E;if(!this.imageMask){let s=c.getRaw("CS")||c.getRaw("ColorSpace");const r=!!s;if(r)this.jpxDecoderOptions?.smaskInData&&(s=Name.get("DeviceRGBA"));else if(this.jpxDecoderOptions)s=Name.get("DeviceRGBA");else switch(i.numComps){case 1:s=Name.get("DeviceGray");break;case 3:s=Name.get("DeviceRGB");break;case 4:s=Name.get("DeviceCMYK");break;default:throw new Error(`Images with ${i.numComps} color components not supported.`)}this.colorSpace=ColorSpace.parse({cs:s,xref:e,resources:a?t:null,pdfFunctionFactory:g,localColorSpaceCache:o});this.numComps=this.colorSpace.numComps;if(this.jpxDecoderOptions){this.jpxDecoderOptions.numComponents=r?this.numComp:0;this.jpxDecoderOptions.isIndexedColormap="Indexed"===this.colorSpace.name}}this.decode=c.getArray("D","Decode");this.needsDecode=!1;if(this.decode&&(this.colorSpace&&!this.colorSpace.isDefaultDecode(this.decode,E)||n&&!ColorSpace.isDefaultDecode(this.decode,1))){this.needsDecode=!0;const e=(1<>3)*i,g=e.byteLength;let o,c;if(!a||s&&!(n===g))if(s){o=new Uint8Array(n);o.set(e);o.fill(255,g)}else o=new Uint8Array(e);else o=e;if(s)for(c=0;c>7&1;n[l+1]=h>>6&1;n[l+2]=h>>5&1;n[l+3]=h>>4&1;n[l+4]=h>>3&1;n[l+5]=h>>2&1;n[l+6]=h>>1&1;n[l+7]=1&h;l+=8}if(l>=1}}}}else{let i=0;h=0;for(l=0,C=r;l>a;s<0?s=0:s>c&&(s=c);n[l]=s;h&=(1<n[a+1]){t=255;break}}g[C]=t}}}if(g)for(C=0,l=3,h=t*a;C>3,C=t&&ImageResizer.needsToBeResized(i,a);if(!this.smask&&!this.mask&&"DeviceRGBA"===this.colorSpace.name){s.kind=S;const e=s.data=await this.getImageBytes(g*n*4,{});return t?C?ImageResizer.createImage(s,!1):this.createBitmap(S,i,a,e):s}if(!e){let e;"DeviceGray"===this.colorSpace.name&&1===o?e=b:"DeviceRGB"!==this.colorSpace.name||8!==o||this.needsDecode||(e=F);if(e&&!this.smask&&!this.mask&&i===n&&a===g){const r=await this.#G(n,g);if(r)return r;const o=await this.getImageBytes(g*c,{});if(t)return C?ImageResizer.createImage({data:o,kind:e,width:i,height:a,interpolate:this.interpolate},this.needsDecode):this.createBitmap(e,n,g,o);s.kind=e;s.data=o;if(this.needsDecode){assert(e===b,"PDFImage.createImageData: The image must be grayscale.");const t=s.data;for(let e=0,i=t.length;e>3,n=await this.getImageBytes(a*r,{internal:!0}),g=this.getComponents(n);let o,c;if(1===s){c=i*a;if(this.needsDecode)for(o=0;o0&&t.args[0].count++}class TimeSlotManager{static TIME_SLOT_DURATION_MS=20;static CHECK_TIME_EVERY=100;constructor(){this.reset()}check(){if(++this.checkedh){const e="Image exceeded maximum allowed size and was removed.";if(this.options.ignoreErrors){warn(e);return}throw new Error(e)}let l;g.has("OC")&&(l=await this.parseMarkedContentProps(g.get("OC"),e));let Q,E;if(g.get("IM","ImageMask")||!1){const e=g.get("I","Interpolate"),i=c+7>>3,n=t.getBytes(i*C),h=g.getArray("D","Decode");if(this.parsingType3Font){Q=PDFImage.createRawMask({imgArray:n,width:c,height:C,imageIsFromDecodeStream:t instanceof DecodeStream,inverseDecode:h?.[0]>0,interpolate:e});Q.cached=!!s;E=[Q];a.addImageOps(Ze,E,l);if(s){const e={fn:Ze,args:E,optionalContent:l};r.set(s,o,e);o&&this._regionalImageCache.set(null,o,e)}return}Q=await PDFImage.createMask({imgArray:n,width:c,height:C,imageIsFromDecodeStream:t instanceof DecodeStream,inverseDecode:h?.[0]>0,interpolate:e,isOffscreenCanvasSupported:this.options.isOffscreenCanvasSupported});if(Q.isSingleOpaquePixel){a.addImageOps(tt,[],l);if(s){const e={fn:tt,args:[],optionalContent:l};r.set(s,o,e);o&&this._regionalImageCache.set(null,o,e)}return}const u=`mask_${this.idFactory.createObjId()}`;a.addDependency(u);Q.dataLen=Q.bitmap?Q.width*Q.height*4:Q.data.length;this._sendImgData(u,Q);E=[{data:u,width:Q.width,height:Q.height,interpolate:Q.interpolate,count:1}];a.addImageOps(Ze,E,l);if(s){const e={objId:u,fn:Ze,args:E,optionalContent:l};r.set(s,o,e);o&&this._regionalImageCache.set(null,o,e)}return}const u=g.has("SMask")||g.has("Mask");if(i&&c+C<200&&!u){try{const s=new PDFImage({xref:this.xref,res:e,image:t,isInline:i,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:n});Q=await s.createImageData(!0,!1);a.isOffscreenCanvasSupported=this.options.isOffscreenCanvasSupported;a.addImageOps(_e,[Q],l)}catch(e){const t=`Unable to decode inline image: "${e}".`;if(!this.options.ignoreErrors)throw new Error(t);warn(t)}return}let d=`img_${this.idFactory.createObjId()}`,f=!1;if(this.parsingType3Font)d=`${this.idFactory.getDocId()}_type3_${d}`;else if(s&&o){f=this.globalImageCache.shouldCache(o,this.pageIndex);if(f){assert(!i,"Cannot cache an inline image globally.");d=`${this.idFactory.getDocId()}_${d}`}}a.addDependency(d);E=[d,c,C];a.addImageOps(ze,E,l,u);if(f){if(this.globalImageCache.hasDecodeFailed(o)){this.globalImageCache.setData(o,{objId:d,fn:ze,args:E,optionalContent:l,hasMask:u,byteSize:0});this._sendImgData(d,null,f);return}if(c*C>25e4||u){const e=await this.handler.sendWithPromise("commonobj",[d,"CopyLocalImage",{imageRef:o}]);if(e){this.globalImageCache.setData(o,{objId:d,fn:ze,args:E,optionalContent:l,hasMask:u,byteSize:0});this.globalImageCache.addByteSize(o,e);return}}}PDFImage.buildImage({xref:this.xref,res:e,image:t,isInline:i,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:n}).then((async e=>{Q=await e.createImageData(!1,this.options.isOffscreenCanvasSupported);Q.dataLen=Q.bitmap?Q.width*Q.height*4:Q.data.length;Q.ref=o;f&&this.globalImageCache.addByteSize(o,Q.dataLen);return this._sendImgData(d,Q,f)})).catch((e=>{warn(`Unable to decode image "${d}": "${e}".`);o&&this.globalImageCache.addDecodeFailed(o);return this._sendImgData(d,null,f)}));if(s){const e={objId:d,fn:ze,args:E,optionalContent:l,hasMask:u};r.set(s,o,e);if(o){this._regionalImageCache.set(null,o,e);f&&this.globalImageCache.setData(o,{objId:d,fn:ze,args:E,optionalContent:l,hasMask:u,byteSize:0})}}}handleSMask(e,t,i,a,s,r){const n=e.get("G"),g={subtype:e.get("S").name,backdrop:e.get("BC")},o=e.get("TR");if(isPDFFunction(o)){const e=this._pdfFunctionFactory.create(o),t=new Uint8Array(256),i=new Float32Array(1);for(let a=0;a<256;a++){i[0]=a/255;e(i,0,i,0);t[a]=255*i[0]|0}g.transferMap=t}return this.buildFormXObject(t,n,g,i,a,s.state.clone(),r)}handleTransferFunction(e){let t;if(Array.isArray(e))t=e;else{if(!isPDFFunction(e))return null;t=[e]}const i=[];let a=0,s=0;for(const e of t){const t=this.xref.fetchIfRef(e);a++;if(isName(t,"Identity")){i.push(null);continue}if(!isPDFFunction(t))return null;const r=this._pdfFunctionFactory.create(t),n=new Uint8Array(256),g=new Float32Array(1);for(let e=0;e<256;e++){g[0]=e/255;r(g,0,g,0);n[e]=255*g[0]|0}i.push(n);s++}return 1!==a&&4!==a||0===s?null:i}handleTilingType(e,t,i,a,s,r,n,g){const o=new OperatorList,c=Dict.merge({xref:this.xref,dictArray:[s.get("Resources"),i]});return this.getOperatorList({stream:a,task:n,resources:c,operatorList:o}).then((function(){const i=o.getIR(),a=getTilingPatternIR(i,s,t);r.addDependencies(o.dependencies);r.addOp(e,a);s.objId&&g.set(null,s.objId,{operatorListIR:i,dict:s})})).catch((e=>{if(!(e instanceof AbortException)){if(!this.options.ignoreErrors)throw e;warn(`handleTilingType - ignoring pattern: "${e}".`)}}))}async handleSetFont(e,t,i,a,s,r,n=null,g=null){const o=t?.[0]instanceof Name?t[0].name:null;let c=await this.loadFont(o,i,e,n,g);if(c.font.isType3Font)try{await c.loadType3Data(this,e,s);a.addDependencies(c.type3Dependencies)}catch(e){c=new TranslatedFont({loadedName:"g_font_error",font:new ErrorFont(`Type3 font load error: ${e}`),dict:c.font,evaluatorOptions:this.options})}r.font=c.font;c.send(this.handler);return c.loadedName}handleText(e,t){const i=t.font,a=i.charsToGlyphs(e);if(i.data){(!!(t.textRenderingMode&D)||"Pattern"===t.fillColorSpace.name||i.disableFontFace||this.options.disableFontFace)&&PartialEvaluator.buildFontPaths(i,a,this.handler,this.options)}return a}ensureStateFont(e){if(e.font)return;const t=new FormatError("Missing setFont (Tf) operator before text rendering operator.");if(!this.options.ignoreErrors)throw t;warn(`ensureStateFont: "${t}".`)}async setGState({resources:e,gState:t,operatorList:i,cacheKey:a,task:s,stateManager:r,localGStateCache:n,localColorSpaceCache:g}){const o=t.objId;let c=!0;const C=[];let h=Promise.resolve();for(const a of t.getKeys()){const n=t.get(a);switch(a){case"Type":break;case"LW":case"LC":case"LJ":case"ML":case"D":case"RI":case"FL":case"CA":case"ca":C.push([a,n]);break;case"Font":c=!1;h=h.then((()=>this.handleSetFont(e,null,n[0],i,s,r.state).then((function(e){i.addDependency(e);C.push([a,[e,n[1]]])}))));break;case"BM":C.push([a,normalizeBlendMode(n)]);break;case"SMask":if(isName(n,"None")){C.push([a,!1]);break}if(n instanceof Dict){c=!1;h=h.then((()=>this.handleSMask(n,e,i,s,r,g)));C.push([a,!0])}else warn("Unsupported SMask type");break;case"TR":const t=this.handleTransferFunction(n);C.push([a,t]);break;case"OP":case"op":case"OPM":case"BG":case"BG2":case"UCR":case"UCR2":case"TR2":case"HT":case"SM":case"SA":case"AIS":case"TK":info("graphic state operator "+a);break;default:info("Unknown graphic state operator "+a)}}await h;C.length>0&&i.addOp(GA,[C]);c&&n.set(a,o,C)}loadFont(e,t,i,a=null,s=null){const errorFont=async()=>new TranslatedFont({loadedName:"g_font_error",font:new ErrorFont(`Font "${e}" is not available.`),dict:t,evaluatorOptions:this.options});let r;if(t)t instanceof Ref&&(r=t);else{const t=i.get("Font");t&&(r=t.getRaw(e))}if(r){if(this.type3FontRefs?.has(r))return errorFont();if(this.fontCache.has(r))return this.fontCache.get(r);try{t=this.xref.fetchIfRef(r)}catch(e){warn(`loadFont - lookup failed: "${e}".`)}}if(!(t instanceof Dict)){if(!this.options.ignoreErrors&&!this.parsingType3Font){warn(`Font "${e}" is not available.`);return errorFont()}warn(`Font "${e}" is not available -- attempting to fallback to a default font.`);t=a||PartialEvaluator.fallbackFontDict}if(t.cacheKey&&this.fontCache.has(t.cacheKey))return this.fontCache.get(t.cacheKey);const{promise:n,resolve:g}=Promise.withResolvers();let o;try{o=this.preEvaluateFont(t);o.cssFontInfo=s}catch(e){warn(`loadFont - preEvaluateFont failed: "${e}".`);return errorFont()}const{descriptor:c,hash:C}=o,h=r instanceof Ref;let l;if(C&&c instanceof Dict){const e=c.fontAliases||=Object.create(null);if(e[C]){const t=e[C].aliasRef;if(h&&t&&this.fontCache.has(t)){this.fontCache.putAlias(r,t);return this.fontCache.get(r)}}else e[C]={fontID:this.idFactory.createFontId()};h&&(e[C].aliasRef=r);l=e[C].fontID}else l=this.idFactory.createFontId();assert(l?.startsWith("f"),'The "fontID" must be (correctly) defined.');if(h)this.fontCache.put(r,n);else{t.cacheKey=`cacheKey_${l}`;this.fontCache.put(t.cacheKey,n)}t.loadedName=`${this.idFactory.getDocId()}_${l}`;this.translateFont(o).then((e=>{g(new TranslatedFont({loadedName:t.loadedName,font:e,dict:t,evaluatorOptions:this.options}))})).catch((e=>{warn(`loadFont - translateFont failed: "${e}".`);g(new TranslatedFont({loadedName:t.loadedName,font:new ErrorFont(e instanceof Error?e.message:e),dict:t,evaluatorOptions:this.options}))}));return n}buildPath(e,t,i,a=!1){const s=e.length-1;i||(i=[]);if(s<0||e.fnArray[s]!==it){if(a){warn(`Encountered path operator "${t}" inside of a text object.`);e.addOp(MA,null)}let s;switch(t){case TA:const e=i[0]+i[2],t=i[1]+i[3];s=[Math.min(i[0],e),Math.min(i[1],t),Math.max(i[0],e),Math.max(i[1],t)];break;case LA:case HA:s=[i[0],i[1],i[0],i[1]];break;default:s=[1/0,1/0,-1/0,-1/0]}e.addOp(it,[[t],i,s]);a&&e.addOp(UA,null)}else{const a=e.argsArray[s];a[0].push(t);a[1].push(...i);const r=a[2];switch(t){case TA:const e=i[0]+i[2],t=i[1]+i[3];r[0]=Math.min(r[0],i[0],e);r[1]=Math.min(r[1],i[1],t);r[2]=Math.max(r[2],i[0],e);r[3]=Math.max(r[3],i[1],t);break;case LA:case HA:r[0]=Math.min(r[0],i[0]);r[1]=Math.min(r[1],i[1]);r[2]=Math.max(r[2],i[0]);r[3]=Math.max(r[3],i[1])}}}parseColorSpace({cs:e,resources:t,localColorSpaceCache:i}){return ColorSpace.parseAsync({cs:e,xref:this.xref,resources:t,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:i}).catch((e=>{if(e instanceof AbortException)return null;if(this.options.ignoreErrors){warn(`parseColorSpace - ignoring ColorSpace: "${e}".`);return null}throw e}))}parseShading({shading:e,resources:t,localColorSpaceCache:i,localShadingPatternCache:a}){let s,r=a.get(e);if(r)return r;try{s=Pattern.parseShading(e,this.xref,t,this._pdfFunctionFactory,i).getIR()}catch(t){if(t instanceof AbortException)return null;if(this.options.ignoreErrors){warn(`parseShading - ignoring shading: "${t}".`);a.set(e,null);return null}throw t}r=`pattern_${this.idFactory.createObjId()}`;this.parsingType3Font&&(r=`${this.idFactory.getDocId()}_type3_${r}`);a.set(e,r);this.parsingType3Font?this.handler.send("commonobj",[r,"Pattern",s]):this.handler.send("obj",[r,this.pageIndex,"Pattern",s]);return r}handleColorN(e,t,i,a,s,r,n,g,o,c){const C=i.pop();if(C instanceof Name){const h=s.getRaw(C.name),l=h instanceof Ref&&o.getByRef(h);if(l)try{const s=a.base?a.base.getRgb(i,0):null,r=getTilingPatternIR(l.operatorListIR,l.dict,s);e.addOp(t,r);return}catch{}const Q=this.xref.fetchIfRef(h);if(Q){const s=Q instanceof BaseStream?Q.dict:Q,C=s.get("PatternType");if(C===fs){const g=a.base?a.base.getRgb(i,0):null;return this.handleTilingType(t,g,r,Q,s,e,n,o)}if(C===ps){const i=s.get("Shading"),a=this.parseShading({shading:i,resources:r,localColorSpaceCache:g,localShadingPatternCache:c});if(a){const i=lookupMatrix(s.getArray("Matrix"),null);e.addOp(t,["Shading",a,i])}return}throw new FormatError(`Unknown PatternType: ${C}`)}}throw new FormatError(`Unknown PatternName: ${C}`)}_parseVisibilityExpression(e,t,i){if(++t>10){warn("Visibility expression is too deeply nested");return}const a=e.length,s=this.xref.fetchIfRef(e[0]);if(!(a<2)&&s instanceof Name){switch(s.name){case"And":case"Or":case"Not":i.push(s.name);break;default:warn(`Invalid operator ${s.name} in visibility expression`);return}for(let s=1;s0)return{type:"OCMD",expression:t}}const t=i.get("OCGs");if(Array.isArray(t)||t instanceof Dict){const e=[];if(Array.isArray(t))for(const i of t)e.push(i.toString());else e.push(t.objId);return{type:a,ids:e,policy:i.get("P")instanceof Name?i.get("P").name:null,expression:null}}if(t instanceof Ref)return{type:a,id:t.toString()}}return null}getOperatorList({stream:e,task:t,resources:i,operatorList:a,initialState:s=null,fallbackFontDict:r=null}){i||=Dict.empty;s||=new EvalState;if(!a)throw new Error('getOperatorList: missing "operatorList" parameter');const n=this,g=this.xref;let o=!1;const c=new LocalImageCache,C=new LocalColorSpaceCache,h=new LocalGStateCache,l=new LocalTilingPatternCache,Q=new Map,E=i.get("XObject")||Dict.empty,u=i.get("Pattern")||Dict.empty,d=new StateManager(s),f=new EvaluatorPreprocessor(e,g,d),p=new TimeSlotManager;function closePendingRestoreOPS(e){for(let e=0,t=f.savedStatesDepth;e0&&a.addOp(GA,[t]);e=null;continue}}next(new Promise((function(e,s){if(!S)throw new FormatError("GState must be referred to by name.");const r=i.get("ExtGState");if(!(r instanceof Dict))throw new FormatError("ExtGState should be a dictionary.");const g=r.get(F);if(!(g instanceof Dict))throw new FormatError("GState should be a dictionary.");n.setGState({resources:i,gState:g,operatorList:a,cacheKey:F,task:t,stateManager:d,localGStateCache:h,localColorSpaceCache:C}).then(e,s)})).catch((function(e){if(!(e instanceof AbortException)){if(!n.options.ignoreErrors)throw e;warn(`getOperatorList - ignoring ExtGState: "${e}".`)}})));return;case LA:case HA:case JA:case YA:case vA:case KA:case TA:n.buildPath(a,s,e,o);continue;case Le:case He:case Ke:case Te:continue;case Ye:if(!(e[0]instanceof Name)){warn(`Expected name for beginMarkedContentProps arg0=${e[0]}`);a.addOp(Ye,["OC",null]);continue}if("OC"===e[0].name){next(n.parseMarkedContentProps(e[1],i).then((e=>{a.addOp(Ye,["OC",e])})).catch((e=>{if(!(e instanceof AbortException)){if(!n.options.ignoreErrors)throw e;warn(`getOperatorList - ignoring beginMarkedContentProps: "${e}".`);a.addOp(Ye,["OC",null])}})));return}e=[e[0].name,e[1]instanceof Dict?e[1].get("MCID"):null];break;default:if(null!==e){for(w=0,D=e.length;w{if(!(e instanceof AbortException)){if(!this.options.ignoreErrors)throw e;warn(`getOperatorList - ignoring errors during "${t.name}" task: "${e}".`);closePendingRestoreOPS()}}))}getTextContent({stream:e,task:t,resources:s,stateManager:r=null,includeMarkedContent:n=!1,sink:g,seenStyles:o=new Set,viewBox:c,lang:C=null,markedContentData:h=null,disableNormalization:l=!1,keepWhiteSpace:Q=!1}){s||=Dict.empty;r||=new StateManager(new TextState);n&&(h||={level:0});const E={items:[],styles:Object.create(null),lang:C},u={initialized:!1,str:[],totalWidth:0,totalHeight:0,width:0,height:0,vertical:!1,prevTransform:null,textAdvanceScale:0,spaceInFlowMin:0,spaceInFlowMax:0,trackingSpaceMin:1/0,negativeSpaceMax:-1/0,notASpace:-1/0,transform:null,fontName:null,hasEOL:!1},d=[" "," "];let f=0;function saveLastChar(e){const t=(f+1)%2,i=" "!==d[f]&&" "===d[t];d[f]=e;f=t;return!Q&&i}function shouldAddWhitepsace(){return!Q&&" "!==d[f]&&" "===d[(f+1)%2]}function resetLastChars(){d[0]=d[1]=" ";f=0}const p=this,m=this.xref,y=[];let w=null;const D=new LocalImageCache,b=new LocalGStateCache,F=new EvaluatorPreprocessor(e,m,r);let S;function pushWhitespace({width:e=0,height:t=0,transform:i=u.prevTransform,fontName:a=u.fontName}){E.items.push({str:" ",dir:"ltr",width:e,height:t,transform:i,fontName:a,hasEOL:!1})}function getCurrentTextTransform(){const e=S.font,t=[S.fontSize*S.textHScale,0,0,S.fontSize,0,S.textRise];if(e.isType3Font&&(S.fontSize<=1||e.isCharBBox)&&!isArrayEqual(S.fontMatrix,a)){const i=e.bbox[3]-e.bbox[1];i>0&&(t[3]*=i*S.fontMatrix[3])}return Util.transform(S.ctm,Util.transform(S.textMatrix,t))}function ensureTextContentItem(){if(u.initialized)return u;const{font:e,loadedName:t}=S;if(!o.has(t)){o.add(t);E.styles[t]={fontFamily:e.fallbackName,ascent:e.ascent,descent:e.descent,vertical:e.vertical};if(p.options.fontExtraProperties&&e.systemFontInfo){const i=E.styles[t];i.fontSubstitution=e.systemFontInfo.css;i.fontSubstitutionLoadedName=e.systemFontInfo.loadedName}}u.fontName=t;const i=u.transform=getCurrentTextTransform();if(e.vertical){u.width=u.totalWidth=Math.hypot(i[0],i[1]);u.height=u.totalHeight=0;u.vertical=!0}else{u.width=u.totalWidth=0;u.height=u.totalHeight=Math.hypot(i[2],i[3]);u.vertical=!1}const a=Math.hypot(S.textLineMatrix[0],S.textLineMatrix[1]),s=Math.hypot(S.ctm[0],S.ctm[1]);u.textAdvanceScale=s*a;const{fontSize:r}=S;u.trackingSpaceMin=.102*r;u.notASpace=.03*r;u.negativeSpaceMax=-.2*r;u.spaceInFlowMin=.102*r;u.spaceInFlowMax=.6*r;u.hasEOL=!1;u.initialized=!0;return u}function updateAdvanceScale(){if(!u.initialized)return;const e=Math.hypot(S.textLineMatrix[0],S.textLineMatrix[1]),t=Math.hypot(S.ctm[0],S.ctm[1])*e;if(t!==u.textAdvanceScale){if(u.vertical){u.totalHeight+=u.height*u.textAdvanceScale;u.height=0}else{u.totalWidth+=u.width*u.textAdvanceScale;u.width=0}u.textAdvanceScale=t}}function runBidiTransform(e){let t=e.str.join("");l||(t=function normalizeUnicode(e){if(!Ct){Ct=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;ht=new Map([["ſt","ſt"]])}return e.replaceAll(Ct,((e,t,i)=>t?t.normalize("NFKC"):ht.get(i)))}(t));const i=bidi(t,-1,e.vertical);return{str:i.str,dir:i.dir,width:Math.abs(e.totalWidth),height:Math.abs(e.totalHeight),transform:e.transform,fontName:e.fontName,hasEOL:e.hasEOL}}async function handleSetFont(e,i){const r=await p.loadFont(e,i,s);if(r.font.isType3Font)try{await r.loadType3Data(p,s,t)}catch{}S.loadedName=r.loadedName;S.font=r.font;S.fontMatrix=r.font.fontMatrix||a}function applyInverseRotation(e,t,i){const a=Math.hypot(i[0],i[1]);return[(i[0]*e+i[1]*t)/a,(i[2]*e+i[3]*t)/a]}function compareWithLastPosition(e){const t=getCurrentTextTransform();let i=t[4],a=t[5];if(S.font?.vertical){if(ic[2]||a+ec[3])return!1}else if(i+ec[2]||ac[3])return!1;if(!S.font||!u.prevTransform)return!0;let s=u.prevTransform[4],r=u.prevTransform[5];if(s===i&&r===a)return!0;let n=-1;t[0]&&0===t[1]&&0===t[2]?n=t[0]>0?0:180:t[1]&&0===t[0]&&0===t[3]&&(n=t[1]>0?90:270);switch(n){case 0:break;case 90:[i,a]=[a,i];[s,r]=[r,s];break;case 180:[i,a,s,r]=[-i,-a,-s,-r];break;case 270:[i,a]=[-a,-i];[s,r]=[-r,-s];break;default:[i,a]=applyInverseRotation(i,a,t);[s,r]=applyInverseRotation(s,r,u.prevTransform)}if(S.font.vertical){const e=(r-a)/u.textAdvanceScale,t=i-s,n=Math.sign(u.height);if(e.5*u.width){appendEOL();return!0}resetLastChars();flushTextContentItem();return!0}if(Math.abs(t)>u.width){appendEOL();return!0}e<=n*u.notASpace&&resetLastChars();if(e<=n*u.trackingSpaceMin)if(shouldAddWhitepsace()){resetLastChars();flushTextContentItem();pushWhitespace({height:Math.abs(e)})}else u.height+=e;else if(!addFakeSpaces(e,u.prevTransform,n))if(0===u.str.length){resetLastChars();pushWhitespace({height:Math.abs(e)})}else u.height+=e;Math.abs(t)>.25*u.width&&flushTextContentItem();return!0}const g=(i-s)/u.textAdvanceScale,o=a-r,C=Math.sign(u.width);if(g.5*u.height){appendEOL();return!0}resetLastChars();flushTextContentItem();return!0}if(Math.abs(o)>u.height){appendEOL();return!0}g<=C*u.notASpace&&resetLastChars();if(g<=C*u.trackingSpaceMin)if(shouldAddWhitepsace()){resetLastChars();flushTextContentItem();pushWhitespace({width:Math.abs(g)})}else u.width+=g;else if(!addFakeSpaces(g,u.prevTransform,C))if(0===u.str.length){resetLastChars();pushWhitespace({width:Math.abs(g)})}else u.width+=g;Math.abs(o)>.25*u.height&&flushTextContentItem();return!0}function buildTextContentItem({chars:e,extraSpacing:t}){const i=S.font;if(!e){const e=S.charSpacing+t;e&&(i.vertical?S.translateTextMatrix(0,-e):S.translateTextMatrix(e*S.textHScale,0));Q&&compareWithLastPosition(0);return}const a=i.charsToGlyphs(e),s=S.fontMatrix[0]*S.fontSize;for(let e=0,r=a.length;e0){const e=y.join("");y.length=0;buildTextContentItem({chars:e,extraSpacing:0})}break;case he:if(!r.state.font){p.ensureStateFont(r.state);continue}buildTextContentItem({chars:N[0],extraSpacing:0});break;case Be:if(!r.state.font){p.ensureStateFont(r.state);continue}S.carriageReturn();buildTextContentItem({chars:N[0],extraSpacing:0});break;case Qe:if(!r.state.font){p.ensureStateFont(r.state);continue}S.wordSpacing=N[0];S.charSpacing=N[1];S.carriageReturn();buildTextContentItem({chars:N[2],extraSpacing:0});break;case xe:flushTextContentItem();w??=s.get("XObject")||Dict.empty;R=N[0]instanceof Name;f=N[0].name;if(R&&D.getByName(f))break;next(new Promise((function(e,i){if(!R)throw new FormatError("XObject must be referred to by name.");let a=w.getRaw(f);if(a instanceof Ref){if(D.getByRef(a)){e();return}if(p.globalImageCache.getData(a,p.pageIndex)){e();return}a=m.fetch(a)}if(!(a instanceof BaseStream))throw new FormatError("XObject should be a stream");const E=a.dict.get("Subtype");if(!(E instanceof Name))throw new FormatError("XObject should have a Name subtype");if("Form"!==E.name){D.set(f,a.dict.objId,!0);e();return}const u=r.state.clone(),d=new StateManager(u),y=lookupMatrix(a.dict.getArray("Matrix"),null);y&&d.transform(y);enqueueChunk();const b={enqueueInvoked:!1,enqueue(e,t){this.enqueueInvoked=!0;g.enqueue(e,t)},get desiredSize(){return g.desiredSize},get ready(){return g.ready}};p.getTextContent({stream:a,task:t,resources:a.dict.get("Resources")||s,stateManager:d,includeMarkedContent:n,sink:b,seenStyles:o,viewBox:c,lang:C,markedContentData:h,disableNormalization:l,keepWhiteSpace:Q}).then((function(){b.enqueueInvoked||D.set(f,a.dict.objId,!0);e()}),i)})).catch((function(e){if(!(e instanceof AbortException)){if(!p.options.ignoreErrors)throw e;warn(`getTextContent - ignoring XObject: "${e}".`)}})));return;case GA:R=N[0]instanceof Name;f=N[0].name;if(R&&b.getByName(f))break;next(new Promise((function(e,t){if(!R)throw new FormatError("GState must be referred to by name.");const i=s.get("ExtGState");if(!(i instanceof Dict))throw new FormatError("ExtGState should be a dictionary.");const a=i.get(f);if(!(a instanceof Dict))throw new FormatError("GState should be a dictionary.");const r=a.get("Font");if(r){flushTextContentItem();S.fontName=null;S.fontSize=r[1];handleSetFont(null,r[0]).then(e,t)}else{b.set(f,a.objId,!0);e()}})).catch((function(e){if(!(e instanceof AbortException)){if(!p.options.ignoreErrors)throw e;warn(`getTextContent - ignoring ExtGState: "${e}".`)}})));return;case Je:flushTextContentItem();if(n){h.level++;E.items.push({type:"beginMarkedContent",tag:N[0]instanceof Name?N[0].name:null})}break;case Ye:flushTextContentItem();if(n){h.level++;let e=null;N[1]instanceof Dict&&(e=N[1].get("MCID"));E.items.push({type:"beginMarkedContentProps",id:Number.isInteger(e)?`${p.idFactory.getPageObjId()}_mc${e}`:null,tag:N[0]instanceof Name?N[0].name:null})}break;case ve:flushTextContentItem();if(n){if(0===h.level)break;h.level--;E.items.push({type:"endMarkedContent"})}break;case UA:!e||e.font===S.font&&e.fontSize===S.fontSize&&e.fontName===S.fontName||flushTextContentItem()}if(E.items.length>=g.desiredSize){d=!0;break}}if(d)next(ms);else{flushTextContentItem();enqueueChunk();e()}})).catch((e=>{if(!(e instanceof AbortException)){if(!this.options.ignoreErrors)throw e;warn(`getTextContent - ignoring errors during "${t.name}" task: "${e}".`);flushTextContentItem();enqueueChunk()}}))}async extractDataStructures(e,t){const i=this.xref;let a;const s=this.readToUnicode(t.toUnicode);if(t.composite){const i=e.get("CIDSystemInfo");i instanceof Dict&&(t.cidSystemInfo={registry:stringToPDFString(i.get("Registry")),ordering:stringToPDFString(i.get("Ordering")),supplement:i.get("Supplement")});try{const t=e.get("CIDToGIDMap");t instanceof BaseStream&&(a=t.getBytes())}catch(e){if(!this.options.ignoreErrors)throw e;warn(`extractDataStructures - ignoring CIDToGIDMap data: "${e}".`)}}const r=[];let n,g=null;if(e.has("Encoding")){n=e.get("Encoding");if(n instanceof Dict){g=n.get("BaseEncoding");g=g instanceof Name?g.name:null;if(n.has("Differences")){const e=n.get("Differences");let t=0;for(const a of e){const e=i.fetchIfRef(a);if("number"==typeof e)t=e;else{if(!(e instanceof Name))throw new FormatError(`Invalid entry in 'Differences' array: ${e}`);r[t++]=e.name}}}}else if(n instanceof Name)g=n.name;else{const e="Encoding is not a Name nor a Dict";if(!this.options.ignoreErrors)throw new FormatError(e);warn(e)}"MacRomanEncoding"!==g&&"MacExpertEncoding"!==g&&"WinAnsiEncoding"!==g&&(g=null)}const o=!t.file||t.isInternalFont,c=qi()[t.name];g&&o&&c&&(g=null);if(g)t.defaultEncoding=getEncoding(g);else{const e=!!(t.flags&Mi),i=!!(t.flags&xi);n=hi;"TrueType"!==t.type||i||(n=li);if(e||c){n=Ci;o&&(/Symbol/i.test(t.name)?n=Bi:/Dingbats/i.test(t.name)?n=Qi:/Wingdings/i.test(t.name)&&(n=li))}t.defaultEncoding=n}t.differences=r;t.baseEncodingName=g;t.hasEncoding=!!g||r.length>0;t.dict=e;t.toUnicode=await s;const C=await this.buildToUnicode(t);t.toUnicode=C;a&&(t.cidToGidMap=this.readCidToGidMap(a,C));return t}_simpleFontToUnicode(e,t=!1){assert(!e.composite,"Must be a simple font.");const i=[],a=e.defaultEncoding.slice(),s=e.baseEncodingName,r=e.differences;for(const e in r){const t=r[e];".notdef"!==t&&(a[e]=t)}const n=wi();for(const r in a){let g=a[r];if(""===g)continue;let o=n[g];if(void 0!==o){i[r]=String.fromCharCode(o);continue}let c=0;switch(g[0]){case"G":3===g.length&&(c=parseInt(g.substring(1),16));break;case"g":5===g.length&&(c=parseInt(g.substring(1),16));break;case"C":case"c":if(g.length>=3&&g.length<=4){const i=g.substring(1);if(t){c=parseInt(i,16);break}c=+i;if(Number.isNaN(c)&&Number.isInteger(parseInt(i,16)))return this._simpleFontToUnicode(e,!0)}break;case"u":o=getUnicodeForGlyph(g,n);-1!==o&&(c=o);break;default:switch(g){case"f_h":case"f_t":case"T_h":i[r]=g.replaceAll("_","");continue}}if(c>0&&c<=1114111&&Number.isInteger(c)){if(s&&c===+r){const e=getEncoding(s);if(e&&(g=e[r])){i[r]=String.fromCharCode(n[g]);continue}}i[r]=String.fromCodePoint(c)}}return i}async buildToUnicode(e){e.hasIncludedToUnicodeMap=e.toUnicode?.length>0;if(e.hasIncludedToUnicodeMap){!e.composite&&e.hasEncoding&&(e.fallbackToUnicode=this._simpleFontToUnicode(e));return e.toUnicode}if(!e.composite)return new ToUnicodeMap(this._simpleFontToUnicode(e));if(e.composite&&(e.cMap.builtInCMap&&!(e.cMap instanceof IdentityCMap)||"Adobe"===e.cidSystemInfo?.registry&&("GB1"===e.cidSystemInfo.ordering||"CNS1"===e.cidSystemInfo.ordering||"Japan1"===e.cidSystemInfo.ordering||"Korea1"===e.cidSystemInfo.ordering))){const{registry:t,ordering:i}=e.cidSystemInfo,a=Name.get(`${t}-${i}-UCS2`),s=await CMapFactory.create({encoding:a,fetchBuiltInCMap:this._fetchBuiltInCMapBound,useCMap:null}),r=[],n=[];e.cMap.forEach((function(e,t){if(t>65535)throw new FormatError("Max size of CID is 65,535");const i=s.lookup(t);if(i){n.length=0;for(let e=0,t=i.length;e>1;(0!==s||t.has(r))&&(i[r]=s)}return i}extractWidths(e,t,i){const a=this.xref;let s=[],r=0;const n=[];let g;if(i.composite){const t=e.get("DW");r="number"==typeof t?Math.ceil(t):1e3;const o=e.get("W");if(Array.isArray(o))for(let e=0,t=o.length;e{const t=o.get(e),s=new OperatorList;return a.getOperatorList({stream:t,task:i,resources:c,operatorList:s}).then((()=>{s.fnArray[0]===ue&&this._removeType3ColorOperators(s,E);C[e]=s.getIR();for(const e of s.dependencies)n.add(e)})).catch((function(t){warn(`Type3 font resource "${e}" is not available.`);const i=new OperatorList;C[e]=i.getIR()}))}));this.type3Loaded=g.then((()=>{r.charProcOperatorList=C;if(this._bbox){r.isCharBBox=!0;r.bbox=this._bbox}}));return this.type3Loaded}_removeType3ColorOperators(e,t=NaN){const i=Util.normalizeRect(e.argsArray[0].slice(2)),a=i[2]-i[0],s=i[3]-i[1],r=Math.hypot(a,s);if(0===a||0===s){e.fnArray.splice(0,1);e.argsArray.splice(0,1)}else if(0===t||Math.round(r/t)>=10){this._bbox||(this._bbox=[1/0,1/0,-1/0,-1/0]);this._bbox[0]=Math.min(this._bbox[0],i[0]);this._bbox[1]=Math.min(this._bbox[1],i[1]);this._bbox[2]=Math.max(this._bbox[2],i[2]);this._bbox[3]=Math.max(this._bbox[3],i[3])}let n=0,g=e.length;for(;n=LA&&r<=zA;if(s.variableArgs)g>n&&info(`Command ${a}: expected [0, ${n}] args, but received ${g} args.`);else{if(g!==n){const e=this.nonProcessedArgs;for(;g>n;){e.push(t.shift());g--}for(;gEvaluatorPreprocessor.MAX_INVALID_PATH_OPS)throw new FormatError(`Invalid ${e}`);warn(`Skipping ${e}`);null!==t&&(t.length=0);continue}}this.preprocessCommand(r,t);e.fn=r;e.args=t;return!0}if(i===Bt)return!1;if(null!==i){null===t&&(t=[]);t.push(i);if(t.length>33)throw new FormatError("Too many arguments")}}}preprocessCommand(e,t){switch(0|e){case MA:this.stateManager.save();break;case UA:this.stateManager.restore();break;case xA:this.stateManager.transform(t)}}}class DefaultAppearanceEvaluator extends EvaluatorPreprocessor{constructor(e){super(new StringStream(e))}parse(){const e={fn:0,args:[]},t={fontSize:0,fontName:"",fontColor:new Uint8ClampedArray(3)};try{for(;;){e.args.length=0;if(!this.read(e))break;if(0!==this.savedStatesDepth)continue;const{fn:i,args:a}=e;switch(0|i){case re:const[e,i]=a;e instanceof Name&&(t.fontName=e.name);"number"==typeof i&&i>0&&(t.fontSize=i);break;case Se:ColorSpace.singletons.rgb.getRgbItem(a,0,t.fontColor,0);break;case be:ColorSpace.singletons.gray.getRgbItem(a,0,t.fontColor,0);break;case Re:ColorSpace.singletons.cmyk.getRgbItem(a,0,t.fontColor,0)}}}catch(e){warn(`parseDefaultAppearance - ignoring errors: "${e}".`)}return t}}function parseDefaultAppearance(e){return new DefaultAppearanceEvaluator(e).parse()}class AppearanceStreamEvaluator extends EvaluatorPreprocessor{constructor(e,t,i){super(e);this.stream=e;this.evaluatorOptions=t;this.xref=i;this.resources=e.dict?.get("Resources")}parse(){const e={fn:0,args:[]};let t={scaleFactor:1,fontSize:0,fontName:"",fontColor:new Uint8ClampedArray(3),fillColorSpace:ColorSpace.singletons.gray},i=!1;const a=[];try{for(;;){e.args.length=0;if(i||!this.read(e))break;const{fn:s,args:r}=e;switch(0|s){case MA:a.push({scaleFactor:t.scaleFactor,fontSize:t.fontSize,fontName:t.fontName,fontColor:t.fontColor.slice(),fillColorSpace:t.fillColorSpace});break;case UA:t=a.pop()||t;break;case ce:t.scaleFactor*=Math.hypot(r[0],r[1]);break;case re:const[e,s]=r;e instanceof Name&&(t.fontName=e.name);"number"==typeof s&&s>0&&(t.fontSize=s*t.scaleFactor);break;case fe:t.fillColorSpace=ColorSpace.parse({cs:r[0],xref:this.xref,resources:this.resources,pdfFunctionFactory:this._pdfFunctionFactory,localColorSpaceCache:this._localColorSpaceCache});break;case ye:t.fillColorSpace.getRgbItem(r,0,t.fontColor,0);break;case Se:ColorSpace.singletons.rgb.getRgbItem(r,0,t.fontColor,0);break;case be:ColorSpace.singletons.gray.getRgbItem(r,0,t.fontColor,0);break;case Re:ColorSpace.singletons.cmyk.getRgbItem(r,0,t.fontColor,0);break;case he:case le:case Be:case Qe:i=!0}}}catch(e){warn(`parseAppearanceStream - ignoring errors: "${e}".`)}this.stream.reset();delete t.scaleFactor;delete t.fillColorSpace;return t}get _localColorSpaceCache(){return shadow(this,"_localColorSpaceCache",new LocalColorSpaceCache)}get _pdfFunctionFactory(){return shadow(this,"_pdfFunctionFactory",new PDFFunctionFactory({xref:this.xref,isEvalSupported:this.evaluatorOptions.isEvalSupported}))}}function getPdfColor(e,t){if(e[0]===e[1]&&e[1]===e[2]){return`${numberToString(e[0]/255)} ${t?"g":"G"}`}return Array.from(e,(e=>numberToString(e/255))).join(" ")+" "+(t?"rg":"RG")}class FakeUnicodeFont{constructor(e,t){this.xref=e;this.widths=null;this.firstChar=1/0;this.lastChar=-1/0;this.fontFamily=t;const i=new OffscreenCanvas(1,1);this.ctxMeasure=i.getContext("2d",{willReadFrequently:!0});FakeUnicodeFont._fontNameId||(FakeUnicodeFont._fontNameId=1);this.fontName=Name.get(`InvalidPDFjsFont_${t}_${FakeUnicodeFont._fontNameId++}`)}get fontDescriptorRef(){if(!FakeUnicodeFont._fontDescriptorRef){const e=new Dict(this.xref);e.set("Type",Name.get("FontDescriptor"));e.set("FontName",this.fontName);e.set("FontFamily","MyriadPro Regular");e.set("FontBBox",[0,0,0,0]);e.set("FontStretch",Name.get("Normal"));e.set("FontWeight",400);e.set("ItalicAngle",0);FakeUnicodeFont._fontDescriptorRef=this.xref.getNewPersistentRef(e)}return FakeUnicodeFont._fontDescriptorRef}get descendantFontRef(){const e=new Dict(this.xref);e.set("BaseFont",this.fontName);e.set("Type",Name.get("Font"));e.set("Subtype",Name.get("CIDFontType0"));e.set("CIDToGIDMap",Name.get("Identity"));e.set("FirstChar",this.firstChar);e.set("LastChar",this.lastChar);e.set("FontDescriptor",this.fontDescriptorRef);e.set("DW",1e3);const t=[],i=[...this.widths.entries()].sort();let a=null,s=null;for(const[e,r]of i)if(a)if(e===a+s.length)s.push(r);else{t.push(a,s);a=e;s=[r]}else{a=e;s=[r]}a&&t.push(a,s);e.set("W",t);const r=new Dict(this.xref);r.set("Ordering","Identity");r.set("Registry","Adobe");r.set("Supplement",0);e.set("CIDSystemInfo",r);return this.xref.getNewPersistentRef(e)}get baseFontRef(){const e=new Dict(this.xref);e.set("BaseFont",this.fontName);e.set("Type",Name.get("Font"));e.set("Subtype",Name.get("Type0"));e.set("Encoding",Name.get("Identity-H"));e.set("DescendantFonts",[this.descendantFontRef]);e.set("ToUnicode",Name.get("Identity-H"));return this.xref.getNewPersistentRef(e)}get resources(){const e=new Dict(this.xref),t=new Dict(this.xref);t.set(this.fontName.name,this.baseFontRef);e.set("Font",t);return e}_createContext(){this.widths=new Map;this.ctxMeasure.font=`1000px ${this.fontFamily}`;return this.ctxMeasure}createFontResources(e){const t=this._createContext();for(const i of e.split(/\r\n?|\n/))for(const e of i.split("")){const i=e.charCodeAt(0);if(this.widths.has(i))continue;const a=t.measureText(e),s=Math.ceil(a.width);this.widths.set(i,s);this.firstChar=Math.min(i,this.firstChar);this.lastChar=Math.max(i,this.lastChar)}return this.resources}static getFirstPositionInfo(e,t,i){const[a,n,g,o]=e;let c=g-a,C=o-n;t%180!=0&&([c,C]=[C,c]);const h=s*i;return{coords:[0,C+r*i-h],bbox:[0,0,c,C],matrix:0!==t?getRotationMatrix(t,C,h):void 0}}createAppearance(e,t,i,a,n,g){const o=this._createContext(),c=[];let C=-1/0;for(const t of e.split(/\r\n?|\n/)){c.push(t);const e=o.measureText(t).width;C=Math.max(C,e);for(const e of codePointIter(t)){const t=String.fromCodePoint(e);let i=this.widths.get(e);if(void 0===i){const a=o.measureText(t);i=Math.ceil(a.width);this.widths.set(e,i);this.firstChar=Math.min(e,this.firstChar);this.lastChar=Math.max(e,this.lastChar)}}}C*=a/1e3;const[h,l,Q,E]=t;let u=Q-h,d=E-l;i%180!=0&&([u,d]=[d,u]);let f=1;C>u&&(f=u/C);let p=1;const m=s*a,y=r*a,w=m*c.length;w>d&&(p=d/w);const D=a*Math.min(f,p),b=["q",`0 0 ${numberToString(u)} ${numberToString(d)} re W n`,"BT",`1 0 0 1 0 ${numberToString(d+y)} Tm 0 Tc ${getPdfColor(n,!0)}`,`/${this.fontName.name} ${numberToString(D)} Tf`],{resources:F}=this;if(1!==(g="number"==typeof g&&g>=0&&g<=1?g:1)){b.push("/R0 gs");const e=new Dict(this.xref),t=new Dict(this.xref);t.set("ca",g);t.set("CA",g);t.set("Type",Name.get("ExtGState"));e.set("R0",t);F.set("ExtGState",e)}const S=numberToString(m);for(const e of c)b.push(`0 -${S} Td <${stringToUTF16HexString(e)}> Tj`);b.push("ET","Q");const k=b.join("\n"),R=new Dict(this.xref);R.set("Subtype",Name.get("Form"));R.set("Type",Name.get("XObject"));R.set("BBox",[0,0,u,d]);R.set("Length",k.length);R.set("Resources",F);if(i){const e=getRotationMatrix(i,u,d);R.set("Matrix",e)}const N=new StringStream(k);N.dict=R;return N}}class NameOrNumberTree{constructor(e,t,i){this.root=e;this.xref=t;this._type=i}getAll(){const e=new Map;if(!this.root)return e;const t=this.xref,i=new RefSet;i.put(this.root);const a=[this.root];for(;a.length>0;){const s=t.fetchIfRef(a.shift());if(!(s instanceof Dict))continue;if(s.has("Kids")){const e=s.get("Kids");if(!Array.isArray(e))continue;for(const t of e){if(i.has(t))throw new FormatError(`Duplicate entry in "${this._type}" tree.`);a.push(t);i.put(t)}continue}const r=s.get(this._type);if(Array.isArray(r))for(let i=0,a=r.length;i10){warn(`Search depth limit reached for "${this._type}" tree.`);return null}const s=i.get("Kids");if(!Array.isArray(s))return null;let r=0,n=s.length-1;for(;r<=n;){const a=r+n>>1,g=t.fetchIfRef(s[a]),o=g.get("Limits");if(et.fetchIfRef(o[1]))){i=g;break}r=a+1}}if(r>n)return null}const s=i.get(this._type);if(Array.isArray(s)){let i=0,a=s.length-2;for(;i<=a;){const r=i+a>>1,n=r+(1&r),g=t.fetchIfRef(s[n]);if(eg))return s[n+1];i=n+2}}}return null}get(e){return this.xref.fetchIfRef(this.getRaw(e))}}class NameTree extends NameOrNumberTree{constructor(e,t){super(e,t,"Names")}}class NumberTree extends NameOrNumberTree{constructor(e,t){super(e,t,"Nums")}}function clearGlobalCaches(){!function clearPatternCaches(){Qa=Object.create(null)}();!function clearPrimitiveCaches(){Qt=Object.create(null);Et=Object.create(null);ut=Object.create(null)}();!function clearUnicodeCaches(){ki.clear()}();JpxImage.cleanup()}function pickPlatformItem(e){return e instanceof Dict?e.has("UF")?e.get("UF"):e.has("F")?e.get("F"):e.has("Unix")?e.get("Unix"):e.has("Mac")?e.get("Mac"):e.has("DOS")?e.get("DOS"):null:null}class FileSpec{#U=!1;constructor(e,t,i=!1){if(e instanceof Dict){this.xref=t;this.root=e;e.has("FS")&&(this.fs=e.get("FS"));e.has("RF")&&warn("Related file specifications are not supported");i||(e.has("EF")?this.#U=!0:warn("Non-embedded file specifications are not supported"))}}get filename(){let e="";const t=pickPlatformItem(this.root);t&&"string"==typeof t&&(e=stringToPDFString(t).replaceAll("\\\\","\\").replaceAll("\\/","/").replaceAll("\\","/"));return shadow(this,"filename",e||"unnamed")}get content(){if(!this.#U)return null;this._contentRef||=pickPlatformItem(this.root?.get("EF"));let e=null;if(this._contentRef){const t=this.xref.fetchIfRef(this._contentRef);t instanceof BaseStream?e=t.getBytes():warn("Embedded file specification points to non-existing/invalid content")}else warn("Embedded file specification does not have any content");return e}get description(){let e="";const t=this.root?.get("Desc");t&&"string"==typeof t&&(e=stringToPDFString(t));return shadow(this,"description",e)}get serializable(){return{rawFilename:this.filename,filename:(e=this.filename,e.substring(e.lastIndexOf("/")+1)),content:this.content,description:this.description};var e}}const ys=0,ws=-2,Ds=-3,bs=-4,Fs=-5,Ss=-6,ks=-9;function isWhitespace(e,t){const i=e[t];return" "===i||"\n"===i||"\r"===i||"\t"===i}class XMLParserBase{_resolveEntities(e){return e.replaceAll(/&([^;]+);/g,((e,t)=>{if("#x"===t.substring(0,2))return String.fromCodePoint(parseInt(t.substring(2),16));if("#"===t.substring(0,1))return String.fromCodePoint(parseInt(t.substring(1),10));switch(t){case"lt":return"<";case"gt":return">";case"amp":return"&";case"quot":return'"';case"apos":return"'"}return this.onResolveEntity(t)}))}_parseContent(e,t){const i=[];let a=t;function skipWs(){for(;a"!==e[a]&&"/"!==e[a];)++a;const s=e.substring(t,a);skipWs();for(;a"!==e[a]&&"/"!==e[a]&&"?"!==e[a];){skipWs();let t="",s="";for(;a"!==e[i]&&"?"!==e[i]&&"/"!==e[i];)++i;const a=e.substring(t,i);!function skipWs(){for(;i"!==e[i+1]);)++i;return{name:a,value:e.substring(s,i),parsed:i-t}}parseXml(e){let t=0;for(;t",i);if(t<0){this.onError(ks);return}this.onEndElement(e.substring(i,t));i=t+1;break;case"?":++i;const a=this._parseProcessingInstruction(e,i);if("?>"!==e.substring(i+a.parsed,i+a.parsed+2)){this.onError(Ds);return}this.onPi(a.name,a.value);i+=a.parsed+2;break;case"!":if("--"===e.substring(i+1,i+3)){t=e.indexOf("--\x3e",i+3);if(t<0){this.onError(Fs);return}this.onComment(e.substring(i+3,t));i=t+3}else if("[CDATA["===e.substring(i+1,i+8)){t=e.indexOf("]]>",i+8);if(t<0){this.onError(ws);return}this.onCdata(e.substring(i+8,t));i=t+3}else{if("DOCTYPE"!==e.substring(i+1,i+8)){this.onError(Ss);return}{const a=e.indexOf("[",i+8);let s=!1;t=e.indexOf(">",i+8);if(t<0){this.onError(bs);return}if(a>0&&t>a){t=e.indexOf("]>",i+8);if(t<0){this.onError(bs);return}s=!0}const r=e.substring(i+8,t+(s?1:0));this.onDoctype(r);i=t+(s?2:1)}}break;default:const s=this._parseContent(e,i);if(null===s){this.onError(Ss);return}let r=!1;if("/>"===e.substring(i+s.parsed,i+s.parsed+2))r=!0;else if(">"!==e.substring(i+s.parsed,i+s.parsed+1)){this.onError(ks);return}this.onBeginElement(s.name,s.attributes,r);i+=s.parsed+(r?2:1)}}else{for(;i0}searchNode(e,t){if(t>=e.length)return this;const i=e[t];if(i.name.startsWith("#")&&t0){a.push([s,0]);s=s.childNodes[0]}else{if(0===a.length)return null;for(;0!==a.length;){const[e,t]=a.pop(),i=t+1;if(i");for(const t of this.childNodes)t.dump(e);e.push(``)}else this.nodeValue?e.push(`>${encodeToXmlString(this.nodeValue)}`):e.push("/>")}else e.push(encodeToXmlString(this.nodeValue))}}class SimpleXMLParser extends XMLParserBase{constructor({hasAttributes:e=!1,lowerCaseName:t=!1}){super();this._currentFragment=null;this._stack=null;this._errorCode=ys;this._hasAttributes=e;this._lowerCaseName=t}parseFromString(e){this._currentFragment=[];this._stack=[];this._errorCode=ys;this.parseXml(e);if(this._errorCode!==ys)return;const[t]=this._currentFragment;return t?{documentElement:t}:void 0}onText(e){if(function isWhitespaceString(e){for(let t=0,i=e.length;t\\376\\377([^<]+)/g,(function(e,t){const i=t.replaceAll(/\\([0-3])([0-7])([0-7])/g,(function(e,t,i,a){return String.fromCharCode(64*t+8*i+1*a)})).replaceAll(/&(amp|apos|gt|lt|quot);/g,(function(e,t){switch(t){case"amp":return"&";case"apos":return"'";case"gt":return">";case"lt":return"<";case"quot":return'"'}throw new Error(`_repair: ${t} isn't defined.`)})),a=[">"];for(let e=0,t=i.length;e=32&&t<127&&60!==t&&62!==t&&38!==t?a.push(String.fromCharCode(t)):a.push("&#x"+(65536+t).toString(16).substring(1)+";")}return a.join("")}))}_getSequence(e){const t=e.nodeName;return"rdf:bag"!==t&&"rdf:seq"!==t&&"rdf:alt"!==t?null:e.childNodes.filter((e=>"rdf:li"===e.nodeName))}_parseArray(e){if(!e.hasChildNodes())return;const[t]=e.childNodes,i=this._getSequence(t)||[];this._metadataMap.set(e.nodeName,i.map((e=>e.textContent.trim())))}_parse(e){let t=e.documentElement;if("rdf:rdf"!==t.nodeName){t=t.firstChild;for(;t&&"rdf:rdf"!==t.nodeName;)t=t.nextSibling}if(t&&"rdf:rdf"===t.nodeName&&t.hasChildNodes())for(const e of t.childNodes)if("rdf:description"===e.nodeName)for(const t of e.childNodes){const e=t.nodeName;switch(e){case"#text":continue;case"dc:creator":case"dc:subject":this._parseArray(t);continue}this._metadataMap.set(e,t.textContent.trim())}}get serializable(){return{parsedData:this._metadataMap,rawData:this._data}}}const Rs=1,Ns=2,Gs=3,Ms=4,Us=5;class StructTreeRoot{constructor(e,t){this.dict=e;this.ref=t instanceof Ref?t:null;this.roleMap=new Map;this.structParentIds=null}init(){this.readRoleMap()}#x(e,t,i){if(!(e instanceof Ref)||t<0)return;this.structParentIds||=new RefSetCache;let a=this.structParentIds.get(e);if(!a){a=[];this.structParentIds.put(e,a)}a.push([t,i])}addAnnotationIdToPage(e,t){this.#x(e,t,Ms)}readRoleMap(){const e=this.dict.get("RoleMap");if(e instanceof Dict)for(const[t,i]of e)i instanceof Name&&this.roleMap.set(t,i.name)}static async canCreateStructureTree({catalogRef:e,pdfManager:t,newAnnotationsByPage:i}){if(!(e instanceof Ref)){warn("Cannot save the struct tree: no catalog reference.");return!1}let a=0,s=!0;for(const[e,r]of i){const{ref:i}=await t.getPage(e);if(!(i instanceof Ref)){warn(`Cannot save the struct tree: page ${e} has no ref.`);s=!0;break}for(const e of r)if(e.accessibilityData?.type){e.parentTreeId=a++;s=!1}}if(s){for(const e of i.values())for(const t of e)delete t.parentTreeId;return!1}return!0}static async createStructureTree({newAnnotationsByPage:e,xref:t,catalogRef:i,pdfManager:a,changes:s}){const r=a.catalog.cloneDict(),n=new RefSetCache;n.put(i,r);const g=t.getNewTemporaryRef();r.set("StructTreeRoot",g);const o=new Dict(t);o.set("Type",Name.get("StructTreeRoot"));const c=t.getNewTemporaryRef();o.set("ParentTree",c);const C=[];o.set("K",C);n.put(g,o);const h=new Dict(t),l=[];h.set("Nums",l);const Q=await this.#L({newAnnotationsByPage:e,structTreeRootRef:g,structTreeRoot:null,kids:C,nums:l,xref:t,pdfManager:a,changes:s,cache:n});o.set("ParentTreeNextKey",Q);n.put(c,h);for(const[e,t]of n.items())s.put(e,{data:t})}async canUpdateStructTree({pdfManager:e,xref:t,newAnnotationsByPage:i}){if(!this.ref){warn("Cannot update the struct tree: no root reference.");return!1}let a=this.dict.get("ParentTreeNextKey");if(!Number.isInteger(a)||a<0){warn("Cannot update the struct tree: invalid next key.");return!1}const s=this.dict.get("ParentTree");if(!(s instanceof Dict)){warn("Cannot update the struct tree: ParentTree isn't a dict.");return!1}const r=s.get("Nums");if(!Array.isArray(r)){warn("Cannot update the struct tree: nums isn't an array.");return!1}const n=new NumberTree(s,t);for(const t of i.keys()){const{pageDict:i}=await e.getPage(t);if(!i.has("StructParents"))continue;const a=i.get("StructParents");if(!Number.isInteger(a)||!Array.isArray(n.get(a))){warn(`Cannot save the struct tree: page ${t} has a wrong id.`);return!1}}let g=!0;for(const[t,s]of i){const{pageDict:i}=await e.getPage(t);StructTreeRoot.#H({elements:s,xref:this.dict.xref,pageDict:i,numberTree:n});for(const e of s)if(e.accessibilityData?.type){e.accessibilityData.structParent>=0||(e.parentTreeId=a++);g=!1}}if(g){for(const e of i.values())for(const t of e){delete t.parentTreeId;delete t.structTreeParent}return!1}return!0}async updateStructureTree({newAnnotationsByPage:e,pdfManager:t,changes:i}){const a=this.dict.xref,s=this.dict.clone(),r=this.ref,n=new RefSetCache;n.put(r,s);let g,o=s.getRaw("ParentTree");if(o instanceof Ref)g=a.fetch(o);else{g=o;o=a.getNewTemporaryRef();s.set("ParentTree",o)}g=g.clone();n.put(o,g);let c=g.getRaw("Nums"),C=null;if(c instanceof Ref){C=c;c=a.fetch(C)}c=c.slice();C||g.set("Nums",c);const h=await StructTreeRoot.#L({newAnnotationsByPage:e,structTreeRootRef:r,structTreeRoot:this,kids:null,nums:c,xref:a,pdfManager:t,changes:i,cache:n});if(-1!==h){s.set("ParentTreeNextKey",h);C&&n.put(C,c);for(const[e,t]of n.items())i.put(e,{data:t})}}static async#L({newAnnotationsByPage:e,structTreeRootRef:t,structTreeRoot:i,kids:a,nums:s,xref:r,pdfManager:n,changes:g,cache:o}){const c=Name.get("OBJR");let C,h=-1;for(const[l,Q]of e){const e=await n.getPage(l),{ref:E}=e,u=E instanceof Ref;for(const{accessibilityData:n,ref:d,parentTreeId:f,structTreeParent:p}of Q){if(!n?.type)continue;const{structParent:Q}=n;if(i&&Number.isInteger(Q)&&Q>=0){let t=(C||=new Map).get(l);if(void 0===t){t=new StructTreePage(i,e.pageDict).collectObjects(E);C.set(l,t)}const a=t?.get(Q);if(a){const e=r.fetch(a).clone();StructTreeRoot.#J(e,n);g.put(a,{data:e});continue}}h=Math.max(h,f);const m=r.getNewTemporaryRef(),y=new Dict(r);StructTreeRoot.#J(y,n);await this.#Y({structTreeParent:p,tagDict:y,newTagRef:m,structTreeRootRef:t,fallbackKids:a,xref:r,cache:o});const w=new Dict(r);y.set("K",w);w.set("Type",c);u&&w.set("Pg",E);w.set("Obj",d);o.put(m,y);s.push(f,m)}}return h+1}static#J(e,{type:t,title:i,lang:a,alt:s,expanded:r,actualText:n}){e.set("S",Name.get(t));i&&e.set("T",stringToAsciiOrUTF16BE(i));a&&e.set("Lang",stringToAsciiOrUTF16BE(a));s&&e.set("Alt",stringToAsciiOrUTF16BE(s));r&&e.set("E",stringToAsciiOrUTF16BE(r));n&&e.set("ActualText",stringToAsciiOrUTF16BE(n))}static#H({elements:e,xref:t,pageDict:i,numberTree:a}){const s=new Map;for(const t of e)if(t.structTreeParentId){const e=parseInt(t.structTreeParentId.split("_mc")[1],10);let i=s.get(e);if(!i){i=[];s.set(e,i)}i.push(t)}const r=i.get("StructParents");if(!Number.isInteger(r))return;const n=a.get(r),updateElement=(e,i,a)=>{const r=s.get(e);if(r){const e=i.getRaw("P"),s=t.fetchIfRef(e);if(e instanceof Ref&&s instanceof Dict){const e={ref:a,dict:i};for(const t of r)t.structTreeParent=e}return!0}return!1};for(const e of n){if(!(e instanceof Ref))continue;const i=t.fetch(e),a=i.get("K");if(Number.isInteger(a))updateElement(a,i,e);else if(Array.isArray(a))for(let s of a){s=t.fetchIfRef(s);if(Number.isInteger(s)&&updateElement(s,i,e))break;if(!(s instanceof Dict))continue;if(!isName(s.get("Type"),"MCR"))break;const a=s.get("MCID");if(Number.isInteger(a)&&updateElement(a,i,e))break}}}static async#Y({structTreeParent:e,tagDict:t,newTagRef:i,structTreeRootRef:a,fallbackKids:s,xref:r,cache:n}){let g,o=null;if(e){({ref:o}=e);g=e.dict.getRaw("P")||a}else g=a;t.set("P",g);const c=r.fetchIfRef(g);if(!c){s.push(i);return}let C=n.get(g);if(!C){C=c.clone();n.put(g,C)}const h=C.getRaw("K");let l=h instanceof Ref?n.get(h):null;if(!l){l=r.fetchIfRef(h);l=Array.isArray(l)?l.slice():[h];const e=r.getNewTemporaryRef();C.set("K",e);n.put(e,l)}const Q=l.indexOf(o);l.splice(Q>=0?Q+1:l.length,0,i)}}class StructElementNode{constructor(e,t){this.tree=e;this.dict=t;this.kids=[];this.parseKids()}get role(){const e=this.dict.get("S"),t=e instanceof Name?e.name:"",{root:i}=this.tree;return i.roleMap.has(t)?i.roleMap.get(t):t}parseKids(){let e=null;const t=this.dict.getRaw("Pg");t instanceof Ref&&(e=t.toString());const i=this.dict.get("K");if(Array.isArray(i))for(const t of i){const i=this.parseKid(e,t);i&&this.kids.push(i)}else{const t=this.parseKid(e,i);t&&this.kids.push(t)}}parseKid(e,t){if(Number.isInteger(t))return this.tree.pageDict.objId!==e?null:new StructElement({type:Rs,mcid:t,pageObjId:e});let i=null;t instanceof Ref?i=this.dict.xref.fetch(t):t instanceof Dict&&(i=t);if(!i)return null;const a=i.getRaw("Pg");a instanceof Ref&&(e=a.toString());const s=i.get("Type")instanceof Name?i.get("Type").name:null;if("MCR"===s){if(this.tree.pageDict.objId!==e)return null;const t=i.getRaw("Stm");return new StructElement({type:Ns,refObjId:t instanceof Ref?t.toString():null,pageObjId:e,mcid:i.get("MCID")})}if("OBJR"===s){if(this.tree.pageDict.objId!==e)return null;const t=i.getRaw("Obj");return new StructElement({type:Gs,refObjId:t instanceof Ref?t.toString():null,pageObjId:e})}return new StructElement({type:Us,dict:i})}}class StructElement{constructor({type:e,dict:t=null,mcid:i=null,pageObjId:a=null,refObjId:s=null}){this.type=e;this.dict=t;this.mcid=i;this.pageObjId=a;this.refObjId=s;this.parentNode=null}}class StructTreePage{constructor(e,t){this.root=e;this.rootDict=e?e.dict:null;this.pageDict=t;this.nodes=[]}collectObjects(e){if(!(this.root&&this.rootDict&&e instanceof Ref))return null;const t=this.rootDict.get("ParentTree");if(!t)return null;const i=this.root.structParentIds?.get(e);if(!i)return null;const a=new Map,s=new NumberTree(t,this.rootDict.xref);for(const[e]of i){const t=s.getRaw(e);t instanceof Ref&&a.set(e,t)}return a}parse(e){if(!(this.root&&this.rootDict&&e instanceof Ref))return;const t=this.rootDict.get("ParentTree");if(!t)return;const i=this.pageDict.get("StructParents"),a=this.root.structParentIds?.get(e);if(!Number.isInteger(i)&&!a)return;const s=new Map,r=new NumberTree(t,this.rootDict.xref);if(Number.isInteger(i)){const e=r.get(i);if(Array.isArray(e))for(const t of e)t instanceof Ref&&this.addNode(this.rootDict.xref.fetch(t),s)}if(a)for(const[e,t]of a){const i=r.get(e);if(i){const e=this.addNode(this.rootDict.xref.fetchIfRef(i),s);1===e?.kids?.length&&e.kids[0].type===Gs&&(e.kids[0].type=t)}}}addNode(e,t,i=0){if(i>40){warn("StructTree MAX_DEPTH reached.");return null}if(!(e instanceof Dict))return null;if(t.has(e))return t.get(e);const a=new StructElementNode(this,e);t.set(e,a);const s=e.get("P");if(!s||isName(s.get("Type"),"StructTreeRoot")){this.addTopLevelNode(e,a)||t.delete(e);return a}const r=this.addNode(s,t,i+1);if(!r)return a;let n=!1;for(const t of r.kids)if(t.type===Us&&t.dict===e){t.parentNode=a;n=!0}n||t.delete(e);return a}addTopLevelNode(e,t){const i=this.rootDict.get("K");if(!i)return!1;if(i instanceof Dict){if(i.objId!==e.objId)return!1;this.nodes[0]=t;return!0}if(!Array.isArray(i))return!0;let a=!1;for(let s=0;s40){warn("StructTree too deep to be fully serialized.");return}const a=Object.create(null);a.role=e.role;a.children=[];t.children.push(a);let s=e.dict.get("Alt");"string"!=typeof s&&(s=e.dict.get("ActualText"));"string"==typeof s&&(a.alt=stringToPDFString(s));const r=e.dict.get("A");if(r instanceof Dict){const e=lookupNormalRect(r.getArray("BBox"),null);if(e)a.bbox=e;else{const e=r.get("Width"),t=r.get("Height");"number"==typeof e&&e>0&&"number"==typeof t&&t>0&&(a.bbox=[0,0,e,t])}}const n=e.dict.get("Lang");"string"==typeof n&&(a.lang=stringToPDFString(n));for(const t of e.kids){const e=t.type===Us?t.parentNode:null;e?nodeToSerializable(e,a,i+1):t.type===Rs||t.type===Ns?a.children.push({type:"content",id:`p${t.pageObjId}_mc${t.mcid}`}):t.type===Gs?a.children.push({type:"object",id:t.refObjId}):t.type===Ms&&a.children.push({type:"annotation",id:`pdfjs_internal_id_${t.refObjId}`})}}const e=Object.create(null);e.children=[];e.role="Root";for(const t of this.nodes)t&&nodeToSerializable(t,e);return e}}function isValidExplicitDest(e){if(!Array.isArray(e)||e.length<2)return!1;const[t,i,...a]=e;if(!(t instanceof Ref||Number.isInteger(t)))return!1;if(!(i instanceof Name))return!1;const s=a.length;let r=!0;switch(i.name){case"XYZ":if(s<2||s>3)return!1;break;case"Fit":case"FitB":return 0===s;case"FitH":case"FitBH":case"FitV":case"FitBV":if(s>1)return!1;break;case"FitR":if(4!==s)return!1;r=!1;break;default:return!1}for(const e of a)if(!("number"==typeof e||r&&null===e))return!1;return!0}function fetchDest(e){e instanceof Dict&&(e=e.get("D"));return isValidExplicitDest(e)?e:null}function fetchRemoteDest(e){let t=e.get("D");if(t){t instanceof Name&&(t=t.name);if("string"==typeof t)return stringToPDFString(t);if(isValidExplicitDest(t))return JSON.stringify(t)}return null}class Catalog{constructor(e,t){this.pdfManager=e;this.xref=t;this._catDict=t.getCatalogObj();if(!(this._catDict instanceof Dict))throw new FormatError("Catalog object is not a dictionary.");this.toplevelPagesDict;this._actualNumPages=null;this.fontCache=new RefSetCache;this.builtInCMapCache=new Map;this.standardFontDataCache=new Map;this.globalImageCache=new GlobalImageCache;this.pageKidsCountCache=new RefSetCache;this.pageIndexCache=new RefSetCache;this.pageDictCache=new RefSetCache;this.nonBlendModesSet=new RefSet;this.systemFontCache=new Map}cloneDict(){return this._catDict.clone()}get version(){const e=this._catDict.get("Version");if(e instanceof Name){if(ft.test(e.name))return shadow(this,"version",e.name);warn(`Invalid PDF catalog version: ${e.name}`)}return shadow(this,"version",null)}get lang(){const e=this._catDict.get("Lang");return shadow(this,"lang",e&&"string"==typeof e?stringToPDFString(e):null)}get needsRendering(){const e=this._catDict.get("NeedsRendering");return shadow(this,"needsRendering","boolean"==typeof e&&e)}get collection(){let e=null;try{const t=this._catDict.get("Collection");t instanceof Dict&&t.size>0&&(e=t)}catch(e){if(e instanceof MissingDataException)throw e;info("Cannot fetch Collection entry; assuming no collection is present.")}return shadow(this,"collection",e)}get acroForm(){let e=null;try{const t=this._catDict.get("AcroForm");t instanceof Dict&&t.size>0&&(e=t)}catch(e){if(e instanceof MissingDataException)throw e;info("Cannot fetch AcroForm entry; assuming no forms are present.")}return shadow(this,"acroForm",e)}get acroFormRef(){const e=this._catDict.getRaw("AcroForm");return shadow(this,"acroFormRef",e instanceof Ref?e:null)}get metadata(){const e=this._catDict.getRaw("Metadata");if(!(e instanceof Ref))return shadow(this,"metadata",null);let t=null;try{const i=this.xref.fetch(e,!this.xref.encrypt?.encryptMetadata);if(i instanceof BaseStream&&i.dict instanceof Dict){const e=i.dict.get("Type"),a=i.dict.get("Subtype");if(isName(e,"Metadata")&&isName(a,"XML")){const e=stringToUTF8String(i.getString());e&&(t=new MetadataParser(e).serializable)}}}catch(e){if(e instanceof MissingDataException)throw e;info(`Skipping invalid Metadata: "${e}".`)}return shadow(this,"metadata",t)}get markInfo(){let e=null;try{e=this._readMarkInfo()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read mark info.")}return shadow(this,"markInfo",e)}_readMarkInfo(){const e=this._catDict.get("MarkInfo");if(!(e instanceof Dict))return null;const t={Marked:!1,UserProperties:!1,Suspects:!1};for(const i in t){const a=e.get(i);"boolean"==typeof a&&(t[i]=a)}return t}get structTreeRoot(){let e=null;try{e=this._readStructTreeRoot()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable read to structTreeRoot info.")}return shadow(this,"structTreeRoot",e)}_readStructTreeRoot(){const e=this._catDict.getRaw("StructTreeRoot"),t=this.xref.fetchIfRef(e);if(!(t instanceof Dict))return null;const i=new StructTreeRoot(t,e);i.init();return i}get toplevelPagesDict(){const e=this._catDict.get("Pages");if(!(e instanceof Dict))throw new FormatError("Invalid top-level pages dictionary.");return shadow(this,"toplevelPagesDict",e)}get documentOutline(){let e=null;try{e=this._readDocumentOutline()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read document outline.")}return shadow(this,"documentOutline",e)}_readDocumentOutline(){let e=this._catDict.get("Outlines");if(!(e instanceof Dict))return null;e=e.getRaw("First");if(!(e instanceof Ref))return null;const t={items:[]},i=[{obj:e,parent:t}],a=new RefSet;a.put(e);const s=this.xref,r=new Uint8ClampedArray(3);for(;i.length>0;){const t=i.shift(),n=s.fetchIfRef(t.obj);if(null===n)continue;n.has("Title")||warn("Invalid outline item encountered.");const g={url:null,dest:null,action:null};Catalog.parseDestDictionary({destDict:n,resultObj:g,docBaseUrl:this.baseUrl,docAttachments:this.attachments});const o=n.get("Title"),c=n.get("F")||0,C=n.getArray("C"),h=n.get("Count");let l=r;!isNumberArray(C,3)||0===C[0]&&0===C[1]&&0===C[2]||(l=ColorSpace.singletons.rgb.getRgb(C,0));const Q={action:g.action,attachment:g.attachment,dest:g.dest,url:g.url,unsafeUrl:g.unsafeUrl,newWindow:g.newWindow,setOCGState:g.setOCGState,title:"string"==typeof o?stringToPDFString(o):"",color:l,count:Number.isInteger(h)?h:void 0,bold:!!(2&c),italic:!!(1&c),items:[]};t.parent.items.push(Q);e=n.getRaw("First");if(e instanceof Ref&&!a.has(e)){i.push({obj:e,parent:Q});a.put(e)}e=n.getRaw("Next");if(e instanceof Ref&&!a.has(e)){i.push({obj:e,parent:t.parent});a.put(e)}}return t.items.length>0?t.items:null}get permissions(){let e=null;try{e=this._readPermissions()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read permissions.")}return shadow(this,"permissions",e)}_readPermissions(){const e=this.xref.trailer.get("Encrypt");if(!(e instanceof Dict))return null;let t=e.get("P");if("number"!=typeof t)return null;t+=2**32;const i=[];for(const e in y){const a=y[e];t&a&&i.push(a)}return i}get optionalContentConfig(){let e=null;try{const t=this._catDict.get("OCProperties");if(!t)return shadow(this,"optionalContentConfig",null);const i=t.get("D");if(!i)return shadow(this,"optionalContentConfig",null);const a=t.get("OCGs");if(!Array.isArray(a))return shadow(this,"optionalContentConfig",null);const s=new RefSetCache;for(const e of a)e instanceof Ref&&!s.has(e)&&s.put(e,this.#v(e));e=this.#K(i,s)}catch(e){if(e instanceof MissingDataException)throw e;warn(`Unable to read optional content config: ${e}`)}return shadow(this,"optionalContentConfig",e)}#v(e){const t=this.xref.fetch(e),i={id:e.toString(),name:null,intent:null,usage:{print:null,view:null},rbGroups:[]},a=t.get("Name");"string"==typeof a&&(i.name=stringToPDFString(a));let s=t.getArray("Intent");Array.isArray(s)||(s=[s]);s.every((e=>e instanceof Name))&&(i.intent=s.map((e=>e.name)));const r=t.get("Usage");if(!(r instanceof Dict))return i;const n=i.usage,g=r.get("Print");if(g instanceof Dict){const e=g.get("PrintState");if(e instanceof Name)switch(e.name){case"ON":case"OFF":n.print={printState:e.name}}}const o=r.get("View");if(o instanceof Dict){const e=o.get("ViewState");if(e instanceof Name)switch(e.name){case"ON":case"OFF":n.view={viewState:e.name}}}return i}#K(e,t){function parseOnOff(e){const i=[];if(Array.isArray(e))for(const a of e)a instanceof Ref&&t.has(a)&&i.push(a.toString());return i}function parseOrder(e,i=0){if(!Array.isArray(e))return null;const s=[];for(const r of e){if(r instanceof Ref&&t.has(r)){a.put(r);s.push(r.toString());continue}const e=parseNestedOrder(r,i);e&&s.push(e)}if(i>0)return s;const r=[];for(const[e]of t.items())a.has(e)||r.push(e.toString());r.length&&s.push({name:null,order:r});return s}function parseNestedOrder(e,t){if(++t>s){warn("parseNestedOrder - reached MAX_NESTED_LEVELS.");return null}const a=i.fetchIfRef(e);if(!Array.isArray(a))return null;const r=i.fetchIfRef(a[0]);if("string"!=typeof r)return null;const n=parseOrder(a.slice(1),t);return n?.length?{name:stringToPDFString(r),order:n}:null}const i=this.xref,a=new RefSet,s=10;!function parseRBGroups(e){if(Array.isArray(e))for(const a of e){const e=i.fetchIfRef(a);if(!Array.isArray(e)||!e.length)continue;const s=new Set;for(const i of e)if(i instanceof Ref&&t.has(i)&&!s.has(i.toString())){s.add(i.toString());t.get(i).rbGroups.push(s)}}}(e.get("RBGroups"));return{name:"string"==typeof e.get("Name")?stringToPDFString(e.get("Name")):null,creator:"string"==typeof e.get("Creator")?stringToPDFString(e.get("Creator")):null,baseState:e.get("BaseState")instanceof Name?e.get("BaseState").name:null,on:parseOnOff(e.get("ON")),off:parseOnOff(e.get("OFF")),order:parseOrder(e.get("Order")),groups:[...t]}}setActualNumPages(e=null){this._actualNumPages=e}get hasActualNumPages(){return null!==this._actualNumPages}get _pagesCount(){const e=this.toplevelPagesDict.get("Count");if(!Number.isInteger(e))throw new FormatError("Page count in top-level pages dictionary is not an integer.");return shadow(this,"_pagesCount",e)}get numPages(){return this.hasActualNumPages?this._actualNumPages:this._pagesCount}get destinations(){const e=this._readDests(),t=Object.create(null);if(e instanceof NameTree)for(const[i,a]of e.getAll()){const e=fetchDest(a);e&&(t[stringToPDFString(i)]=e)}else if(e instanceof Dict)for(const[i,a]of e){const e=fetchDest(a);e&&(t[i]=e)}return shadow(this,"destinations",t)}getDestination(e){const t=this._readDests();if(t instanceof NameTree){const i=fetchDest(t.get(e));if(i)return i;const a=this.destinations[e];if(a){warn(`Found "${e}" at an incorrect position in the NameTree.`);return a}}else if(t instanceof Dict){const i=fetchDest(t.get(e));if(i)return i}return null}_readDests(){const e=this._catDict.get("Names");return e?.has("Dests")?new NameTree(e.getRaw("Dests"),this.xref):this._catDict.has("Dests")?this._catDict.get("Dests"):void 0}get pageLabels(){let e=null;try{e=this._readPageLabels()}catch(e){if(e instanceof MissingDataException)throw e;warn("Unable to read page labels.")}return shadow(this,"pageLabels",e)}_readPageLabels(){const e=this._catDict.getRaw("PageLabels");if(!e)return null;const t=new Array(this.numPages);let i=null,a="";const s=new NumberTree(e,this.xref).getAll();let r="",n=1;for(let e=0,g=this.numPages;e=1))throw new FormatError("Invalid start in PageLabel dictionary.");n=e}else n=1}switch(i){case"D":r=n;break;case"R":case"r":r=toRomanNumerals(n,"r"===i);break;case"A":case"a":const e=26,t="a"===i?97:65,a=n-1;r=String.fromCharCode(t+a%e).repeat(Math.floor(a/e)+1);break;default:if(i)throw new FormatError(`Invalid style "${i}" in PageLabel dictionary.`);r=""}t[e]=a+r;n++}return t}get pageLayout(){const e=this._catDict.get("PageLayout");let t="";if(e instanceof Name)switch(e.name){case"SinglePage":case"OneColumn":case"TwoColumnLeft":case"TwoColumnRight":case"TwoPageLeft":case"TwoPageRight":t=e.name}return shadow(this,"pageLayout",t)}get pageMode(){const e=this._catDict.get("PageMode");let t="UseNone";if(e instanceof Name)switch(e.name){case"UseNone":case"UseOutlines":case"UseThumbs":case"FullScreen":case"UseOC":case"UseAttachments":t=e.name}return shadow(this,"pageMode",t)}get viewerPreferences(){const e=this._catDict.get("ViewerPreferences");if(!(e instanceof Dict))return shadow(this,"viewerPreferences",null);let t=null;for(const i of e.getKeys()){const a=e.get(i);let s;switch(i){case"HideToolbar":case"HideMenubar":case"HideWindowUI":case"FitWindow":case"CenterWindow":case"DisplayDocTitle":case"PickTrayByPDFSize":"boolean"==typeof a&&(s=a);break;case"NonFullScreenPageMode":if(a instanceof Name)switch(a.name){case"UseNone":case"UseOutlines":case"UseThumbs":case"UseOC":s=a.name;break;default:s="UseNone"}break;case"Direction":if(a instanceof Name)switch(a.name){case"L2R":case"R2L":s=a.name;break;default:s="L2R"}break;case"ViewArea":case"ViewClip":case"PrintArea":case"PrintClip":if(a instanceof Name)switch(a.name){case"MediaBox":case"CropBox":case"BleedBox":case"TrimBox":case"ArtBox":s=a.name;break;default:s="CropBox"}break;case"PrintScaling":if(a instanceof Name)switch(a.name){case"None":case"AppDefault":s=a.name;break;default:s="AppDefault"}break;case"Duplex":if(a instanceof Name)switch(a.name){case"Simplex":case"DuplexFlipShortEdge":case"DuplexFlipLongEdge":s=a.name;break;default:s="None"}break;case"PrintPageRange":if(Array.isArray(a)&&a.length%2==0){a.every(((e,t,i)=>Number.isInteger(e)&&e>0&&(0===t||e>=i[t-1])&&e<=this.numPages))&&(s=a)}break;case"NumCopies":Number.isInteger(a)&&a>0&&(s=a);break;default:warn(`Ignoring non-standard key in ViewerPreferences: ${i}.`);continue}if(void 0!==s){t||(t=Object.create(null));t[i]=s}else warn(`Bad value, for key "${i}", in ViewerPreferences: ${a}.`)}return shadow(this,"viewerPreferences",t)}get openAction(){const e=this._catDict.get("OpenAction"),t=Object.create(null);if(e instanceof Dict){const i=new Dict(this.xref);i.set("A",e);const a={url:null,dest:null,action:null};Catalog.parseDestDictionary({destDict:i,resultObj:a});Array.isArray(a.dest)?t.dest=a.dest:a.action&&(t.action=a.action)}else Array.isArray(e)&&(t.dest=e);return shadow(this,"openAction",objectSize(t)>0?t:null)}get attachments(){const e=this._catDict.get("Names");let t=null;if(e instanceof Dict&&e.has("EmbeddedFiles")){const i=new NameTree(e.getRaw("EmbeddedFiles"),this.xref);for(const[e,a]of i.getAll()){const i=new FileSpec(a,this.xref);t||(t=Object.create(null));t[stringToPDFString(e)]=i.serializable}}return shadow(this,"attachments",t)}get xfaImages(){const e=this._catDict.get("Names");let t=null;if(e instanceof Dict&&e.has("XFAImages")){const i=new NameTree(e.getRaw("XFAImages"),this.xref);for(const[e,a]of i.getAll()){t||(t=new Dict(this.xref));t.set(stringToPDFString(e),a)}}return shadow(this,"xfaImages",t)}_collectJavaScript(){const e=this._catDict.get("Names");let t=null;function appendIfJavaScriptDict(e,i){if(!(i instanceof Dict))return;if(!isName(i.get("S"),"JavaScript"))return;let a=i.get("JS");if(a instanceof BaseStream)a=a.getString();else if("string"!=typeof a)return;a=stringToPDFString(a).replaceAll("\0","");a&&(t||=new Map).set(e,a)}if(e instanceof Dict&&e.has("JavaScript")){const t=new NameTree(e.getRaw("JavaScript"),this.xref);for(const[e,i]of t.getAll())appendIfJavaScriptDict(stringToPDFString(e),i)}const i=this._catDict.get("OpenAction");i&&appendIfJavaScriptDict("OpenAction",i);return t}get jsActions(){const e=this._collectJavaScript();let t=collectActions(this.xref,this._catDict,fA);if(e){t||=Object.create(null);for(const[i,a]of e)i in t?t[i].push(a):t[i]=[a]}return shadow(this,"jsActions",t)}async fontFallback(e,t){const i=await Promise.all(this.fontCache);for(const a of i)if(a.loadedName===e){a.fallback(t);return}}async cleanup(e=!1){clearGlobalCaches();this.globalImageCache.clear(e);this.pageKidsCountCache.clear();this.pageIndexCache.clear();this.pageDictCache.clear();this.nonBlendModesSet.clear();const t=await Promise.all(this.fontCache);for(const{dict:e}of t)delete e.cacheKey;this.fontCache.clear();this.builtInCMapCache.clear();this.standardFontDataCache.clear();this.systemFontCache.clear()}async getPageDict(e){const t=[this.toplevelPagesDict],i=new RefSet,a=this._catDict.getRaw("Pages");a instanceof Ref&&i.put(a);const s=this.xref,r=this.pageKidsCountCache,n=this.pageIndexCache,g=this.pageDictCache;let o=0;for(;t.length;){const a=t.pop();if(a instanceof Ref){const c=r.get(a);if(c>=0&&o+c<=e){o+=c;continue}if(i.has(a))throw new FormatError("Pages tree contains circular reference.");i.put(a);const C=await(g.get(a)||s.fetchAsync(a));if(C instanceof Dict){let t=C.getRaw("Type");t instanceof Ref&&(t=await s.fetchAsync(t));if(isName(t,"Page")||!C.has("Kids")){r.has(a)||r.put(a,1);n.has(a)||n.put(a,o);if(o===e)return[C,a];o++;continue}}t.push(C);continue}if(!(a instanceof Dict))throw new FormatError("Page dictionary kid reference points to wrong type of object.");const{objId:c}=a;let C=a.getRaw("Count");C instanceof Ref&&(C=await s.fetchAsync(C));if(Number.isInteger(C)&&C>=0){c&&!r.has(c)&&r.put(c,C);if(o+C<=e){o+=C;continue}}let h=a.getRaw("Kids");h instanceof Ref&&(h=await s.fetchAsync(h));if(!Array.isArray(h)){let t=a.getRaw("Type");t instanceof Ref&&(t=await s.fetchAsync(t));if(isName(t,"Page")||!a.has("Kids")){if(o===e)return[a,null];o++;continue}throw new FormatError("Page dictionary kids object is not an array.")}for(let e=h.length-1;e>=0;e--){const i=h[e];t.push(i);a===this.toplevelPagesDict&&i instanceof Ref&&!g.has(i)&&g.put(i,s.fetchAsync(i))}}throw new Error(`Page index ${e} not found.`)}async getAllPageDicts(e=!1){const{ignoreErrors:t}=this.pdfManager.evaluatorOptions,i=[{currentNode:this.toplevelPagesDict,posInKids:0}],a=new RefSet,s=this._catDict.getRaw("Pages");s instanceof Ref&&a.put(s);const r=new Map,n=this.xref,g=this.pageIndexCache;let o=0;function addPageDict(e,t){t&&!g.has(t)&&g.put(t,o);r.set(o++,[e,t])}function addPageError(i){if(i instanceof XRefEntryException&&!e)throw i;if(e&&t&&0===o){warn(`getAllPageDicts - Skipping invalid first page: "${i}".`);i=Dict.empty}r.set(o++,[i,null])}for(;i.length>0;){const e=i.at(-1),{currentNode:t,posInKids:s}=e;let r=t.getRaw("Kids");if(r instanceof Ref)try{r=await n.fetchAsync(r)}catch(e){addPageError(e);break}if(!Array.isArray(r)){addPageError(new FormatError("Page dictionary kids object is not an array."));break}if(s>=r.length){i.pop();continue}const g=r[s];let o;if(g instanceof Ref){if(a.has(g)){addPageError(new FormatError("Pages tree contains circular reference."));break}a.put(g);try{o=await n.fetchAsync(g)}catch(e){addPageError(e);break}}else o=g;if(!(o instanceof Dict)){addPageError(new FormatError("Page dictionary kid reference points to wrong type of object."));break}let c=o.getRaw("Type");if(c instanceof Ref)try{c=await n.fetchAsync(c)}catch(e){addPageError(e);break}isName(c,"Page")||!o.has("Kids")?addPageDict(o,g instanceof Ref?g:null):i.push({currentNode:o,posInKids:0});e.posInKids++}return r}getPageIndex(e){const t=this.pageIndexCache.get(e);if(void 0!==t)return Promise.resolve(t);const i=this.xref;let a=0;const next=t=>function pagesBeforeRef(t){let a,s=0;return i.fetchAsync(t).then((function(i){if(isRefsEqual(t,e)&&!isDict(i,"Page")&&!(i instanceof Dict&&!i.has("Type")&&i.has("Contents")))throw new FormatError("The reference does not point to a /Page dictionary.");if(!i)return null;if(!(i instanceof Dict))throw new FormatError("Node must be a dictionary.");a=i.getRaw("Parent");return i.getAsync("Parent")})).then((function(e){if(!e)return null;if(!(e instanceof Dict))throw new FormatError("Parent must be a dictionary.");return e.getAsync("Kids")})).then((function(e){if(!e)return null;const r=[];let n=!1;for(const a of e){if(!(a instanceof Ref))throw new FormatError("Kid must be a reference.");if(isRefsEqual(a,t)){n=!0;break}r.push(i.fetchAsync(a).then((function(e){if(!(e instanceof Dict))throw new FormatError("Kid node must be a dictionary.");e.has("Count")?s+=e.get("Count"):s++})))}if(!n)throw new FormatError("Kid reference not found in parent's kids.");return Promise.all(r).then((function(){return[s,a]}))}))}(t).then((t=>{if(!t){this.pageIndexCache.put(e,a);return a}const[i,s]=t;a+=i;return next(s)}));return next(e)}get baseUrl(){const e=this._catDict.get("URI");if(e instanceof Dict){const t=e.get("Base");if("string"==typeof t){const e=createValidAbsoluteUrl(t,null,{tryConvertEncoding:!0});if(e)return shadow(this,"baseUrl",e.href)}}return shadow(this,"baseUrl",this.pdfManager.docBaseUrl)}static parseDestDictionary({destDict:e,resultObj:t,docBaseUrl:i=null,docAttachments:a=null}){if(!(e instanceof Dict)){warn("parseDestDictionary: `destDict` must be a dictionary.");return}let s,r,n=e.get("A");if(!(n instanceof Dict))if(e.has("Dest"))n=e.get("Dest");else{n=e.get("AA");n instanceof Dict&&(n.has("D")?n=n.get("D"):n.has("U")&&(n=n.get("U")))}if(n instanceof Dict){const e=n.get("S");if(!(e instanceof Name)){warn("parseDestDictionary: Invalid type in Action dictionary.");return}const i=e.name;switch(i){case"ResetForm":const e=n.get("Flags"),g=!(1&("number"==typeof e?e:0)),o=[],c=[];for(const e of n.get("Fields")||[])e instanceof Ref?c.push(e.toString()):"string"==typeof e&&o.push(stringToPDFString(e));t.resetForm={fields:o,refs:c,include:g};break;case"URI":s=n.get("URI");s instanceof Name&&(s="/"+s.name);break;case"GoTo":r=n.get("D");break;case"Launch":case"GoToR":const C=n.get("F");if(C instanceof Dict){const e=new FileSpec(C,null,!0),{rawFilename:t}=e.serializable;s=t}else"string"==typeof C&&(s=C);const h=fetchRemoteDest(n);h&&"string"==typeof s&&(s=s.split("#",1)[0]+"#"+h);const l=n.get("NewWindow");"boolean"==typeof l&&(t.newWindow=l);break;case"GoToE":const Q=n.get("T");let E;if(a&&Q instanceof Dict){const e=Q.get("R"),t=Q.get("N");isName(e,"C")&&"string"==typeof t&&(E=a[stringToPDFString(t)])}if(E){t.attachment=E;const e=fetchRemoteDest(n);e&&(t.attachmentDest=e)}else warn('parseDestDictionary - unimplemented "GoToE" action.');break;case"Named":const u=n.get("N");u instanceof Name&&(t.action=u.name);break;case"SetOCGState":const d=n.get("State"),f=n.get("PreserveRB");if(!Array.isArray(d)||0===d.length)break;const p=[];for(const e of d)if(e instanceof Name)switch(e.name){case"ON":case"OFF":case"Toggle":p.push(e.name)}else e instanceof Ref&&p.push(e.toString());if(p.length!==d.length)break;t.setOCGState={state:p,preserveRB:"boolean"!=typeof f||f};break;case"JavaScript":const m=n.get("JS");let y;m instanceof BaseStream?y=m.getString():"string"==typeof m&&(y=m);const w=y&&recoverJsURL(stringToPDFString(y));if(w){s=w.url;t.newWindow=w.newWindow;break}default:if("JavaScript"===i||"SubmitForm"===i)break;warn(`parseDestDictionary - unsupported action: "${i}".`)}}else e.has("Dest")&&(r=e.get("Dest"));if("string"==typeof s){const e=createValidAbsoluteUrl(s,i,{addDefaultProtocol:!0,tryConvertEncoding:!0});e&&(t.url=e.href);t.unsafeUrl=s}if(r){r instanceof Name&&(r=r.name);"string"==typeof r?t.dest=stringToPDFString(r):isValidExplicitDest(r)&&(t.dest=r)}}}function addChildren(e,t){if(e instanceof Dict)e=e.getRawValues();else if(e instanceof BaseStream)e=e.dict.getRawValues();else if(!Array.isArray(e))return;for(const a of e)((i=a)instanceof Ref||i instanceof Dict||i instanceof BaseStream||Array.isArray(i))&&t.push(a);var i}class ObjectLoader{constructor(e,t,i){this.dict=e;this.keys=t;this.xref=i;this.refSet=null}async load(){if(this.xref.stream.isDataLoaded)return;const{keys:e,dict:t}=this;this.refSet=new RefSet;const i=[];for(const a of e){const e=t.getRaw(a);void 0!==e&&i.push(e)}return this._walk(i)}async _walk(e){const t=[],i=[];for(;e.length;){let a=e.pop();if(a instanceof Ref){if(this.refSet.has(a))continue;try{this.refSet.put(a);a=this.xref.fetch(a)}catch(e){if(!(e instanceof MissingDataException)){warn(`ObjectLoader._walk - requesting all data: "${e}".`);this.refSet=null;const{manager:t}=this.xref.stream;return t.requestAllChunks()}t.push(a);i.push({begin:e.begin,end:e.end})}}if(a instanceof BaseStream){const e=a.getBaseStreams();if(e){let s=!1;for(const t of e)if(!t.isDataLoaded){s=!0;i.push({begin:t.start,end:t.end})}s&&t.push(a)}}addChildren(a,e)}if(i.length){await this.xref.stream.manager.requestRanges(i);for(const e of t)e instanceof Ref&&this.refSet.remove(e);return this._walk(t)}this.refSet=null}}const xs=Symbol(),Ls=Symbol(),Hs=Symbol(),Js=Symbol(),Ys=Symbol(),vs=Symbol(),Ks=Symbol(),Ts=Symbol(),qs=Symbol(),Os=Symbol("content"),Ws=Symbol("data"),js=Symbol(),Xs=Symbol("extra"),Zs=Symbol(),Vs=Symbol(),zs=Symbol(),_s=Symbol(),$s=Symbol(),Ar=Symbol(),er=Symbol(),tr=Symbol(),ir=Symbol(),ar=Symbol(),sr=Symbol(),rr=Symbol(),nr=Symbol(),gr=Symbol(),or=Symbol(),Ir=Symbol(),cr=Symbol(),Cr=Symbol(),hr=Symbol(),lr=Symbol(),Qr=Symbol(),Er=Symbol(),ur=Symbol(),dr=Symbol(),fr=Symbol(),pr=Symbol(),mr=Symbol(),yr=Symbol(),wr=Symbol(),Dr=Symbol(),br=Symbol(),Fr=Symbol(),Sr=Symbol("namespaceId"),kr=Symbol("nodeName"),Rr=Symbol(),Nr=Symbol(),Gr=Symbol(),Mr=Symbol(),Ur=Symbol(),xr=Symbol(),Lr=Symbol(),Hr=Symbol(),Jr=Symbol("root"),Yr=Symbol(),vr=Symbol(),Kr=Symbol(),Tr=Symbol(),qr=Symbol(),Or=Symbol(),Pr=Symbol(),Wr=Symbol(),jr=Symbol(),Xr=Symbol(),Zr=Symbol(),Vr=Symbol("uid"),zr=Symbol(),_r={config:{id:0,check:e=>e.startsWith("http://www.xfa.org/schema/xci/")},connectionSet:{id:1,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-connection-set/")},datasets:{id:2,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-data/")},form:{id:3,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-form/")},localeSet:{id:4,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-locale-set/")},pdf:{id:5,check:e=>"http://ns.adobe.com/xdp/pdf/"===e},signature:{id:6,check:e=>"http://www.w3.org/2000/09/xmldsig#"===e},sourceSet:{id:7,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-source-set/")},stylesheet:{id:8,check:e=>"http://www.w3.org/1999/XSL/Transform"===e},template:{id:9,check:e=>e.startsWith("http://www.xfa.org/schema/xfa-template/")},xdc:{id:10,check:e=>e.startsWith("http://www.xfa.org/schema/xdc/")},xdp:{id:11,check:e=>"http://ns.adobe.com/xdp/"===e},xfdf:{id:12,check:e=>"http://ns.adobe.com/xfdf/"===e},xhtml:{id:13,check:e=>"http://www.w3.org/1999/xhtml"===e},xmpmeta:{id:14,check:e=>"http://ns.adobe.com/xmpmeta/"===e}},$r={pt:e=>e,cm:e=>e/2.54*72,mm:e=>e/25.4*72,in:e=>72*e,px:e=>e},An=/([+-]?\d+\.?\d*)(.*)/;function stripQuotes(e){return e.startsWith("'")||e.startsWith('"')?e.slice(1,-1):e}function getInteger({data:e,defaultValue:t,validate:i}){if(!e)return t;e=e.trim();const a=parseInt(e,10);return!isNaN(a)&&i(a)?a:t}function getFloat({data:e,defaultValue:t,validate:i}){if(!e)return t;e=e.trim();const a=parseFloat(e);return!isNaN(a)&&i(a)?a:t}function getKeyword({data:e,defaultValue:t,validate:i}){return e&&i(e=e.trim())?e:t}function getStringOption(e,t){return getKeyword({data:e,defaultValue:t[0],validate:e=>t.includes(e)})}function getMeasurement(e,t="0"){t||="0";if(!e)return getMeasurement(t);const i=e.trim().match(An);if(!i)return getMeasurement(t);const[,a,s]=i,r=parseFloat(a);if(isNaN(r))return getMeasurement(t);if(0===r)return 0;const n=$r[s];return n?n(r):r}function getRatio(e){if(!e)return{num:1,den:1};const t=e.trim().split(/\s*:\s*/).map((e=>parseFloat(e))).filter((e=>!isNaN(e)));1===t.length&&t.push(1);if(0===t.length)return{num:1,den:1};const[i,a]=t;return{num:i,den:a}}function getRelevant(e){return e?e.trim().split(/\s+/).map((e=>({excluded:"-"===e[0],viewname:e.substring(1)}))):[]}class HTMLResult{static get FAILURE(){return shadow(this,"FAILURE",new HTMLResult(!1,null,null,null))}static get EMPTY(){return shadow(this,"EMPTY",new HTMLResult(!0,null,null,null))}constructor(e,t,i,a){this.success=e;this.html=t;this.bbox=i;this.breakNode=a}isBreak(){return!!this.breakNode}static breakNode(e){return new HTMLResult(!1,null,null,e)}static success(e,t=null){return new HTMLResult(!0,e,t,null)}}class FontFinder{constructor(e){this.fonts=new Map;this.cache=new Map;this.warned=new Set;this.defaultFont=null;this.add(e)}add(e,t=null){for(const t of e)this.addPdfFont(t);for(const e of this.fonts.values())e.regular||(e.regular=e.italic||e.bold||e.bolditalic);if(!t||0===t.size)return;const i=this.fonts.get("PdfJS-Fallback-PdfJS-XFA");for(const e of t)this.fonts.set(e,i)}addPdfFont(e){const t=e.cssFontInfo,i=t.fontFamily;let a=this.fonts.get(i);if(!a){a=Object.create(null);this.fonts.set(i,a);this.defaultFont||(this.defaultFont=a)}let s="";const r=parseFloat(t.fontWeight);0!==parseFloat(t.italicAngle)?s=r>=700?"bolditalic":"italic":r>=700&&(s="bold");if(!s){(e.name.includes("Bold")||e.psName?.includes("Bold"))&&(s="bold");(e.name.includes("Italic")||e.name.endsWith("It")||e.psName?.includes("Italic")||e.psName?.endsWith("It"))&&(s+="italic")}s||(s="regular");a[s]=e}getDefault(){return this.defaultFont}find(e,t=!0){let i=this.fonts.get(e)||this.cache.get(e);if(i)return i;const a=/,|-|_| |bolditalic|bold|italic|regular|it/gi;let s=e.replaceAll(a,"");i=this.fonts.get(s);if(i){this.cache.set(e,i);return i}s=s.toLowerCase();const r=[];for(const[e,t]of this.fonts.entries())e.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(t);if(0===r.length)for(const[,e]of this.fonts.entries())e.regular.name?.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(e);if(0===r.length){s=s.replaceAll(/psmt|mt/gi,"");for(const[e,t]of this.fonts.entries())e.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(t)}if(0===r.length)for(const e of this.fonts.values())e.regular.name?.replaceAll(a,"").toLowerCase().startsWith(s)&&r.push(e);if(r.length>=1){1!==r.length&&t&&warn(`XFA - Too many choices to guess the correct font: ${e}`);this.cache.set(e,r[0]);return r[0]}if(t&&!this.warned.has(e)){this.warned.add(e);warn(`XFA - Cannot find the font: ${e}`)}return null}}function selectFont(e,t){return"italic"===e.posture?"bold"===e.weight?t.bolditalic:t.italic:"bold"===e.weight?t.bold:t.regular}class FontInfo{constructor(e,t,i,a){this.lineHeight=i;this.paraMargin=t||{top:0,bottom:0,left:0,right:0};if(!e){[this.pdfFont,this.xfaFont]=this.defaultFont(a);return}this.xfaFont={typeface:e.typeface,posture:e.posture,weight:e.weight,size:e.size,letterSpacing:e.letterSpacing};const s=a.find(e.typeface);if(s){this.pdfFont=selectFont(e,s);this.pdfFont||([this.pdfFont,this.xfaFont]=this.defaultFont(a))}else[this.pdfFont,this.xfaFont]=this.defaultFont(a)}defaultFont(e){const t=e.find("Helvetica",!1)||e.find("Myriad Pro",!1)||e.find("Arial",!1)||e.getDefault();if(t?.regular){const e=t.regular;return[e,{typeface:e.cssFontInfo.fontFamily,posture:"normal",weight:"normal",size:10,letterSpacing:0}]}return[null,{typeface:"Courier",posture:"normal",weight:"normal",size:10,letterSpacing:0}]}}class FontSelector{constructor(e,t,i,a){this.fontFinder=a;this.stack=[new FontInfo(e,t,i,a)]}pushData(e,t,i){const a=this.stack.at(-1);for(const t of["typeface","posture","weight","size","letterSpacing"])e[t]||(e[t]=a.xfaFont[t]);for(const e of["top","bottom","left","right"])isNaN(t[e])&&(t[e]=a.paraMargin[e]);const s=new FontInfo(e,t,i||a.lineHeight,this.fontFinder);s.pdfFont||(s.pdfFont=a.pdfFont);this.stack.push(s)}popFont(){this.stack.pop()}topFont(){return this.stack.at(-1)}}class TextMeasure{constructor(e,t,i,a){this.glyphs=[];this.fontSelector=new FontSelector(e,t,i,a);this.extraHeight=0}pushData(e,t,i){this.fontSelector.pushData(e,t,i)}popFont(e){return this.fontSelector.popFont()}addPara(){const e=this.fontSelector.topFont();this.extraHeight+=e.paraMargin.top+e.paraMargin.bottom}addString(e){if(!e)return;const t=this.fontSelector.topFont(),i=t.xfaFont.size;if(t.pdfFont){const a=t.xfaFont.letterSpacing,s=t.pdfFont,r=s.lineHeight||1.2,n=t.lineHeight||Math.max(1.2,r)*i,g=r-(void 0===s.lineGap?.2:s.lineGap),o=Math.max(1,g)*i,c=i/1e3,C=s.defaultWidth||s.charsToGlyphs(" ")[0].width;for(const t of e.split(/[\u2029\n]/)){const e=s.encodeString(t).join(""),i=s.charsToGlyphs(e);for(const e of i){const t=e.width||C;this.glyphs.push([t*c+a,n,o,e.unicode,!1])}this.glyphs.push([0,0,0,"\n",!0])}this.glyphs.pop()}else{for(const t of e.split(/[\u2029\n]/)){for(const e of t.split(""))this.glyphs.push([i,1.2*i,i,e,!1]);this.glyphs.push([0,0,0,"\n",!0])}this.glyphs.pop()}}compute(e){let t=-1,i=0,a=0,s=0,r=0,n=0,g=!1,o=!0;for(let c=0,C=this.glyphs.length;ce){a=Math.max(a,r);r=0;s+=n;n=d;t=-1;i=0;g=!0;o=!1}else{n=Math.max(d,n);i=r;r+=C;t=c}else if(r+C>e){s+=n;n=d;if(-1!==t){c=t;a=Math.max(a,i);r=0;t=-1;i=0}else{a=Math.max(a,r);r=C}g=!0;o=!1}else{r+=C;n=Math.max(d,n)}}a=Math.max(a,r);s+=n+this.extraHeight;return{width:1.02*a,height:s,isBroken:g}}}const en=/^[^.[]+/,tn=/^[^\]]+/,an=0,sn=1,rn=2,nn=3,gn=4,on=new Map([["$data",(e,t)=>e.datasets?e.datasets.data:e],["$record",(e,t)=>(e.datasets?e.datasets.data:e)[rr]()[0]],["$template",(e,t)=>e.template],["$connectionSet",(e,t)=>e.connectionSet],["$form",(e,t)=>e.form],["$layout",(e,t)=>e.layout],["$host",(e,t)=>e.host],["$dataWindow",(e,t)=>e.dataWindow],["$event",(e,t)=>e.event],["!",(e,t)=>e.datasets],["$xfa",(e,t)=>e],["xfa",(e,t)=>e],["$",(e,t)=>t]]),In=new WeakMap;function parseExpression(e,t,i=!0){let a=e.match(en);if(!a)return null;let[s]=a;const r=[{name:s,cacheName:"."+s,index:0,js:null,formCalc:null,operator:an}];let n=s.length;for(;n0&&C.push(e)}if(0!==C.length||g||0!==o)e=isFinite(c)?C.filter((e=>ce[c])):C.flat();else{const i=t[Ir]();if(!(t=i))return null;o=-1;e=[t]}}return 0===e.length?null:e}function createDataNode(e,t,i){const a=parseExpression(i);if(!a)return null;if(a.some((e=>e.operator===sn)))return null;const s=on.get(a[0].name);let r=0;if(s){e=s(e,t);r=1}else e=t||e;for(let t=a.length;re[Pr]())).join("")}get[hn](){const e=Object.getPrototypeOf(this);if(!e._attributes){const t=e._attributes=new Set;for(const e of Object.getOwnPropertyNames(this)){if(null===this[e]||this[e]instanceof XFAObject||this[e]instanceof XFAObjectArray)break;t.add(e)}}return shadow(this,hn,e._attributes)}[pr](e){let t=this;for(;t;){if(t===e)return!0;t=t[Ir]()}return!1}[Ir](){return this[wn]}[or](){return this[Ir]()}[rr](e=null){return e?this[e]:this[ln]}[js](){const e=Object.create(null);this[Os]&&(e.$content=this[Os]);for(const t of Object.getOwnPropertyNames(this)){const i=this[t];null!==i&&(i instanceof XFAObject?e[t]=i[js]():i instanceof XFAObjectArray?i.isEmpty()||(e[t]=i.dump()):e[t]=i)}return e}[Zr](){return null}[jr](){return HTMLResult.EMPTY}*[nr](){for(const e of this[rr]())yield e}*[un](e,t){for(const i of this[nr]())if(!e||t===e.has(i[kr])){const e=this[$s](),t=i[jr](e);t.success||(this[Xs].failingNode=i);yield t}}[Vs](){return null}[Ls](e,t){this[Xs].children.push(e)}[$s](){}[Js]({filter:e=null,include:t=!0}){if(this[Xs].generator){const e=this[$s](),t=this[Xs].failingNode[jr](e);if(!t.success)return t;t.html&&this[Ls](t.html,t.bbox);delete this[Xs].failingNode}else this[Xs].generator=this[un](e,t);for(;;){const e=this[Xs].generator.next();if(e.done)break;const t=e.value;if(!t.success)return t;t.html&&this[Ls](t.html,t.bbox)}this[Xs].generator=null;return HTMLResult.EMPTY}[Tr](e){this[bn]=new Set(Object.keys(e))}[fn](e){const t=this[hn],i=this[bn];return[...e].filter((e=>t.has(e)&&!i.has(e)))}[Yr](e,t=new Set){for(const i of this[ln])i[Dn](e,t)}[Dn](e,t){const i=this[dn](e,t);i?this[cn](i,e,t):this[Yr](e,t)}[dn](e,t){const{use:i,usehref:a}=this;if(!i&&!a)return null;let s=null,r=null,n=null,g=i;if(a){g=a;a.startsWith("#som(")&&a.endsWith(")")?r=a.slice(5,-1):a.startsWith(".#som(")&&a.endsWith(")")?r=a.slice(6,-1):a.startsWith("#")?n=a.slice(1):a.startsWith(".#")&&(n=a.slice(2))}else i.startsWith("#")?n=i.slice(1):r=i;this.use=this.usehref="";if(n)s=e.get(n);else{s=searchNode(e.get(Jr),this,r,!0,!1);s&&(s=s[0])}if(!s){warn(`XFA - Invalid prototype reference: ${g}.`);return null}if(s[kr]!==this[kr]){warn(`XFA - Incompatible prototype: ${s[kr]} !== ${this[kr]}.`);return null}if(t.has(s)){warn("XFA - Cycle detected in prototypes use.");return null}t.add(s);const o=s[dn](e,t);o&&s[cn](o,e,t);s[Yr](e,t);t.delete(s);return s}[cn](e,t,i){if(i.has(e)){warn("XFA - Cycle detected in prototypes use.");return}!this[Os]&&e[Os]&&(this[Os]=e[Os]);new Set(i).add(e);for(const t of this[fn](e[bn])){this[t]=e[t];this[bn]&&this[bn].add(t)}for(const a of Object.getOwnPropertyNames(this)){if(this[hn].has(a))continue;const s=this[a],r=e[a];if(s instanceof XFAObjectArray){for(const e of s[ln])e[Dn](t,i);for(let a=s[ln].length,n=r[ln].length;aXFAObject[Bn](e))):"object"==typeof e&&null!==e?Object.assign({},e):e}[Ts](){const e=Object.create(Object.getPrototypeOf(this));for(const t of Object.getOwnPropertySymbols(this))try{e[t]=this[t]}catch{shadow(e,t,this[t])}e[Vr]=`${e[kr]}${Sn++}`;e[ln]=[];for(const t of Object.getOwnPropertyNames(this)){if(this[hn].has(t)){e[t]=XFAObject[Bn](this[t]);continue}const i=this[t];e[t]=i instanceof XFAObjectArray?new XFAObjectArray(i[mn]):null}for(const t of this[ln]){const i=t[kr],a=t[Ts]();e[ln].push(a);a[wn]=e;null===e[i]?e[i]=a:e[i][ln].push(a)}return e}[rr](e=null){return e?this[ln].filter((t=>t[kr]===e)):this[ln]}[Ar](e){return this[e]}[er](e,t,i=!0){return Array.from(this[tr](e,t,i))}*[tr](e,t,i=!0){if("parent"!==e){for(const i of this[ln]){i[kr]===e&&(yield i);i.name===e&&(yield i);(t||i[Dr]())&&(yield*i[tr](e,t,!1))}i&&this[hn].has(e)&&(yield new XFAAttribute(this,e,this[e]))}else yield this[wn]}}class XFAObjectArray{constructor(e=1/0){this[mn]=e;this[ln]=[]}get isXFAObject(){return!1}get isXFAObjectArray(){return!0}push(e){if(this[ln].length<=this[mn]){this[ln].push(e);return!0}warn(`XFA - node "${e[kr]}" accepts no more than ${this[mn]} children`);return!1}isEmpty(){return 0===this[ln].length}dump(){return 1===this[ln].length?this[ln][0][js]():this[ln].map((e=>e[js]()))}[Ts](){const e=new XFAObjectArray(this[mn]);e[ln]=this[ln].map((e=>e[Ts]()));return e}get children(){return this[ln]}clear(){this[ln].length=0}}class XFAAttribute{constructor(e,t,i){this[wn]=e;this[kr]=t;this[Os]=i;this[qs]=!1;this[Vr]="attribute"+Sn++}[Ir](){return this[wn]}[fr](){return!0}[ir](){return this[Os].trim()}[qr](e){e=e.value||"";this[Os]=e.toString()}[Pr](){return this[Os]}[pr](e){return this[wn]===e||this[wn][pr](e)}}class XmlObject extends XFAObject{constructor(e,t,i={}){super(e,t);this[Os]="";this[Qn]=null;if("#text"!==t){const e=new Map;this[Cn]=e;for(const[t,a]of Object.entries(i))e.set(t,new XFAAttribute(this,t,a));if(i.hasOwnProperty(Rr)){const e=i[Rr].xfa.dataNode;void 0!==e&&("dataGroup"===e?this[Qn]=!1:"dataValue"===e&&(this[Qn]=!0))}}this[qs]=!1}[Xr](e){const t=this[kr];if("#text"===t){e.push(encodeToXmlString(this[Os]));return}const i=utf8StringToString(t),a=this[Sr]===kn?"xfa:":"";e.push(`<${a}${i}`);for(const[t,i]of this[Cn].entries()){const a=utf8StringToString(t);e.push(` ${a}="${encodeToXmlString(i[Os])}"`)}null!==this[Qn]&&(this[Qn]?e.push(' xfa:dataNode="dataValue"'):e.push(' xfa:dataNode="dataGroup"'));if(this[Os]||0!==this[ln].length){e.push(">");if(this[Os])"string"==typeof this[Os]?e.push(encodeToXmlString(this[Os])):this[Os][Xr](e);else for(const t of this[ln])t[Xr](e);e.push(``)}else e.push("/>")}[Nr](e){if(this[Os]){const e=new XmlObject(this[Sr],"#text");this[Hs](e);e[Os]=this[Os];this[Os]=""}this[Hs](e);return!0}[Mr](e){this[Os]+=e}[Zs](){if(this[Os]&&this[ln].length>0){const e=new XmlObject(this[Sr],"#text");this[Hs](e);e[Os]=this[Os];delete this[Os]}}[jr](){return"#text"===this[kr]?HTMLResult.success({name:"#text",value:this[Os]}):HTMLResult.EMPTY}[rr](e=null){return e?this[ln].filter((t=>t[kr]===e)):this[ln]}[_s](){return this[Cn]}[Ar](e){const t=this[Cn].get(e);return void 0!==t?t:this[rr](e)}*[tr](e,t){const i=this[Cn].get(e);i&&(yield i);for(const i of this[ln]){i[kr]===e&&(yield i);t&&(yield*i[tr](e,t))}}*[zs](e,t){const i=this[Cn].get(e);!i||t&&i[qs]||(yield i);for(const i of this[ln])yield*i[zs](e,t)}*[sr](e,t,i){for(const a of this[ln]){a[kr]!==e||i&&a[qs]||(yield a);t&&(yield*a[sr](e,t,i))}}[fr](){return null===this[Qn]?0===this[ln].length||this[ln][0][Sr]===_r.xhtml.id:this[Qn]}[ir](){return null===this[Qn]?0===this[ln].length?this[Os].trim():this[ln][0][Sr]===_r.xhtml.id?this[ln][0][Pr]().trim():null:this[Os].trim()}[qr](e){e=e.value||"";this[Os]=e.toString()}[js](e=!1){const t=Object.create(null);e&&(t.$ns=this[Sr]);this[Os]&&(t.$content=this[Os]);t.$name=this[kr];t.children=[];for(const i of this[ln])t.children.push(i[js](e));t.attributes=Object.create(null);for(const[e,i]of this[Cn])t.attributes[e]=i[Os];return t}}class ContentObject extends XFAObject{constructor(e,t){super(e,t);this[Os]=""}[Mr](e){this[Os]+=e}[Zs](){}}class OptionObject extends ContentObject{constructor(e,t,i){super(e,t);this[yn]=i}[Zs](){this[Os]=getKeyword({data:this[Os],defaultValue:this[yn][0],validate:e=>this[yn].includes(e)})}[Ys](e){super[Ys](e);delete this[yn]}}class StringObject extends ContentObject{[Zs](){this[Os]=this[Os].trim()}}class IntegerObject extends ContentObject{constructor(e,t,i,a){super(e,t);this[En]=i;this[Fn]=a}[Zs](){this[Os]=getInteger({data:this[Os],defaultValue:this[En],validate:this[Fn]})}[Ys](e){super[Ys](e);delete this[En];delete this[Fn]}}class Option01 extends IntegerObject{constructor(e,t){super(e,t,0,(e=>1===e))}}class Option10 extends IntegerObject{constructor(e,t){super(e,t,1,(e=>0===e))}}function measureToString(e){return"string"==typeof e?"0px":Number.isInteger(e)?`${e}px`:`${e.toFixed(2)}px`}const Rn={anchorType(e,t){const i=e[or]();if(i&&(!i.layout||"position"===i.layout)){"transform"in t||(t.transform="");switch(e.anchorType){case"bottomCenter":t.transform+="translate(-50%, -100%)";break;case"bottomLeft":t.transform+="translate(0,-100%)";break;case"bottomRight":t.transform+="translate(-100%,-100%)";break;case"middleCenter":t.transform+="translate(-50%,-50%)";break;case"middleLeft":t.transform+="translate(0,-50%)";break;case"middleRight":t.transform+="translate(-100%,-50%)";break;case"topCenter":t.transform+="translate(-50%,0)";break;case"topRight":t.transform+="translate(-100%,0)"}}},dimensions(e,t){const i=e[or]();let a=e.w;const s=e.h;if(i.layout?.includes("row")){const t=i[Xs],s=e.colSpan;let r;if(-1===s){r=t.columnWidths.slice(t.currentColumn).reduce(((e,t)=>e+t),0);t.currentColumn=0}else{r=t.columnWidths.slice(t.currentColumn,t.currentColumn+s).reduce(((e,t)=>e+t),0);t.currentColumn=(t.currentColumn+e.colSpan)%t.columnWidths.length}isNaN(r)||(a=e.w=r)}t.width=""!==a?measureToString(a):"auto";t.height=""!==s?measureToString(s):"auto"},position(e,t){const i=e[or]();if(!i?.layout||"position"===i.layout){t.position="absolute";t.left=measureToString(e.x);t.top=measureToString(e.y)}},rotate(e,t){if(e.rotate){"transform"in t||(t.transform="");t.transform+=`rotate(-${e.rotate}deg)`;t.transformOrigin="top left"}},presence(e,t){switch(e.presence){case"invisible":t.visibility="hidden";break;case"hidden":case"inactive":t.display="none"}},hAlign(e,t){if("para"===e[kr])switch(e.hAlign){case"justifyAll":t.textAlign="justify-all";break;case"radix":t.textAlign="left";break;default:t.textAlign=e.hAlign}else switch(e.hAlign){case"left":t.alignSelf="start";break;case"center":t.alignSelf="center";break;case"right":t.alignSelf="end"}},margin(e,t){e.margin&&(t.margin=e.margin[Zr]().margin)}};function setMinMaxDimensions(e,t){if("position"===e[or]().layout){e.minW>0&&(t.minWidth=measureToString(e.minW));e.maxW>0&&(t.maxWidth=measureToString(e.maxW));e.minH>0&&(t.minHeight=measureToString(e.minH));e.maxH>0&&(t.maxHeight=measureToString(e.maxH))}}function layoutText(e,t,i,a,s,r){const n=new TextMeasure(t,i,a,s);"string"==typeof e?n.addString(e):e[Ur](n);return n.compute(r)}function layoutNode(e,t){let i=null,a=null,s=!1;if((!e.w||!e.h)&&e.value){let r=0,n=0;if(e.margin){r=e.margin.leftInset+e.margin.rightInset;n=e.margin.topInset+e.margin.bottomInset}let g=null,o=null;if(e.para){o=Object.create(null);g=""===e.para.lineHeight?null:e.para.lineHeight;o.top=""===e.para.spaceAbove?0:e.para.spaceAbove;o.bottom=""===e.para.spaceBelow?0:e.para.spaceBelow;o.left=""===e.para.marginLeft?0:e.para.marginLeft;o.right=""===e.para.marginRight?0:e.para.marginRight}let c=e.font;if(!c){const t=e[cr]();let i=e[Ir]();for(;i&&i!==t;){if(i.font){c=i.font;break}i=i[Ir]()}}const C=(e.w||t.width)-r,h=e[Cr].fontFinder;if(e.value.exData&&e.value.exData[Os]&&"text/html"===e.value.exData.contentType){const t=layoutText(e.value.exData[Os],c,o,g,h,C);a=t.width;i=t.height;s=t.isBroken}else{const t=e.value[Pr]();if(t){const e=layoutText(t,c,o,g,h,C);a=e.width;i=e.height;s=e.isBroken}}null===a||e.w||(a+=r);null===i||e.h||(i+=n)}return{w:a,h:i,isBroken:s}}function computeBbox(e,t,i){let a;if(""!==e.w&&""!==e.h)a=[e.x,e.y,e.w,e.h];else{if(!i)return null;let s=e.w;if(""===s){if(0===e.maxW){const t=e[or]();s="position"===t.layout&&""!==t.w?0:e.minW}else s=Math.min(e.maxW,i.width);t.attributes.style.width=measureToString(s)}let r=e.h;if(""===r){if(0===e.maxH){const t=e[or]();r="position"===t.layout&&""!==t.h?0:e.minH}else r=Math.min(e.maxH,i.height);t.attributes.style.height=measureToString(r)}a=[e.x,e.y,s,r]}return a}function fixDimensions(e){const t=e[or]();if(t.layout?.includes("row")){const i=t[Xs],a=e.colSpan;let s;s=-1===a?i.columnWidths.slice(i.currentColumn).reduce(((e,t)=>e+t),0):i.columnWidths.slice(i.currentColumn,i.currentColumn+a).reduce(((e,t)=>e+t),0);isNaN(s)||(e.w=s)}t.layout&&"position"!==t.layout&&(e.x=e.y=0);"table"===e.layout&&""===e.w&&Array.isArray(e.columnWidths)&&(e.w=e.columnWidths.reduce(((e,t)=>e+t),0))}function layoutClass(e){switch(e.layout){case"position":default:return"xfaPosition";case"lr-tb":return"xfaLrTb";case"rl-row":return"xfaRlRow";case"rl-tb":return"xfaRlTb";case"row":return"xfaRow";case"table":return"xfaTable";case"tb":return"xfaTb"}}function toStyle(e,...t){const i=Object.create(null);for(const a of t){const t=e[a];if(null!==t)if(Rn.hasOwnProperty(a))Rn[a](e,i);else if(t instanceof XFAObject){const e=t[Zr]();e?Object.assign(i,e):warn(`(DEBUG) - XFA - style for ${a} not implemented yet`)}}return i}function createWrapper(e,t){const{attributes:i}=t,{style:a}=i,s={name:"div",attributes:{class:["xfaWrapper"],style:Object.create(null)},children:[]};i.class.push("xfaWrapped");if(e.border){const{widths:i,insets:r}=e.border[Xs];let n,g,o=r[0],c=r[3];const C=r[0]+r[2],h=r[1]+r[3];switch(e.border.hand){case"even":o-=i[0]/2;c-=i[3]/2;n=`calc(100% + ${(i[1]+i[3])/2-h}px)`;g=`calc(100% + ${(i[0]+i[2])/2-C}px)`;break;case"left":o-=i[0];c-=i[3];n=`calc(100% + ${i[1]+i[3]-h}px)`;g=`calc(100% + ${i[0]+i[2]-C}px)`;break;case"right":n=h?`calc(100% - ${h}px)`:"100%";g=C?`calc(100% - ${C}px)`:"100%"}const l=["xfaBorder"];isPrintOnly(e.border)&&l.push("xfaPrintOnly");const Q={name:"div",attributes:{class:l,style:{top:`${o}px`,left:`${c}px`,width:n,height:g}},children:[]};for(const e of["border","borderWidth","borderColor","borderRadius","borderStyle"])if(void 0!==a[e]){Q.attributes.style[e]=a[e];delete a[e]}s.children.push(Q,t)}else s.children.push(t);for(const e of["background","backgroundClip","top","left","width","height","minWidth","minHeight","maxWidth","maxHeight","transform","transformOrigin","visibility"])if(void 0!==a[e]){s.attributes.style[e]=a[e];delete a[e]}s.attributes.style.position="absolute"===a.position?"absolute":"relative";delete a.position;if(a.alignSelf){s.attributes.style.alignSelf=a.alignSelf;delete a.alignSelf}return s}function fixTextIndent(e){const t=getMeasurement(e.textIndent,"0px");if(t>=0)return;const i="padding"+("left"===("right"===e.textAlign?"right":"left")?"Left":"Right"),a=getMeasurement(e[i],"0px");e[i]=a-t+"px"}function setAccess(e,t){switch(e.access){case"nonInteractive":t.push("xfaNonInteractive");break;case"readOnly":t.push("xfaReadOnly");break;case"protected":t.push("xfaDisabled")}}function isPrintOnly(e){return e.relevant.length>0&&!e.relevant[0].excluded&&"print"===e.relevant[0].viewname}function getCurrentPara(e){const t=e[cr]()[Xs].paraStack;return t.length?t.at(-1):null}function setPara(e,t,i){if(i.attributes.class?.includes("xfaRich")){if(t){""===e.h&&(t.height="auto");""===e.w&&(t.width="auto")}const a=getCurrentPara(e);if(a){const e=i.attributes.style;e.display="flex";e.flexDirection="column";switch(a.vAlign){case"top":e.justifyContent="start";break;case"bottom":e.justifyContent="end";break;case"middle":e.justifyContent="center"}const t=a[Zr]();for(const[i,a]of Object.entries(t))i in e||(e[i]=a)}}}function setFontFamily(e,t,i,a){if(!i){delete a.fontFamily;return}const s=stripQuotes(e.typeface);a.fontFamily=`"${s}"`;const r=i.find(s);if(r){const{fontFamily:i}=r.regular.cssFontInfo;i!==s&&(a.fontFamily=`"${i}"`);const n=getCurrentPara(t);if(n&&""!==n.lineHeight)return;if(a.lineHeight)return;const g=selectFont(e,r);g&&(a.lineHeight=Math.max(1.2,g.lineHeight))}}function fixURL(e){const t=createValidAbsoluteUrl(e,null,{addDefaultProtocol:!0,tryConvertEncoding:!0});return t?t.href:null}function createLine(e,t){return{name:"div",attributes:{class:["lr-tb"===e.layout?"xfaLr":"xfaRl"]},children:t}}function flushHTML(e){if(!e[Xs])return null;const t={name:"div",attributes:e[Xs].attributes,children:e[Xs].children};if(e[Xs].failingNode){const i=e[Xs].failingNode[Vs]();i&&(e.layout.endsWith("-tb")?t.children.push(createLine(e,[i])):t.children.push(i))}return 0===t.children.length?null:t}function addHTML(e,t,i){const a=e[Xs],s=a.availableSpace,[r,n,g,o]=i;switch(e.layout){case"position":a.width=Math.max(a.width,r+g);a.height=Math.max(a.height,n+o);a.children.push(t);break;case"lr-tb":case"rl-tb":if(!a.line||1===a.attempt){a.line=createLine(e,[]);a.children.push(a.line);a.numberInLine=0}a.numberInLine+=1;a.line.children.push(t);if(0===a.attempt){a.currentWidth+=g;a.height=Math.max(a.height,a.prevHeight+o)}else{a.currentWidth=g;a.prevHeight=a.height;a.height+=o;a.attempt=0}a.width=Math.max(a.width,a.currentWidth);break;case"rl-row":case"row":{a.children.push(t);a.width+=g;a.height=Math.max(a.height,o);const e=measureToString(a.height);for(const t of a.children)t.attributes.style.height=e;break}case"table":case"tb":a.width=Math.min(s.width,Math.max(a.width,g));a.height+=o;a.children.push(t)}}function getAvailableSpace(e){const t=e[Xs].availableSpace,i=e.margin?e.margin.topInset+e.margin.bottomInset:0,a=e.margin?e.margin.leftInset+e.margin.rightInset:0;switch(e.layout){case"lr-tb":case"rl-tb":return 0===e[Xs].attempt?{width:t.width-a-e[Xs].currentWidth,height:t.height-i-e[Xs].prevHeight}:{width:t.width-a,height:t.height-i-e[Xs].height};case"rl-row":case"row":return{width:e[Xs].columnWidths.slice(e[Xs].currentColumn).reduce(((e,t)=>e+t)),height:t.height-a};case"table":case"tb":return{width:t.width-a,height:t.height-i-e[Xs].height};default:return t}}function checkDimensions(e,t){if(null===e[cr]()[Xs].firstUnsplittable)return!0;if(0===e.w||0===e.h)return!0;const i=e[or](),a=i[Xs]?.attempt||0,[,s,r,n]=function getTransformedBBox(e){let t,i,a=""===e.w?NaN:e.w,s=""===e.h?NaN:e.h,[r,n]=[0,0];switch(e.anchorType||""){case"bottomCenter":[r,n]=[a/2,s];break;case"bottomLeft":[r,n]=[0,s];break;case"bottomRight":[r,n]=[a,s];break;case"middleCenter":[r,n]=[a/2,s/2];break;case"middleLeft":[r,n]=[0,s/2];break;case"middleRight":[r,n]=[a,s/2];break;case"topCenter":[r,n]=[a/2,0];break;case"topRight":[r,n]=[a,0]}switch(e.rotate||0){case 0:[t,i]=[-r,-n];break;case 90:[t,i]=[-n,r];[a,s]=[s,-a];break;case 180:[t,i]=[r,n];[a,s]=[-a,-s];break;case 270:[t,i]=[n,-r];[a,s]=[-s,a]}return[e.x+t+Math.min(0,a),e.y+i+Math.min(0,s),Math.abs(a),Math.abs(s)]}(e);switch(i.layout){case"lr-tb":case"rl-tb":return 0===a?e[cr]()[Xs].noLayoutFailure?""!==e.w?Math.round(r-t.width)<=2:t.width>2:!(""!==e.h&&Math.round(n-t.height)>2)&&(""!==e.w?Math.round(r-t.width)<=2||0===i[Xs].numberInLine&&t.height>2:t.width>2):!!e[cr]()[Xs].noLayoutFailure||!(""!==e.h&&Math.round(n-t.height)>2)&&((""===e.w||Math.round(r-t.width)<=2||!i[wr]())&&t.height>2);case"table":case"tb":return!!e[cr]()[Xs].noLayoutFailure||(""===e.h||e[yr]()?(""===e.w||Math.round(r-t.width)<=2||!i[wr]())&&t.height>2:Math.round(n-t.height)<=2);case"position":if(e[cr]()[Xs].noLayoutFailure)return!0;if(""===e.h||Math.round(n+s-t.height)<=2)return!0;return n+s>e[cr]()[Xs].currentContentArea.h;case"rl-row":case"row":return!!e[cr]()[Xs].noLayoutFailure||(""===e.h||Math.round(n-t.height)<=2);default:return!0}}const Nn=_r.template.id,Gn="http://www.w3.org/2000/svg",Mn=/^H(\d+)$/,Un=new Set(["image/gif","image/jpeg","image/jpg","image/pjpeg","image/png","image/apng","image/x-png","image/bmp","image/x-ms-bmp","image/tiff","image/tif","application/octet-stream"]),xn=[[[66,77],"image/bmp"],[[255,216,255],"image/jpeg"],[[73,73,42,0],"image/tiff"],[[77,77,0,42],"image/tiff"],[[71,73,70,56,57,97],"image/gif"],[[137,80,78,71,13,10,26,10],"image/png"]];function getBorderDims(e){if(!e||!e.border)return{w:0,h:0};const t=e.border[ar]();return t?{w:t.widths[0]+t.widths[2]+t.insets[0]+t.insets[2],h:t.widths[1]+t.widths[3]+t.insets[1]+t.insets[3]}:{w:0,h:0}}function hasMargin(e){return e.margin&&(e.margin.topInset||e.margin.rightInset||e.margin.bottomInset||e.margin.leftInset)}function _setValue(e,t){if(!e.value){const t=new Value({});e[Hs](t);e.value=t}e.value[qr](t)}function*getContainedChildren(e){for(const t of e[rr]())t instanceof SubformSet?yield*t[nr]():yield t}function isRequired(e){return"error"===e.validate?.nullTest}function setTabIndex(e){for(;e;){if(!e.traversal){e[Or]=e[Ir]()[Or];return}if(e[Or])return;let t=null;for(const i of e.traversal[rr]())if("next"===i.operation){t=i;break}if(!t||!t.ref){e[Or]=e[Ir]()[Or];return}const i=e[cr]();e[Or]=++i[Or];const a=i[vr](t.ref,e);if(!a)return;e=a[0]}}function applyAssist(e,t){const i=e.assist;if(i){const e=i[jr]();e&&(t.title=e);const a=i.role.match(Mn);if(a){const e="heading",i=a[1];t.role=e;t["aria-level"]=i}}if("table"===e.layout)t.role="table";else if("row"===e.layout)t.role="row";else{const i=e[Ir]();"row"===i.layout&&(t.role="TH"===i.assist?.role?"columnheader":"cell")}}function ariaLabel(e){if(!e.assist)return null;const t=e.assist;return t.speak&&""!==t.speak[Os]?t.speak[Os]:t.toolTip?t.toolTip[Os]:null}function valueToHtml(e){return HTMLResult.success({name:"div",attributes:{class:["xfaRich"],style:Object.create(null)},children:[{name:"span",attributes:{style:Object.create(null)},value:e}]})}function setFirstUnsplittable(e){const t=e[cr]();if(null===t[Xs].firstUnsplittable){t[Xs].firstUnsplittable=e;t[Xs].noLayoutFailure=!0}}function unsetFirstUnsplittable(e){const t=e[cr]();t[Xs].firstUnsplittable===e&&(t[Xs].noLayoutFailure=!1)}function handleBreak(e){if(e[Xs])return!1;e[Xs]=Object.create(null);if("auto"===e.targetType)return!1;const t=e[cr]();let i=null;if(e.target){i=t[vr](e.target,e[Ir]());if(!i)return!1;i=i[0]}const{currentPageArea:a,currentContentArea:s}=t[Xs];if("pageArea"===e.targetType){i instanceof PageArea||(i=null);if(e.startNew){e[Xs].target=i||a;return!0}if(i&&i!==a){e[Xs].target=i;return!0}return!1}i instanceof ContentArea||(i=null);const r=i&&i[Ir]();let n,g=r;if(e.startNew)if(i){const e=r.contentArea.children,t=e.indexOf(s),a=e.indexOf(i);-1!==t&&te;a[Xs].noLayoutFailure=!0;const n=t[jr](i);e[Ls](n.html,n.bbox);a[Xs].noLayoutFailure=s;t[or]=r}class AppearanceFilter extends StringObject{constructor(e){super(Nn,"appearanceFilter");this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Arc extends XFAObject{constructor(e){super(Nn,"arc",!0);this.circular=getInteger({data:e.circular,defaultValue:0,validate:e=>1===e});this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.startAngle=getFloat({data:e.startAngle,defaultValue:0,validate:e=>!0});this.sweepAngle=getFloat({data:e.sweepAngle,defaultValue:360,validate:e=>!0});this.use=e.use||"";this.usehref=e.usehref||"";this.edge=null;this.fill=null}[jr](){const e=this.edge||new Edge({}),t=e[Zr](),i=Object.create(null);"visible"===this.fill?.presence?Object.assign(i,this.fill[Zr]()):i.fill="transparent";i.strokeWidth=measureToString("visible"===e.presence?e.thickness:0);i.stroke=t.color;let a;const s={xmlns:Gn,style:{width:"100%",height:"100%",overflow:"visible"}};if(360===this.sweepAngle)a={name:"ellipse",attributes:{xmlns:Gn,cx:"50%",cy:"50%",rx:"50%",ry:"50%",style:i}};else{const e=this.startAngle*Math.PI/180,t=this.sweepAngle*Math.PI/180,r=this.sweepAngle>180?1:0,[n,g,o,c]=[50*(1+Math.cos(e)),50*(1-Math.sin(e)),50*(1+Math.cos(e+t)),50*(1-Math.sin(e+t))];a={name:"path",attributes:{xmlns:Gn,d:`M ${n} ${g} A 50 50 0 ${r} 0 ${o} ${c}`,vectorEffect:"non-scaling-stroke",style:i}};Object.assign(s,{viewBox:"0 0 100 100",preserveAspectRatio:"none"})}const r={name:"svg",children:[a],attributes:s};if(hasMargin(this[Ir]()[Ir]()))return HTMLResult.success({name:"div",attributes:{style:{display:"inline",width:"100%",height:"100%"}},children:[r]});r.attributes.style.position="absolute";return HTMLResult.success(r)}}class Area extends XFAObject{constructor(e){super(Nn,"area",!0);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.id=e.id||"";this.name=e.name||"";this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.desc=null;this.extras=null;this.area=new XFAObjectArray;this.draw=new XFAObjectArray;this.exObject=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.field=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray}*[nr](){yield*getContainedChildren(this)}[Dr](){return!0}[dr](){return!0}[Ls](e,t){const[i,a,s,r]=t;this[Xs].width=Math.max(this[Xs].width,i+s);this[Xs].height=Math.max(this[Xs].height,a+r);this[Xs].children.push(e)}[$s](){return this[Xs].availableSpace}[jr](e){const t=toStyle(this,"position"),i={style:t,id:this[Vr],class:["xfaArea"]};isPrintOnly(this)&&i.class.push("xfaPrintOnly");this.name&&(i.xfaName=this.name);const a=[];this[Xs]={children:a,width:0,height:0,availableSpace:e};const s=this[Js]({filter:new Set(["area","draw","field","exclGroup","subform","subformSet"]),include:!0});if(!s.success){if(s.isBreak())return s;delete this[Xs];return HTMLResult.FAILURE}t.width=measureToString(this[Xs].width);t.height=measureToString(this[Xs].height);const r={name:"div",attributes:i,children:a},n=[this.x,this.y,this[Xs].width,this[Xs].height];delete this[Xs];return HTMLResult.success(r,n)}}class Assist extends XFAObject{constructor(e){super(Nn,"assist",!0);this.id=e.id||"";this.role=e.role||"";this.use=e.use||"";this.usehref=e.usehref||"";this.speak=null;this.toolTip=null}[jr](){return this.toolTip?.[Os]||null}}class Barcode extends XFAObject{constructor(e){super(Nn,"barcode",!0);this.charEncoding=getKeyword({data:e.charEncoding?e.charEncoding.toLowerCase():"",defaultValue:"",validate:e=>["utf-8","big-five","fontspecific","gbk","gb-18030","gb-2312","ksc-5601","none","shift-jis","ucs-2","utf-16"].includes(e)||e.match(/iso-8859-\d{2}/)});this.checksum=getStringOption(e.checksum,["none","1mod10","1mod10_1mod11","2mod10","auto"]);this.dataColumnCount=getInteger({data:e.dataColumnCount,defaultValue:-1,validate:e=>e>=0});this.dataLength=getInteger({data:e.dataLength,defaultValue:-1,validate:e=>e>=0});this.dataPrep=getStringOption(e.dataPrep,["none","flateCompress"]);this.dataRowCount=getInteger({data:e.dataRowCount,defaultValue:-1,validate:e=>e>=0});this.endChar=e.endChar||"";this.errorCorrectionLevel=getInteger({data:e.errorCorrectionLevel,defaultValue:-1,validate:e=>e>=0&&e<=8});this.id=e.id||"";this.moduleHeight=getMeasurement(e.moduleHeight,"5mm");this.moduleWidth=getMeasurement(e.moduleWidth,"0.25mm");this.printCheckDigit=getInteger({data:e.printCheckDigit,defaultValue:0,validate:e=>1===e});this.rowColumnRatio=getRatio(e.rowColumnRatio);this.startChar=e.startChar||"";this.textLocation=getStringOption(e.textLocation,["below","above","aboveEmbedded","belowEmbedded","none"]);this.truncate=getInteger({data:e.truncate,defaultValue:0,validate:e=>1===e});this.type=getStringOption(e.type?e.type.toLowerCase():"",["aztec","codabar","code2of5industrial","code2of5interleaved","code2of5matrix","code2of5standard","code3of9","code3of9extended","code11","code49","code93","code128","code128a","code128b","code128c","code128sscc","datamatrix","ean8","ean8add2","ean8add5","ean13","ean13add2","ean13add5","ean13pwcd","fim","logmars","maxicode","msi","pdf417","pdf417macro","plessey","postauscust2","postauscust3","postausreplypaid","postausstandard","postukrm4scc","postusdpbc","postusimb","postusstandard","postus5zip","qrcode","rfid","rss14","rss14expanded","rss14limited","rss14stacked","rss14stackedomni","rss14truncated","telepen","ucc128","ucc128random","ucc128sscc","upca","upcaadd2","upcaadd5","upcapwcd","upce","upceadd2","upceadd5","upcean2","upcean5","upsmaxicode"]);this.upsMode=getStringOption(e.upsMode,["usCarrier","internationalCarrier","secureSymbol","standardSymbol"]);this.use=e.use||"";this.usehref=e.usehref||"";this.wideNarrowRatio=getRatio(e.wideNarrowRatio);this.encrypt=null;this.extras=null}}class Bind extends XFAObject{constructor(e){super(Nn,"bind",!0);this.match=getStringOption(e.match,["once","dataRef","global","none"]);this.ref=e.ref||"";this.picture=null}}class BindItems extends XFAObject{constructor(e){super(Nn,"bindItems");this.connection=e.connection||"";this.labelRef=e.labelRef||"";this.ref=e.ref||"";this.valueRef=e.valueRef||""}}class Bookend extends XFAObject{constructor(e){super(Nn,"bookend");this.id=e.id||"";this.leader=e.leader||"";this.trailer=e.trailer||"";this.use=e.use||"";this.usehref=e.usehref||""}}class BooleanElement extends Option01{constructor(e){super(Nn,"boolean");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[jr](e){return valueToHtml(1===this[Os]?"1":"0")}}class Border extends XFAObject{constructor(e){super(Nn,"border",!0);this.break=getStringOption(e.break,["close","open"]);this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.corner=new XFAObjectArray(4);this.edge=new XFAObjectArray(4);this.extras=null;this.fill=null;this.margin=null}[ar](){if(!this[Xs]){const e=this.edge.children.slice();if(e.length<4){const t=e.at(-1)||new Edge({});for(let i=e.length;i<4;i++)e.push(t)}const t=e.map((e=>e.thickness)),i=[0,0,0,0];if(this.margin){i[0]=this.margin.topInset;i[1]=this.margin.rightInset;i[2]=this.margin.bottomInset;i[3]=this.margin.leftInset}this[Xs]={widths:t,insets:i,edges:e}}return this[Xs]}[Zr](){const{edges:e}=this[ar](),t=e.map((e=>{const t=e[Zr]();t.color||="#000000";return t})),i=Object.create(null);this.margin&&Object.assign(i,this.margin[Zr]());"visible"===this.fill?.presence&&Object.assign(i,this.fill[Zr]());if(this.corner.children.some((e=>0!==e.radius))){const e=this.corner.children.map((e=>e[Zr]()));if(2===e.length||3===e.length){const t=e.at(-1);for(let i=e.length;i<4;i++)e.push(t)}i.borderRadius=e.map((e=>e.radius)).join(" ")}switch(this.presence){case"invisible":case"hidden":i.borderStyle="";break;case"inactive":i.borderStyle="none";break;default:i.borderStyle=t.map((e=>e.style)).join(" ")}i.borderWidth=t.map((e=>e.width)).join(" ");i.borderColor=t.map((e=>e.color)).join(" ");return i}}class Break extends XFAObject{constructor(e){super(Nn,"break",!0);this.after=getStringOption(e.after,["auto","contentArea","pageArea","pageEven","pageOdd"]);this.afterTarget=e.afterTarget||"";this.before=getStringOption(e.before,["auto","contentArea","pageArea","pageEven","pageOdd"]);this.beforeTarget=e.beforeTarget||"";this.bookendLeader=e.bookendLeader||"";this.bookendTrailer=e.bookendTrailer||"";this.id=e.id||"";this.overflowLeader=e.overflowLeader||"";this.overflowTarget=e.overflowTarget||"";this.overflowTrailer=e.overflowTrailer||"";this.startNew=getInteger({data:e.startNew,defaultValue:0,validate:e=>1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}}class BreakAfter extends XFAObject{constructor(e){super(Nn,"breakAfter",!0);this.id=e.id||"";this.leader=e.leader||"";this.startNew=getInteger({data:e.startNew,defaultValue:0,validate:e=>1===e});this.target=e.target||"";this.targetType=getStringOption(e.targetType,["auto","contentArea","pageArea"]);this.trailer=e.trailer||"";this.use=e.use||"";this.usehref=e.usehref||"";this.script=null}}class BreakBefore extends XFAObject{constructor(e){super(Nn,"breakBefore",!0);this.id=e.id||"";this.leader=e.leader||"";this.startNew=getInteger({data:e.startNew,defaultValue:0,validate:e=>1===e});this.target=e.target||"";this.targetType=getStringOption(e.targetType,["auto","contentArea","pageArea"]);this.trailer=e.trailer||"";this.use=e.use||"";this.usehref=e.usehref||"";this.script=null}[jr](e){this[Xs]={};return HTMLResult.FAILURE}}class Button extends XFAObject{constructor(e){super(Nn,"button",!0);this.highlight=getStringOption(e.highlight,["inverted","none","outline","push"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[jr](e){const t=this[Ir]()[Ir](),i={name:"button",attributes:{id:this[Vr],class:["xfaButton"],style:{}},children:[]};for(const e of t.event.children){if("click"!==e.activity||!e.script)continue;const t=recoverJsURL(e.script[Os]);if(!t)continue;const a=fixURL(t.url);a&&i.children.push({name:"a",attributes:{id:"link"+this[Vr],href:a,newWindow:t.newWindow,class:["xfaLink"],style:{}},children:[]})}return HTMLResult.success(i)}}class Calculate extends XFAObject{constructor(e){super(Nn,"calculate",!0);this.id=e.id||"";this.override=getStringOption(e.override,["disabled","error","ignore","warning"]);this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.message=null;this.script=null}}class Caption extends XFAObject{constructor(e){super(Nn,"caption",!0);this.id=e.id||"";this.placement=getStringOption(e.placement,["left","bottom","inline","right","top"]);this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.reserve=Math.ceil(getMeasurement(e.reserve));this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.font=null;this.margin=null;this.para=null;this.value=null}[qr](e){_setValue(this,e)}[ar](e){if(!this[Xs]){let{width:t,height:i}=e;switch(this.placement){case"left":case"right":case"inline":t=this.reserve<=0?t:this.reserve;break;case"top":case"bottom":i=this.reserve<=0?i:this.reserve}this[Xs]=layoutNode(this,{width:t,height:i})}return this[Xs]}[jr](e){if(!this.value)return HTMLResult.EMPTY;this[Lr]();const t=this.value[jr](e).html;if(!t){this[xr]();return HTMLResult.EMPTY}const i=this.reserve;if(this.reserve<=0){const{w:t,h:i}=this[ar](e);switch(this.placement){case"left":case"right":case"inline":this.reserve=t;break;case"top":case"bottom":this.reserve=i}}const a=[];"string"==typeof t?a.push({name:"#text",value:t}):a.push(t);const s=toStyle(this,"font","margin","visibility");switch(this.placement){case"left":case"right":this.reserve>0&&(s.width=measureToString(this.reserve));break;case"top":case"bottom":this.reserve>0&&(s.height=measureToString(this.reserve))}setPara(this,null,t);this[xr]();this.reserve=i;return HTMLResult.success({name:"div",attributes:{style:s,class:["xfaCaption"]},children:a})}}class Certificate extends StringObject{constructor(e){super(Nn,"certificate");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Certificates extends XFAObject{constructor(e){super(Nn,"certificates",!0);this.credentialServerPolicy=getStringOption(e.credentialServerPolicy,["optional","required"]);this.id=e.id||"";this.url=e.url||"";this.urlPolicy=e.urlPolicy||"";this.use=e.use||"";this.usehref=e.usehref||"";this.encryption=null;this.issuers=null;this.keyUsage=null;this.oids=null;this.signing=null;this.subjectDNs=null}}class CheckButton extends XFAObject{constructor(e){super(Nn,"checkButton",!0);this.id=e.id||"";this.mark=getStringOption(e.mark,["default","check","circle","cross","diamond","square","star"]);this.shape=getStringOption(e.shape,["square","round"]);this.size=getMeasurement(e.size,"10pt");this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle("margin"),i=measureToString(this.size);t.width=t.height=i;let a,s,r;const n=this[Ir]()[Ir](),g=n.items.children.length&&n.items.children[0][jr]().html||[],o={on:(void 0!==g[0]?g[0]:"on").toString(),off:(void 0!==g[1]?g[1]:"off").toString()},c=(n.value?.[Pr]()||"off")===o.on||void 0,C=n[or](),h=n[Vr];let l;if(C instanceof ExclGroup){r=C[Vr];a="radio";s="xfaRadio";l=C[Ws]?.[Vr]||C[Vr]}else{a="checkbox";s="xfaCheckbox";l=n[Ws]?.[Vr]||n[Vr]}const Q={name:"input",attributes:{class:[s],style:t,fieldId:h,dataId:l,type:a,checked:c,xfaOn:o.on,xfaOff:o.off,"aria-label":ariaLabel(n),"aria-required":!1}};r&&(Q.attributes.name=r);if(isRequired(n)){Q.attributes["aria-required"]=!0;Q.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[Q]})}}class ChoiceList extends XFAObject{constructor(e){super(Nn,"choiceList",!0);this.commitOn=getStringOption(e.commitOn,["select","exit"]);this.id=e.id||"";this.open=getStringOption(e.open,["userControl","always","multiSelect","onEntry"]);this.textEntry=getInteger({data:e.textEntry,defaultValue:0,validate:e=>1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","margin"),i=this[Ir]()[Ir](),a={fontSize:`calc(${i.font?.size||10}px * var(--scale-factor))`},s=[];if(i.items.children.length>0){const e=i.items;let t=0,r=0;if(2===e.children.length){t=e.children[0].save;r=1-t}const n=e.children[t][jr]().html,g=e.children[r][jr]().html;let o=!1;const c=i.value?.[Pr]()||"";for(let e=0,t=n.length;eMath.min(Math.max(0,parseInt(e.trim(),10)),255))).map((e=>isNaN(e)?0:e));if(r.length<3)return{r:i,g:a,b:s};[i,a,s]=r;return{r:i,g:a,b:s}}(e.value):"";this.extras=null}[hr](){return!1}[Zr](){return this.value?Util.makeHexColor(this.value.r,this.value.g,this.value.b):null}}class Comb extends XFAObject{constructor(e){super(Nn,"comb");this.id=e.id||"";this.numberOfCells=getInteger({data:e.numberOfCells,defaultValue:0,validate:e=>e>=0});this.use=e.use||"";this.usehref=e.usehref||""}}class Connect extends XFAObject{constructor(e){super(Nn,"connect",!0);this.connection=e.connection||"";this.id=e.id||"";this.ref=e.ref||"";this.usage=getStringOption(e.usage,["exportAndImport","exportOnly","importOnly"]);this.use=e.use||"";this.usehref=e.usehref||"";this.picture=null}}class ContentArea extends XFAObject{constructor(e){super(Nn,"contentArea",!0);this.h=getMeasurement(e.h);this.id=e.id||"";this.name=e.name||"";this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.w=getMeasurement(e.w);this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.desc=null;this.extras=null}[jr](e){const t={left:measureToString(this.x),top:measureToString(this.y),width:measureToString(this.w),height:measureToString(this.h)},i=["xfaContentarea"];isPrintOnly(this)&&i.push("xfaPrintOnly");return HTMLResult.success({name:"div",children:[],attributes:{style:t,class:i,id:this[Vr]}})}}class Corner extends XFAObject{constructor(e){super(Nn,"corner",!0);this.id=e.id||"";this.inverted=getInteger({data:e.inverted,defaultValue:0,validate:e=>1===e});this.join=getStringOption(e.join,["square","round"]);this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.radius=getMeasurement(e.radius);this.stroke=getStringOption(e.stroke,["solid","dashDot","dashDotDot","dashed","dotted","embossed","etched","lowered","raised"]);this.thickness=getMeasurement(e.thickness,"0.5pt");this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](){const e=toStyle(this,"visibility");e.radius=measureToString("square"===this.join?0:this.radius);return e}}class DateElement extends ContentObject{constructor(e){super(Nn,"date");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=this[Os].trim();this[Os]=e?new Date(e):null}[jr](e){return valueToHtml(this[Os]?this[Os].toString():"")}}class DateTime extends ContentObject{constructor(e){super(Nn,"dateTime");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=this[Os].trim();this[Os]=e?new Date(e):null}[jr](e){return valueToHtml(this[Os]?this[Os].toString():"")}}class DateTimeEdit extends XFAObject{constructor(e){super(Nn,"dateTimeEdit",!0);this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.picker=getStringOption(e.picker,["host","none"]);this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.comb=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","font","margin"),i=this[Ir]()[Ir](),a={name:"input",attributes:{type:"text",fieldId:i[Vr],dataId:i[Ws]?.[Vr]||i[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(i),"aria-required":!1}};if(isRequired(i)){a.attributes["aria-required"]=!0;a.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[a]})}}class Decimal extends ContentObject{constructor(e){super(Nn,"decimal");this.fracDigits=getInteger({data:e.fracDigits,defaultValue:2,validate:e=>!0});this.id=e.id||"";this.leadDigits=getInteger({data:e.leadDigits,defaultValue:-1,validate:e=>!0});this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=parseFloat(this[Os].trim());this[Os]=isNaN(e)?null:e}[jr](e){return valueToHtml(null!==this[Os]?this[Os].toString():"")}}class DefaultUi extends XFAObject{constructor(e){super(Nn,"defaultUi",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}}class Desc extends XFAObject{constructor(e){super(Nn,"desc",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}}class DigestMethod extends OptionObject{constructor(e){super(Nn,"digestMethod",["","SHA1","SHA256","SHA512","RIPEMD160"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class DigestMethods extends XFAObject{constructor(e){super(Nn,"digestMethods",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.digestMethod=new XFAObjectArray}}class Draw extends XFAObject{constructor(e){super(Nn,"draw",!0);this.anchorType=getStringOption(e.anchorType,["topLeft","bottomCenter","bottomLeft","bottomRight","middleCenter","middleLeft","middleRight","topCenter","topRight"]);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.locale=e.locale||"";this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.rotate=getInteger({data:e.rotate,defaultValue:0,validate:e=>e%90==0});this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.border=null;this.caption=null;this.desc=null;this.extras=null;this.font=null;this.keep=null;this.margin=null;this.para=null;this.traversal=null;this.ui=null;this.value=null;this.setProperty=new XFAObjectArray}[qr](e){_setValue(this,e)}[jr](e){setTabIndex(this);if("hidden"===this.presence||"inactive"===this.presence)return HTMLResult.EMPTY;fixDimensions(this);this[Lr]();const t=this.w,i=this.h,{w:a,h:s,isBroken:r}=layoutNode(this,e);if(a&&""===this.w){if(r&&this[or]()[wr]()){this[xr]();return HTMLResult.FAILURE}this.w=a}s&&""===this.h&&(this.h=s);setFirstUnsplittable(this);if(!checkDimensions(this,e)){this.w=t;this.h=i;this[xr]();return HTMLResult.FAILURE}unsetFirstUnsplittable(this);const n=toStyle(this,"font","hAlign","dimensions","position","presence","rotate","anchorType","border","margin");setMinMaxDimensions(this,n);if(n.margin){n.padding=n.margin;delete n.margin}const g=["xfaDraw"];this.font&&g.push("xfaFont");isPrintOnly(this)&&g.push("xfaPrintOnly");const o={style:n,id:this[Vr],class:g};this.name&&(o.xfaName=this.name);const c={name:"div",attributes:o,children:[]};applyAssist(this,o);const C=computeBbox(this,c,e),h=this.value?this.value[jr](e).html:null;if(null===h){this.w=t;this.h=i;this[xr]();return HTMLResult.success(createWrapper(this,c),C)}c.children.push(h);setPara(this,n,h);this.w=t;this.h=i;this[xr]();return HTMLResult.success(createWrapper(this,c),C)}}class Edge extends XFAObject{constructor(e){super(Nn,"edge",!0);this.cap=getStringOption(e.cap,["square","butt","round"]);this.id=e.id||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.stroke=getStringOption(e.stroke,["solid","dashDot","dashDotDot","dashed","dotted","embossed","etched","lowered","raised"]);this.thickness=getMeasurement(e.thickness,"0.5pt");this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](){const e=toStyle(this,"visibility");Object.assign(e,{linecap:this.cap,width:measureToString(this.thickness),color:this.color?this.color[Zr]():"#000000",style:""});if("visible"!==this.presence)e.style="none";else switch(this.stroke){case"solid":e.style="solid";break;case"dashDot":case"dashDotDot":case"dashed":e.style="dashed";break;case"dotted":e.style="dotted";break;case"embossed":e.style="ridge";break;case"etched":e.style="groove";break;case"lowered":e.style="inset";break;case"raised":e.style="outset"}return e}}class Encoding extends OptionObject{constructor(e){super(Nn,"encoding",["adbe.x509.rsa_sha1","adbe.pkcs7.detached","adbe.pkcs7.sha1"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Encodings extends XFAObject{constructor(e){super(Nn,"encodings",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.encoding=new XFAObjectArray}}class Encrypt extends XFAObject{constructor(e){super(Nn,"encrypt",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=null}}class EncryptData extends XFAObject{constructor(e){super(Nn,"encryptData",!0);this.id=e.id||"";this.operation=getStringOption(e.operation,["encrypt","decrypt"]);this.target=e.target||"";this.use=e.use||"";this.usehref=e.usehref||"";this.filter=null;this.manifest=null}}class Encryption extends XFAObject{constructor(e){super(Nn,"encryption",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=new XFAObjectArray}}class EncryptionMethod extends OptionObject{constructor(e){super(Nn,"encryptionMethod",["","AES256-CBC","TRIPLEDES-CBC","AES128-CBC","AES192-CBC"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class EncryptionMethods extends XFAObject{constructor(e){super(Nn,"encryptionMethods",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.encryptionMethod=new XFAObjectArray}}class Event extends XFAObject{constructor(e){super(Nn,"event",!0);this.activity=getStringOption(e.activity,["click","change","docClose","docReady","enter","exit","full","indexChange","initialize","mouseDown","mouseEnter","mouseExit","mouseUp","postExecute","postOpen","postPrint","postSave","postSign","postSubmit","preExecute","preOpen","prePrint","preSave","preSign","preSubmit","ready","validationState"]);this.id=e.id||"";this.listen=getStringOption(e.listen,["refOnly","refAndDescendents"]);this.name=e.name||"";this.ref=e.ref||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.encryptData=null;this.execute=null;this.script=null;this.signData=null;this.submit=null}}class ExData extends ContentObject{constructor(e){super(Nn,"exData");this.contentType=e.contentType||"";this.href=e.href||"";this.id=e.id||"";this.maxLength=getInteger({data:e.maxLength,defaultValue:-1,validate:e=>e>=-1});this.name=e.name||"";this.rid=e.rid||"";this.transferEncoding=getStringOption(e.transferEncoding,["none","base64","package"]);this.use=e.use||"";this.usehref=e.usehref||""}[ur](){return"text/html"===this.contentType}[Nr](e){if("text/html"===this.contentType&&e[Sr]===_r.xhtml.id){this[Os]=e;return!0}if("text/xml"===this.contentType){this[Os]=e;return!0}return!1}[jr](e){return"text/html"===this.contentType&&this[Os]?this[Os][jr](e):HTMLResult.EMPTY}}class ExObject extends XFAObject{constructor(e){super(Nn,"exObject",!0);this.archive=e.archive||"";this.classId=e.classId||"";this.codeBase=e.codeBase||"";this.codeType=e.codeType||"";this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.exObject=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}}class ExclGroup extends XFAObject{constructor(e){super(Nn,"exclGroup",!0);this.access=getStringOption(e.access,["open","nonInteractive","protected","readOnly"]);this.accessKey=e.accessKey||"";this.anchorType=getStringOption(e.anchorType,["topLeft","bottomCenter","bottomLeft","bottomRight","middleCenter","middleLeft","middleRight","topCenter","topRight"]);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.layout=getStringOption(e.layout,["position","lr-tb","rl-row","rl-tb","row","table","tb"]);this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.bind=null;this.border=null;this.calculate=null;this.caption=null;this.desc=null;this.extras=null;this.margin=null;this.para=null;this.traversal=null;this.validate=null;this.connect=new XFAObjectArray;this.event=new XFAObjectArray;this.field=new XFAObjectArray;this.setProperty=new XFAObjectArray}[dr](){return!0}[hr](){return!0}[qr](e){for(const t of this.field.children){if(!t.value){const e=new Value({});t[Hs](e);t.value=e}t.value[qr](e)}}[wr](){return this.layout.endsWith("-tb")&&0===this[Xs].attempt&&this[Xs].numberInLine>0||this[Ir]()[wr]()}[yr](){const e=this[or]();if(!e[yr]())return!1;if(void 0!==this[Xs]._isSplittable)return this[Xs]._isSplittable;if("position"===this.layout||this.layout.includes("row")){this[Xs]._isSplittable=!1;return!1}if(e.layout?.endsWith("-tb")&&0!==e[Xs].numberInLine)return!1;this[Xs]._isSplittable=!0;return!0}[Vs](){return flushHTML(this)}[Ls](e,t){addHTML(this,e,t)}[$s](){return getAvailableSpace(this)}[jr](e){setTabIndex(this);if("hidden"===this.presence||"inactive"===this.presence||0===this.h||0===this.w)return HTMLResult.EMPTY;fixDimensions(this);const t=[],i={id:this[Vr],class:[]};setAccess(this,i.class);this[Xs]||(this[Xs]=Object.create(null));Object.assign(this[Xs],{children:t,attributes:i,attempt:0,line:null,numberInLine:0,availableSpace:{width:Math.min(this.w||1/0,e.width),height:Math.min(this.h||1/0,e.height)},width:0,height:0,prevHeight:0,currentWidth:0});const a=this[yr]();a||setFirstUnsplittable(this);if(!checkDimensions(this,e))return HTMLResult.FAILURE;const s=new Set(["field"]);if(this.layout.includes("row")){const e=this[or]().columnWidths;if(Array.isArray(e)&&e.length>0){this[Xs].columnWidths=e;this[Xs].currentColumn=0}}const r=toStyle(this,"anchorType","dimensions","position","presence","border","margin","hAlign"),n=["xfaExclgroup"],g=layoutClass(this);g&&n.push(g);isPrintOnly(this)&&n.push("xfaPrintOnly");i.style=r;i.class=n;this.name&&(i.xfaName=this.name);this[Lr]();const o="lr-tb"===this.layout||"rl-tb"===this.layout,c=o?2:1;for(;this[Xs].attempte>=1||-1===e});this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.locale=e.locale||"";this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.rotate=getInteger({data:e.rotate,defaultValue:0,validate:e=>e%90==0});this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.bind=null;this.border=null;this.calculate=null;this.caption=null;this.desc=null;this.extras=null;this.font=null;this.format=null;this.items=new XFAObjectArray(2);this.keep=null;this.margin=null;this.para=null;this.traversal=null;this.ui=null;this.validate=null;this.value=null;this.bindItems=new XFAObjectArray;this.connect=new XFAObjectArray;this.event=new XFAObjectArray;this.setProperty=new XFAObjectArray}[dr](){return!0}[qr](e){_setValue(this,e)}[jr](e){setTabIndex(this);if(!this.ui){this.ui=new Ui({});this.ui[Cr]=this[Cr];this[Hs](this.ui);let e;switch(this.items.children.length){case 0:e=new TextEdit({});this.ui.textEdit=e;break;case 1:e=new CheckButton({});this.ui.checkButton=e;break;case 2:e=new ChoiceList({});this.ui.choiceList=e}this.ui[Hs](e)}if(!this.ui||"hidden"===this.presence||"inactive"===this.presence||0===this.h||0===this.w)return HTMLResult.EMPTY;this.caption&&delete this.caption[Xs];this[Lr]();const t=this.caption?this.caption[jr](e).html:null,i=this.w,a=this.h;let s=0,r=0;if(this.margin){s=this.margin.leftInset+this.margin.rightInset;r=this.margin.topInset+this.margin.bottomInset}let n=null;if(""===this.w||""===this.h){let t=null,i=null,a=0,g=0;if(this.ui.checkButton)a=g=this.ui.checkButton.size;else{const{w:t,h:i}=layoutNode(this,e);if(null!==t){a=t;g=i}else g=function fonts_getMetrics(e,t=!1){let i=null;if(e){const t=stripQuotes(e.typeface),a=e[Cr].fontFinder.find(t);i=selectFont(e,a)}if(!i)return{lineHeight:12,lineGap:2,lineNoGap:10};const a=e.size||10,s=i.lineHeight?Math.max(t?0:1.2,i.lineHeight):1.2,r=void 0===i.lineGap?.2:i.lineGap;return{lineHeight:s*a,lineGap:r*a,lineNoGap:Math.max(1,s-r)*a}}(this.font,!0).lineNoGap}n=getBorderDims(this.ui[ar]());a+=n.w;g+=n.h;if(this.caption){const{w:s,h:r,isBroken:n}=this.caption[ar](e);if(n&&this[or]()[wr]()){this[xr]();return HTMLResult.FAILURE}t=s;i=r;switch(this.caption.placement){case"left":case"right":case"inline":t+=a;break;case"top":case"bottom":i+=g}}else{t=a;i=g}if(t&&""===this.w){t+=s;this.w=Math.min(this.maxW<=0?1/0:this.maxW,this.minW+1e>=1&&e<=5});this.appearanceFilter=null;this.certificates=null;this.digestMethods=null;this.encodings=null;this.encryptionMethods=null;this.handler=null;this.lockDocument=null;this.mdp=null;this.reasons=null;this.timeStamp=null}}class Float extends ContentObject{constructor(e){super(Nn,"float");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=parseFloat(this[Os].trim());this[Os]=isNaN(e)?null:e}[jr](e){return valueToHtml(null!==this[Os]?this[Os].toString():"")}}class template_Font extends XFAObject{constructor(e){super(Nn,"font",!0);this.baselineShift=getMeasurement(e.baselineShift);this.fontHorizontalScale=getFloat({data:e.fontHorizontalScale,defaultValue:100,validate:e=>e>=0});this.fontVerticalScale=getFloat({data:e.fontVerticalScale,defaultValue:100,validate:e=>e>=0});this.id=e.id||"";this.kerningMode=getStringOption(e.kerningMode,["none","pair"]);this.letterSpacing=getMeasurement(e.letterSpacing,"0");this.lineThrough=getInteger({data:e.lineThrough,defaultValue:0,validate:e=>1===e||2===e});this.lineThroughPeriod=getStringOption(e.lineThroughPeriod,["all","word"]);this.overline=getInteger({data:e.overline,defaultValue:0,validate:e=>1===e||2===e});this.overlinePeriod=getStringOption(e.overlinePeriod,["all","word"]);this.posture=getStringOption(e.posture,["normal","italic"]);this.size=getMeasurement(e.size,"10pt");this.typeface=e.typeface||"Courier";this.underline=getInteger({data:e.underline,defaultValue:0,validate:e=>1===e||2===e});this.underlinePeriod=getStringOption(e.underlinePeriod,["all","word"]);this.use=e.use||"";this.usehref=e.usehref||"";this.weight=getStringOption(e.weight,["normal","bold"]);this.extras=null;this.fill=null}[Ys](e){super[Ys](e);this[Cr].usedTypefaces.add(this.typeface)}[Zr](){const e=toStyle(this,"fill"),t=e.color;if(t)if("#000000"===t)delete e.color;else if(!t.startsWith("#")){e.background=t;e.backgroundClip="text";e.color="transparent"}this.baselineShift&&(e.verticalAlign=measureToString(this.baselineShift));e.fontKerning="none"===this.kerningMode?"none":"normal";e.letterSpacing=measureToString(this.letterSpacing);if(0!==this.lineThrough){e.textDecoration="line-through";2===this.lineThrough&&(e.textDecorationStyle="double")}if(0!==this.overline){e.textDecoration="overline";2===this.overline&&(e.textDecorationStyle="double")}e.fontStyle=this.posture;e.fontSize=measureToString(.99*this.size);setFontFamily(this,this,this[Cr].fontFinder,e);if(0!==this.underline){e.textDecoration="underline";2===this.underline&&(e.textDecorationStyle="double")}e.fontWeight=this.weight;return e}}class Format extends XFAObject{constructor(e){super(Nn,"format",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.picture=null}}class Handler extends StringObject{constructor(e){super(Nn,"handler");this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Hyphenation extends XFAObject{constructor(e){super(Nn,"hyphenation");this.excludeAllCaps=getInteger({data:e.excludeAllCaps,defaultValue:0,validate:e=>1===e});this.excludeInitialCap=getInteger({data:e.excludeInitialCap,defaultValue:0,validate:e=>1===e});this.hyphenate=getInteger({data:e.hyphenate,defaultValue:0,validate:e=>1===e});this.id=e.id||"";this.pushCharacterCount=getInteger({data:e.pushCharacterCount,defaultValue:3,validate:e=>e>=0});this.remainCharacterCount=getInteger({data:e.remainCharacterCount,defaultValue:3,validate:e=>e>=0});this.use=e.use||"";this.usehref=e.usehref||"";this.wordCharacterCount=getInteger({data:e.wordCharacterCount,defaultValue:7,validate:e=>e>=0})}}class Image extends StringObject{constructor(e){super(Nn,"image");this.aspect=getStringOption(e.aspect,["fit","actual","height","none","width"]);this.contentType=e.contentType||"";this.href=e.href||"";this.id=e.id||"";this.name=e.name||"";this.transferEncoding=getStringOption(e.transferEncoding,["base64","none","package"]);this.use=e.use||"";this.usehref=e.usehref||""}[jr](){if(this.contentType&&!Un.has(this.contentType.toLowerCase()))return HTMLResult.EMPTY;let e=this[Cr].images&&this[Cr].images.get(this.href);if(!e&&(this.href||!this[Os]))return HTMLResult.EMPTY;e||"base64"!==this.transferEncoding||(e=function fromBase64Util(e){return Uint8Array.fromBase64?Uint8Array.fromBase64(e):stringToBytes(atob(e))}(this[Os]));if(!e)return HTMLResult.EMPTY;if(!this.contentType){for(const[t,i]of xn)if(e.length>t.length&&t.every(((t,i)=>t===e[i]))){this.contentType=i;break}if(!this.contentType)return HTMLResult.EMPTY}const t=new Blob([e],{type:this.contentType});let i;switch(this.aspect){case"fit":case"actual":break;case"height":i={height:"100%",objectFit:"fill"};break;case"none":i={width:"100%",height:"100%",objectFit:"fill"};break;case"width":i={width:"100%",objectFit:"fill"}}const a=this[Ir]();return HTMLResult.success({name:"img",attributes:{class:["xfaImage"],style:i,src:URL.createObjectURL(t),alt:a?ariaLabel(a[Ir]()):null}})}}class ImageEdit extends XFAObject{constructor(e){super(Nn,"imageEdit",!0);this.data=getStringOption(e.data,["link","embed"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}[jr](e){return"embed"===this.data?HTMLResult.success({name:"div",children:[],attributes:{}}):HTMLResult.EMPTY}}class Integer extends ContentObject{constructor(e){super(Nn,"integer");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=parseInt(this[Os].trim(),10);this[Os]=isNaN(e)?null:e}[jr](e){return valueToHtml(null!==this[Os]?this[Os].toString():"")}}class Issuers extends XFAObject{constructor(e){super(Nn,"issuers",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=new XFAObjectArray}}class Items extends XFAObject{constructor(e){super(Nn,"items",!0);this.id=e.id||"";this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.ref=e.ref||"";this.save=getInteger({data:e.save,defaultValue:0,validate:e=>1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}[jr](){const e=[];for(const t of this[rr]())e.push(t[Pr]());return HTMLResult.success(e)}}class Keep extends XFAObject{constructor(e){super(Nn,"keep",!0);this.id=e.id||"";const t=["none","contentArea","pageArea"];this.intact=getStringOption(e.intact,t);this.next=getStringOption(e.next,t);this.previous=getStringOption(e.previous,t);this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}}class KeyUsage extends XFAObject{constructor(e){super(Nn,"keyUsage");const t=["","yes","no"];this.crlSign=getStringOption(e.crlSign,t);this.dataEncipherment=getStringOption(e.dataEncipherment,t);this.decipherOnly=getStringOption(e.decipherOnly,t);this.digitalSignature=getStringOption(e.digitalSignature,t);this.encipherOnly=getStringOption(e.encipherOnly,t);this.id=e.id||"";this.keyAgreement=getStringOption(e.keyAgreement,t);this.keyCertSign=getStringOption(e.keyCertSign,t);this.keyEncipherment=getStringOption(e.keyEncipherment,t);this.nonRepudiation=getStringOption(e.nonRepudiation,t);this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Line extends XFAObject{constructor(e){super(Nn,"line",!0);this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.slope=getStringOption(e.slope,["\\","/"]);this.use=e.use||"";this.usehref=e.usehref||"";this.edge=null}[jr](){const e=this[Ir]()[Ir](),t=this.edge||new Edge({}),i=t[Zr](),a=Object.create(null),s="visible"===t.presence?t.thickness:0;a.strokeWidth=measureToString(s);a.stroke=i.color;let r,n,g,o,c="100%",C="100%";if(e.w<=s){[r,n,g,o]=["50%",0,"50%","100%"];c=a.strokeWidth}else if(e.h<=s){[r,n,g,o]=[0,"50%","100%","50%"];C=a.strokeWidth}else"\\"===this.slope?[r,n,g,o]=[0,0,"100%","100%"]:[r,n,g,o]=[0,"100%","100%",0];const h={name:"svg",children:[{name:"line",attributes:{xmlns:Gn,x1:r,y1:n,x2:g,y2:o,style:a}}],attributes:{xmlns:Gn,width:c,height:C,style:{overflow:"visible"}}};if(hasMargin(e))return HTMLResult.success({name:"div",attributes:{style:{display:"inline",width:"100%",height:"100%"}},children:[h]});h.attributes.style.position="absolute";return HTMLResult.success(h)}}class Linear extends XFAObject{constructor(e){super(Nn,"linear",!0);this.id=e.id||"";this.type=getStringOption(e.type,["toRight","toBottom","toLeft","toTop"]);this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){e=e?e[Zr]():"#FFFFFF";return`linear-gradient(${this.type.replace(/([RBLT])/," $1").toLowerCase()}, ${e}, ${this.color?this.color[Zr]():"#000000"})`}}class LockDocument extends ContentObject{constructor(e){super(Nn,"lockDocument");this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){this[Os]=getStringOption(this[Os],["auto","0","1"])}}class Manifest extends XFAObject{constructor(e){super(Nn,"manifest",!0);this.action=getStringOption(e.action,["include","all","exclude"]);this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.ref=new XFAObjectArray}}class Margin extends XFAObject{constructor(e){super(Nn,"margin",!0);this.bottomInset=getMeasurement(e.bottomInset,"0");this.id=e.id||"";this.leftInset=getMeasurement(e.leftInset,"0");this.rightInset=getMeasurement(e.rightInset,"0");this.topInset=getMeasurement(e.topInset,"0");this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[Zr](){return{margin:measureToString(this.topInset)+" "+measureToString(this.rightInset)+" "+measureToString(this.bottomInset)+" "+measureToString(this.leftInset)}}}class Mdp extends XFAObject{constructor(e){super(Nn,"mdp");this.id=e.id||"";this.permissions=getInteger({data:e.permissions,defaultValue:2,validate:e=>1===e||3===e});this.signatureType=getStringOption(e.signatureType,["filler","author"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Medium extends XFAObject{constructor(e){super(Nn,"medium");this.id=e.id||"";this.imagingBBox=function getBBox(e){const t=-1;if(!e)return{x:t,y:t,width:t,height:t};const i=e.trim().split(/\s*,\s*/).map((e=>getMeasurement(e,"-1")));if(i.length<4||i[2]<0||i[3]<0)return{x:t,y:t,width:t,height:t};const[a,s,r,n]=i;return{x:a,y:s,width:r,height:n}}(e.imagingBBox);this.long=getMeasurement(e.long);this.orientation=getStringOption(e.orientation,["portrait","landscape"]);this.short=getMeasurement(e.short);this.stock=e.stock||"";this.trayIn=getStringOption(e.trayIn,["auto","delegate","pageFront"]);this.trayOut=getStringOption(e.trayOut,["auto","delegate"]);this.use=e.use||"";this.usehref=e.usehref||""}}class Message extends XFAObject{constructor(e){super(Nn,"message",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.text=new XFAObjectArray}}class NumericEdit extends XFAObject{constructor(e){super(Nn,"numericEdit",!0);this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.comb=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","font","margin"),i=this[Ir]()[Ir](),a={name:"input",attributes:{type:"text",fieldId:i[Vr],dataId:i[Ws]?.[Vr]||i[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(i),"aria-required":!1}};if(isRequired(i)){a.attributes["aria-required"]=!0;a.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[a]})}}class Occur extends XFAObject{constructor(e){super(Nn,"occur",!0);this.id=e.id||"";this.initial=""!==e.initial?getInteger({data:e.initial,defaultValue:"",validate:e=>!0}):"";this.max=""!==e.max?getInteger({data:e.max,defaultValue:1,validate:e=>!0}):"";this.min=""!==e.min?getInteger({data:e.min,defaultValue:1,validate:e=>!0}):"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[Ys](){const e=this[Ir](),t=this.min;""===this.min&&(this.min=e instanceof PageArea||e instanceof PageSet?0:1);""===this.max&&(this.max=""===t?e instanceof PageArea||e instanceof PageSet?-1:1:this.min);-1!==this.max&&this.max!0});this.name=e.name||"";this.numbered=getInteger({data:e.numbered,defaultValue:1,validate:e=>!0});this.oddOrEven=getStringOption(e.oddOrEven,["any","even","odd"]);this.pagePosition=getStringOption(e.pagePosition,["any","first","last","only","rest"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.desc=null;this.extras=null;this.medium=null;this.occur=null;this.area=new XFAObjectArray;this.contentArea=new XFAObjectArray;this.draw=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.field=new XFAObjectArray;this.subform=new XFAObjectArray}[br](){if(!this[Xs]){this[Xs]={numberOfUse:0};return!0}return!this.occur||-1===this.occur.max||this[Xs].numberOfUsee.oddOrEven===t&&e.pagePosition===i));if(a)return a;a=this.pageArea.children.find((e=>"any"===e.oddOrEven&&e.pagePosition===i));if(a)return a;a=this.pageArea.children.find((e=>"any"===e.oddOrEven&&"any"===e.pagePosition));return a||this.pageArea.children[0]}}class Para extends XFAObject{constructor(e){super(Nn,"para",!0);this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.lineHeight=e.lineHeight?getMeasurement(e.lineHeight,"0pt"):"";this.marginLeft=e.marginLeft?getMeasurement(e.marginLeft,"0pt"):"";this.marginRight=e.marginRight?getMeasurement(e.marginRight,"0pt"):"";this.orphans=getInteger({data:e.orphans,defaultValue:0,validate:e=>e>=0});this.preserve=e.preserve||"";this.radixOffset=e.radixOffset?getMeasurement(e.radixOffset,"0pt"):"";this.spaceAbove=e.spaceAbove?getMeasurement(e.spaceAbove,"0pt"):"";this.spaceBelow=e.spaceBelow?getMeasurement(e.spaceBelow,"0pt"):"";this.tabDefault=e.tabDefault?getMeasurement(this.tabDefault):"";this.tabStops=(e.tabStops||"").trim().split(/\s+/).map(((e,t)=>t%2==1?getMeasurement(e):e));this.textIndent=e.textIndent?getMeasurement(e.textIndent,"0pt"):"";this.use=e.use||"";this.usehref=e.usehref||"";this.vAlign=getStringOption(e.vAlign,["top","bottom","middle"]);this.widows=getInteger({data:e.widows,defaultValue:0,validate:e=>e>=0});this.hyphenation=null}[Zr](){const e=toStyle(this,"hAlign");""!==this.marginLeft&&(e.paddingLeft=measureToString(this.marginLeft));""!==this.marginRight&&(e.paddingRight=measureToString(this.marginRight));""!==this.spaceAbove&&(e.paddingTop=measureToString(this.spaceAbove));""!==this.spaceBelow&&(e.paddingBottom=measureToString(this.spaceBelow));if(""!==this.textIndent){e.textIndent=measureToString(this.textIndent);fixTextIndent(e)}this.lineHeight>0&&(e.lineHeight=measureToString(this.lineHeight));""!==this.tabDefault&&(e.tabSize=measureToString(this.tabDefault));this.tabStops.length;this.hyphenatation&&Object.assign(e,this.hyphenatation[Zr]());return e}}class PasswordEdit extends XFAObject{constructor(e){super(Nn,"passwordEdit",!0);this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.passwordChar=e.passwordChar||"*";this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.margin=null}}class template_Pattern extends XFAObject{constructor(e){super(Nn,"pattern",!0);this.id=e.id||"";this.type=getStringOption(e.type,["crossHatch","crossDiagonal","diagonalLeft","diagonalRight","horizontal","vertical"]);this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){e=e?e[Zr]():"#FFFFFF";const t=this.color?this.color[Zr]():"#000000",i="repeating-linear-gradient",a=`${e},${e} 5px,${t} 5px,${t} 10px`;switch(this.type){case"crossHatch":return`${i}(to top,${a}) ${i}(to right,${a})`;case"crossDiagonal":return`${i}(45deg,${a}) ${i}(-45deg,${a})`;case"diagonalLeft":return`${i}(45deg,${a})`;case"diagonalRight":return`${i}(-45deg,${a})`;case"horizontal":return`${i}(to top,${a})`;case"vertical":return`${i}(to right,${a})`}return""}}class Picture extends StringObject{constructor(e){super(Nn,"picture");this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Proto extends XFAObject{constructor(e){super(Nn,"proto",!0);this.appearanceFilter=new XFAObjectArray;this.arc=new XFAObjectArray;this.area=new XFAObjectArray;this.assist=new XFAObjectArray;this.barcode=new XFAObjectArray;this.bindItems=new XFAObjectArray;this.bookend=new XFAObjectArray;this.boolean=new XFAObjectArray;this.border=new XFAObjectArray;this.break=new XFAObjectArray;this.breakAfter=new XFAObjectArray;this.breakBefore=new XFAObjectArray;this.button=new XFAObjectArray;this.calculate=new XFAObjectArray;this.caption=new XFAObjectArray;this.certificate=new XFAObjectArray;this.certificates=new XFAObjectArray;this.checkButton=new XFAObjectArray;this.choiceList=new XFAObjectArray;this.color=new XFAObjectArray;this.comb=new XFAObjectArray;this.connect=new XFAObjectArray;this.contentArea=new XFAObjectArray;this.corner=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.dateTimeEdit=new XFAObjectArray;this.decimal=new XFAObjectArray;this.defaultUi=new XFAObjectArray;this.desc=new XFAObjectArray;this.digestMethod=new XFAObjectArray;this.digestMethods=new XFAObjectArray;this.draw=new XFAObjectArray;this.edge=new XFAObjectArray;this.encoding=new XFAObjectArray;this.encodings=new XFAObjectArray;this.encrypt=new XFAObjectArray;this.encryptData=new XFAObjectArray;this.encryption=new XFAObjectArray;this.encryptionMethod=new XFAObjectArray;this.encryptionMethods=new XFAObjectArray;this.event=new XFAObjectArray;this.exData=new XFAObjectArray;this.exObject=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.execute=new XFAObjectArray;this.extras=new XFAObjectArray;this.field=new XFAObjectArray;this.fill=new XFAObjectArray;this.filter=new XFAObjectArray;this.float=new XFAObjectArray;this.font=new XFAObjectArray;this.format=new XFAObjectArray;this.handler=new XFAObjectArray;this.hyphenation=new XFAObjectArray;this.image=new XFAObjectArray;this.imageEdit=new XFAObjectArray;this.integer=new XFAObjectArray;this.issuers=new XFAObjectArray;this.items=new XFAObjectArray;this.keep=new XFAObjectArray;this.keyUsage=new XFAObjectArray;this.line=new XFAObjectArray;this.linear=new XFAObjectArray;this.lockDocument=new XFAObjectArray;this.manifest=new XFAObjectArray;this.margin=new XFAObjectArray;this.mdp=new XFAObjectArray;this.medium=new XFAObjectArray;this.message=new XFAObjectArray;this.numericEdit=new XFAObjectArray;this.occur=new XFAObjectArray;this.oid=new XFAObjectArray;this.oids=new XFAObjectArray;this.overflow=new XFAObjectArray;this.pageArea=new XFAObjectArray;this.pageSet=new XFAObjectArray;this.para=new XFAObjectArray;this.passwordEdit=new XFAObjectArray;this.pattern=new XFAObjectArray;this.picture=new XFAObjectArray;this.radial=new XFAObjectArray;this.reason=new XFAObjectArray;this.reasons=new XFAObjectArray;this.rectangle=new XFAObjectArray;this.ref=new XFAObjectArray;this.script=new XFAObjectArray;this.setProperty=new XFAObjectArray;this.signData=new XFAObjectArray;this.signature=new XFAObjectArray;this.signing=new XFAObjectArray;this.solid=new XFAObjectArray;this.speak=new XFAObjectArray;this.stipple=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray;this.subjectDN=new XFAObjectArray;this.subjectDNs=new XFAObjectArray;this.submit=new XFAObjectArray;this.text=new XFAObjectArray;this.textEdit=new XFAObjectArray;this.time=new XFAObjectArray;this.timeStamp=new XFAObjectArray;this.toolTip=new XFAObjectArray;this.traversal=new XFAObjectArray;this.traverse=new XFAObjectArray;this.ui=new XFAObjectArray;this.validate=new XFAObjectArray;this.value=new XFAObjectArray;this.variables=new XFAObjectArray}}class Radial extends XFAObject{constructor(e){super(Nn,"radial",!0);this.id=e.id||"";this.type=getStringOption(e.type,["toEdge","toCenter"]);this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){e=e?e[Zr]():"#FFFFFF";const t=this.color?this.color[Zr]():"#000000";return`radial-gradient(circle at center, ${"toEdge"===this.type?`${e},${t}`:`${t},${e}`})`}}class Reason extends StringObject{constructor(e){super(Nn,"reason");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Reasons extends XFAObject{constructor(e){super(Nn,"reasons",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.reason=new XFAObjectArray}}class Rectangle extends XFAObject{constructor(e){super(Nn,"rectangle",!0);this.hand=getStringOption(e.hand,["even","left","right"]);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.corner=new XFAObjectArray(4);this.edge=new XFAObjectArray(4);this.fill=null}[jr](){const e=this.edge.children.length?this.edge.children[0]:new Edge({}),t=e[Zr](),i=Object.create(null);"visible"===this.fill?.presence?Object.assign(i,this.fill[Zr]()):i.fill="transparent";i.strokeWidth=measureToString("visible"===e.presence?e.thickness:0);i.stroke=t.color;const a=(this.corner.children.length?this.corner.children[0]:new Corner({}))[Zr](),s={name:"svg",children:[{name:"rect",attributes:{xmlns:Gn,width:"100%",height:"100%",x:0,y:0,rx:a.radius,ry:a.radius,style:i}}],attributes:{xmlns:Gn,style:{overflow:"visible"},width:"100%",height:"100%"}};if(hasMargin(this[Ir]()[Ir]()))return HTMLResult.success({name:"div",attributes:{style:{display:"inline",width:"100%",height:"100%"}},children:[s]});s.attributes.style.position="absolute";return HTMLResult.success(s)}}class RefElement extends StringObject{constructor(e){super(Nn,"ref");this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Script extends StringObject{constructor(e){super(Nn,"script");this.binding=e.binding||"";this.contentType=e.contentType||"";this.id=e.id||"";this.name=e.name||"";this.runAt=getStringOption(e.runAt,["client","both","server"]);this.use=e.use||"";this.usehref=e.usehref||""}}class SetProperty extends XFAObject{constructor(e){super(Nn,"setProperty");this.connection=e.connection||"";this.ref=e.ref||"";this.target=e.target||""}}class SignData extends XFAObject{constructor(e){super(Nn,"signData",!0);this.id=e.id||"";this.operation=getStringOption(e.operation,["sign","clear","verify"]);this.ref=e.ref||"";this.target=e.target||"";this.use=e.use||"";this.usehref=e.usehref||"";this.filter=null;this.manifest=null}}class Signature extends XFAObject{constructor(e){super(Nn,"signature",!0);this.id=e.id||"";this.type=getStringOption(e.type,["PDF1.3","PDF1.6"]);this.use=e.use||"";this.usehref=e.usehref||"";this.border=null;this.extras=null;this.filter=null;this.manifest=null;this.margin=null}}class Signing extends XFAObject{constructor(e){super(Nn,"signing",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.certificate=new XFAObjectArray}}class Solid extends XFAObject{constructor(e){super(Nn,"solid",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null}[Zr](e){return e?e[Zr]():"#FFFFFF"}}class Speak extends StringObject{constructor(e){super(Nn,"speak");this.disable=getInteger({data:e.disable,defaultValue:0,validate:e=>1===e});this.id=e.id||"";this.priority=getStringOption(e.priority,["custom","caption","name","toolTip"]);this.rid=e.rid||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Stipple extends XFAObject{constructor(e){super(Nn,"stipple",!0);this.id=e.id||"";this.rate=getInteger({data:e.rate,defaultValue:50,validate:e=>e>=0&&e<=100});this.use=e.use||"";this.usehref=e.usehref||"";this.color=null;this.extras=null}[Zr](e){const t=this.rate/100;return Util.makeHexColor(Math.round(e.value.r*(1-t)+this.value.r*t),Math.round(e.value.g*(1-t)+this.value.g*t),Math.round(e.value.b*(1-t)+this.value.b*t))}}class Subform extends XFAObject{constructor(e){super(Nn,"subform",!0);this.access=getStringOption(e.access,["open","nonInteractive","protected","readOnly"]);this.allowMacro=getInteger({data:e.allowMacro,defaultValue:0,validate:e=>1===e});this.anchorType=getStringOption(e.anchorType,["topLeft","bottomCenter","bottomLeft","bottomRight","middleCenter","middleLeft","middleRight","topCenter","topRight"]);this.colSpan=getInteger({data:e.colSpan,defaultValue:1,validate:e=>e>=1||-1===e});this.columnWidths=(e.columnWidths||"").trim().split(/\s+/).map((e=>"-1"===e?-1:getMeasurement(e)));this.h=e.h?getMeasurement(e.h):"";this.hAlign=getStringOption(e.hAlign,["left","center","justify","justifyAll","radix","right"]);this.id=e.id||"";this.layout=getStringOption(e.layout,["position","lr-tb","rl-row","rl-tb","row","table","tb"]);this.locale=e.locale||"";this.maxH=getMeasurement(e.maxH,"0pt");this.maxW=getMeasurement(e.maxW,"0pt");this.mergeMode=getStringOption(e.mergeMode,["consumeData","matchTemplate"]);this.minH=getMeasurement(e.minH,"0pt");this.minW=getMeasurement(e.minW,"0pt");this.name=e.name||"";this.presence=getStringOption(e.presence,["visible","hidden","inactive","invisible"]);this.relevant=getRelevant(e.relevant);this.restoreState=getStringOption(e.restoreState,["manual","auto"]);this.scope=getStringOption(e.scope,["name","none"]);this.use=e.use||"";this.usehref=e.usehref||"";this.w=e.w?getMeasurement(e.w):"";this.x=getMeasurement(e.x,"0pt");this.y=getMeasurement(e.y,"0pt");this.assist=null;this.bind=null;this.bookend=null;this.border=null;this.break=null;this.calculate=null;this.desc=null;this.extras=null;this.keep=null;this.margin=null;this.occur=null;this.overflow=null;this.pageSet=null;this.para=null;this.traversal=null;this.validate=null;this.variables=null;this.area=new XFAObjectArray;this.breakAfter=new XFAObjectArray;this.breakBefore=new XFAObjectArray;this.connect=new XFAObjectArray;this.draw=new XFAObjectArray;this.event=new XFAObjectArray;this.exObject=new XFAObjectArray;this.exclGroup=new XFAObjectArray;this.field=new XFAObjectArray;this.proto=new XFAObjectArray;this.setProperty=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray}[or](){const e=this[Ir]();return e instanceof SubformSet?e[or]():e}[dr](){return!0}[wr](){return this.layout.endsWith("-tb")&&0===this[Xs].attempt&&this[Xs].numberInLine>0||this[Ir]()[wr]()}*[nr](){yield*getContainedChildren(this)}[Vs](){return flushHTML(this)}[Ls](e,t){addHTML(this,e,t)}[$s](){return getAvailableSpace(this)}[yr](){const e=this[or]();if(!e[yr]())return!1;if(void 0!==this[Xs]._isSplittable)return this[Xs]._isSplittable;if("position"===this.layout||this.layout.includes("row")){this[Xs]._isSplittable=!1;return!1}if(this.keep&&"none"!==this.keep.intact){this[Xs]._isSplittable=!1;return!1}if(e.layout?.endsWith("-tb")&&0!==e[Xs].numberInLine)return!1;this[Xs]._isSplittable=!0;return!0}[jr](e){setTabIndex(this);if(this.break){if("auto"!==this.break.after||""!==this.break.afterTarget){const e=new BreakAfter({targetType:this.break.after,target:this.break.afterTarget,startNew:this.break.startNew.toString()});e[Cr]=this[Cr];this[Hs](e);this.breakAfter.push(e)}if("auto"!==this.break.before||""!==this.break.beforeTarget){const e=new BreakBefore({targetType:this.break.before,target:this.break.beforeTarget,startNew:this.break.startNew.toString()});e[Cr]=this[Cr];this[Hs](e);this.breakBefore.push(e)}if(""!==this.break.overflowTarget){const e=new Overflow({target:this.break.overflowTarget,leader:this.break.overflowLeader,trailer:this.break.overflowTrailer});e[Cr]=this[Cr];this[Hs](e);this.overflow.push(e)}this[Hr](this.break);this.break=null}if("hidden"===this.presence||"inactive"===this.presence)return HTMLResult.EMPTY;(this.breakBefore.children.length>1||this.breakAfter.children.length>1)&&warn("XFA - Several breakBefore or breakAfter in subforms: please file a bug.");if(this.breakBefore.children.length>=1){const e=this.breakBefore.children[0];if(handleBreak(e))return HTMLResult.breakNode(e)}if(this[Xs]?.afterBreakAfter)return HTMLResult.EMPTY;fixDimensions(this);const t=[],i={id:this[Vr],class:[]};setAccess(this,i.class);this[Xs]||(this[Xs]=Object.create(null));Object.assign(this[Xs],{children:t,line:null,attributes:i,attempt:0,numberInLine:0,availableSpace:{width:Math.min(this.w||1/0,e.width),height:Math.min(this.h||1/0,e.height)},width:0,height:0,prevHeight:0,currentWidth:0});const a=this[cr](),s=a[Xs].noLayoutFailure,r=this[yr]();r||setFirstUnsplittable(this);if(!checkDimensions(this,e))return HTMLResult.FAILURE;const n=new Set(["area","draw","exclGroup","field","subform","subformSet"]);if(this.layout.includes("row")){const e=this[or]().columnWidths;if(Array.isArray(e)&&e.length>0){this[Xs].columnWidths=e;this[Xs].currentColumn=0}}const g=toStyle(this,"anchorType","dimensions","position","presence","border","margin","hAlign"),o=["xfaSubform"],c=layoutClass(this);c&&o.push(c);i.style=g;i.class=o;this.name&&(i.xfaName=this.name);if(this.overflow){const t=this.overflow[ar]();if(t.addLeader){t.addLeader=!1;handleOverflow(this,t.leader,e)}}this[Lr]();const C="lr-tb"===this.layout||"rl-tb"===this.layout,h=C?2:1;for(;this[Xs].attempt=1){const e=this.breakAfter.children[0];if(handleBreak(e)){this[Xs].afterBreakAfter=p;return HTMLResult.breakNode(e)}}delete this[Xs];return p}}class SubformSet extends XFAObject{constructor(e){super(Nn,"subformSet",!0);this.id=e.id||"";this.name=e.name||"";this.relation=getStringOption(e.relation,["ordered","choice","unordered"]);this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.bookend=null;this.break=null;this.desc=null;this.extras=null;this.occur=null;this.overflow=null;this.breakAfter=new XFAObjectArray;this.breakBefore=new XFAObjectArray;this.subform=new XFAObjectArray;this.subformSet=new XFAObjectArray}*[nr](){yield*getContainedChildren(this)}[or](){let e=this[Ir]();for(;!(e instanceof Subform);)e=e[Ir]();return e}[dr](){return!0}}class SubjectDN extends ContentObject{constructor(e){super(Nn,"subjectDN");this.delimiter=e.delimiter||",";this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){this[Os]=new Map(this[Os].split(this.delimiter).map((e=>{(e=e.split("=",2))[0]=e[0].trim();return e})))}}class SubjectDNs extends XFAObject{constructor(e){super(Nn,"subjectDNs",!0);this.id=e.id||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||"";this.subjectDN=new XFAObjectArray}}class Submit extends XFAObject{constructor(e){super(Nn,"submit",!0);this.embedPDF=getInteger({data:e.embedPDF,defaultValue:0,validate:e=>1===e});this.format=getStringOption(e.format,["xdp","formdata","pdf","urlencoded","xfd","xml"]);this.id=e.id||"";this.target=e.target||"";this.textEncoding=getKeyword({data:e.textEncoding?e.textEncoding.toLowerCase():"",defaultValue:"",validate:e=>["utf-8","big-five","fontspecific","gbk","gb-18030","gb-2312","ksc-5601","none","shift-jis","ucs-2","utf-16"].includes(e)||e.match(/iso-8859-\d{2}/)});this.use=e.use||"";this.usehref=e.usehref||"";this.xdpContent=e.xdpContent||"";this.encrypt=null;this.encryptData=new XFAObjectArray;this.signData=new XFAObjectArray}}class Template extends XFAObject{constructor(e){super(Nn,"template",!0);this.baseProfile=getStringOption(e.baseProfile,["full","interactiveForms"]);this.extras=null;this.subform=new XFAObjectArray}[Zs](){0===this.subform.children.length&&warn("XFA - No subforms in template node.");this.subform.children.length>=2&&warn("XFA - Several subforms in template node: please file a bug.");this[Or]=5e3}[yr](){return!0}[vr](e,t){return e.startsWith("#")?[this[lr].get(e.slice(1))]:searchNode(this,t,e,!0,!0)}*[Wr](){if(!this.subform.children.length)return HTMLResult.success({name:"div",children:[]});this[Xs]={overflowNode:null,firstUnsplittable:null,currentContentArea:null,currentPageArea:null,noLayoutFailure:!1,pageNumber:1,pagePosition:"first",oddOrEven:"odd",blankOrNotBlank:"nonBlank",paraStack:[]};const e=this.subform.children[0];e.pageSet[vs]();const t=e.pageSet.pageArea.children,i={name:"div",children:[]};let a=null,s=null,r=null;if(e.breakBefore.children.length>=1){s=e.breakBefore.children[0];r=s.target}else if(e.subform.children.length>=1&&e.subform.children[0].breakBefore.children.length>=1){s=e.subform.children[0].breakBefore.children[0];r=s.target}else if(e.break?.beforeTarget){s=e.break;r=s.beforeTarget}else if(e.subform.children.length>=1&&e.subform.children[0].break?.beforeTarget){s=e.subform.children[0].break;r=s.beforeTarget}if(s){const e=this[vr](r,s[Ir]());if(e instanceof PageArea){a=e;s[Xs]={}}}a||(a=t[0]);a[Xs]={numberOfUse:1};const n=a[Ir]();n[Xs]={numberOfUse:1,pageIndex:n.pageArea.children.indexOf(a),pageSetIndex:0};let g,o=null,c=null,C=!0,h=0,l=0;for(;;){if(C)h=0;else{i.children.pop();if(3==++h){warn("XFA - Something goes wrong: please file a bug.");return i}}g=null;this[Xs].currentPageArea=a;const t=a[jr]().html;i.children.push(t);if(o){this[Xs].noLayoutFailure=!0;t.children.push(o[jr](a[Xs].space).html);o=null}if(c){this[Xs].noLayoutFailure=!0;t.children.push(c[jr](a[Xs].space).html);c=null}const s=a.contentArea.children,r=t.children.filter((e=>e.attributes.class.includes("xfaContentarea")));C=!1;this[Xs].firstUnsplittable=null;this[Xs].noLayoutFailure=!1;const flush=t=>{const i=e[Vs]();if(i){C||=i.children?.length>0;r[t].children.push(i)}};for(let t=l,a=s.length;t0;r[t].children.push(h.html)}else!C&&i.children.length>1&&i.children.pop();return i}if(h.isBreak()){const e=h.breakNode;flush(t);if("auto"===e.targetType)continue;if(e.leader){o=this[vr](e.leader,e[Ir]());o=o?o[0]:null}if(e.trailer){c=this[vr](e.trailer,e[Ir]());c=c?c[0]:null}if("pageArea"===e.targetType){g=e[Xs].target;t=1/0}else if(e[Xs].target){g=e[Xs].target;l=e[Xs].index+1;t=1/0}else t=e[Xs].index}else if(this[Xs].overflowNode){const e=this[Xs].overflowNode;this[Xs].overflowNode=null;const i=e[ar](),a=i.target;i.addLeader=null!==i.leader;i.addTrailer=null!==i.trailer;flush(t);const r=t;t=1/0;if(a instanceof PageArea)g=a;else if(a instanceof ContentArea){const e=s.indexOf(a);if(-1!==e)e>r?t=e-1:l=e;else{g=a[Ir]();l=g.contentArea.children.indexOf(a)}}}else flush(t)}this[Xs].pageNumber+=1;g&&(g[br]()?g[Xs].numberOfUse+=1:g=null);a=g||a[gr]();yield null}}}class Text extends ContentObject{constructor(e){super(Nn,"text");this.id=e.id||"";this.maxChars=getInteger({data:e.maxChars,defaultValue:0,validate:e=>e>=0});this.name=e.name||"";this.rid=e.rid||"";this.use=e.use||"";this.usehref=e.usehref||""}[xs](){return!0}[Nr](e){if(e[Sr]===_r.xhtml.id){this[Os]=e;return!0}warn(`XFA - Invalid content in Text: ${e[kr]}.`);return!1}[Mr](e){this[Os]instanceof XFAObject||super[Mr](e)}[Zs](){"string"==typeof this[Os]&&(this[Os]=this[Os].replaceAll("\r\n","\n"))}[ar](){return"string"==typeof this[Os]?this[Os].split(/[\u2029\u2028\n]/).reduce(((e,t)=>{t&&e.push(t);return e}),[]).join("\n"):this[Os][Pr]()}[jr](e){if("string"==typeof this[Os]){const e=valueToHtml(this[Os]).html;if(this[Os].includes("\u2029")){e.name="div";e.children=[];this[Os].split("\u2029").map((e=>e.split(/[\u2028\n]/).reduce(((e,t)=>{e.push({name:"span",value:t},{name:"br"});return e}),[]))).forEach((t=>{e.children.push({name:"p",children:t})}))}else if(/[\u2028\n]/.test(this[Os])){e.name="div";e.children=[];this[Os].split(/[\u2028\n]/).forEach((t=>{e.children.push({name:"span",value:t},{name:"br"})}))}return HTMLResult.success(e)}return this[Os][jr](e)}}class TextEdit extends XFAObject{constructor(e){super(Nn,"textEdit",!0);this.allowRichText=getInteger({data:e.allowRichText,defaultValue:0,validate:e=>1===e});this.hScrollPolicy=getStringOption(e.hScrollPolicy,["auto","off","on"]);this.id=e.id||"";this.multiLine=getInteger({data:e.multiLine,defaultValue:"",validate:e=>0===e||1===e});this.use=e.use||"";this.usehref=e.usehref||"";this.vScrollPolicy=getStringOption(e.vScrollPolicy,["auto","off","on"]);this.border=null;this.comb=null;this.extras=null;this.margin=null}[jr](e){const t=toStyle(this,"border","font","margin");let i;const a=this[Ir]()[Ir]();""===this.multiLine&&(this.multiLine=a instanceof Draw?1:0);i=1===this.multiLine?{name:"textarea",attributes:{dataId:a[Ws]?.[Vr]||a[Vr],fieldId:a[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(a),"aria-required":!1}}:{name:"input",attributes:{type:"text",dataId:a[Ws]?.[Vr]||a[Vr],fieldId:a[Vr],class:["xfaTextfield"],style:t,"aria-label":ariaLabel(a),"aria-required":!1}};if(isRequired(a)){i.attributes["aria-required"]=!0;i.attributes.required=!0}return HTMLResult.success({name:"label",attributes:{class:["xfaLabel"]},children:[i]})}}class Time extends StringObject{constructor(e){super(Nn,"time");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}[Zs](){const e=this[Os].trim();this[Os]=e?new Date(e):null}[jr](e){return valueToHtml(this[Os]?this[Os].toString():"")}}class TimeStamp extends XFAObject{constructor(e){super(Nn,"timeStamp");this.id=e.id||"";this.server=e.server||"";this.type=getStringOption(e.type,["optional","required"]);this.use=e.use||"";this.usehref=e.usehref||""}}class ToolTip extends StringObject{constructor(e){super(Nn,"toolTip");this.id=e.id||"";this.rid=e.rid||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Traversal extends XFAObject{constructor(e){super(Nn,"traversal",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.traverse=new XFAObjectArray}}class Traverse extends XFAObject{constructor(e){super(Nn,"traverse",!0);this.id=e.id||"";this.operation=getStringOption(e.operation,["next","back","down","first","left","right","up"]);this.ref=e.ref||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.script=null}get name(){return this.operation}[Dr](){return!1}}class Ui extends XFAObject{constructor(e){super(Nn,"ui",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.picture=null;this.barcode=null;this.button=null;this.checkButton=null;this.choiceList=null;this.dateTimeEdit=null;this.defaultUi=null;this.imageEdit=null;this.numericEdit=null;this.passwordEdit=null;this.signature=null;this.textEdit=null}[ar](){if(void 0===this[Xs]){for(const e of Object.getOwnPropertyNames(this)){if("extras"===e||"picture"===e)continue;const t=this[e];if(t instanceof XFAObject){this[Xs]=t;return t}}this[Xs]=null}return this[Xs]}[jr](e){const t=this[ar]();return t?t[jr](e):HTMLResult.EMPTY}}class Validate extends XFAObject{constructor(e){super(Nn,"validate",!0);this.formatTest=getStringOption(e.formatTest,["warning","disabled","error"]);this.id=e.id||"";this.nullTest=getStringOption(e.nullTest,["disabled","error","warning"]);this.scriptTest=getStringOption(e.scriptTest,["error","disabled","warning"]);this.use=e.use||"";this.usehref=e.usehref||"";this.extras=null;this.message=null;this.picture=null;this.script=null}}class Value extends XFAObject{constructor(e){super(Nn,"value",!0);this.id=e.id||"";this.override=getInteger({data:e.override,defaultValue:0,validate:e=>1===e});this.relevant=getRelevant(e.relevant);this.use=e.use||"";this.usehref=e.usehref||"";this.arc=null;this.boolean=null;this.date=null;this.dateTime=null;this.decimal=null;this.exData=null;this.float=null;this.image=null;this.integer=null;this.line=null;this.rectangle=null;this.text=null;this.time=null}[qr](e){const t=this[Ir]();if(t instanceof Field&&t.ui?.imageEdit){if(!this.image){this.image=new Image({});this[Hs](this.image)}this.image[Os]=e[Os];return}const i=e[kr];if(null===this[i]){for(const e of Object.getOwnPropertyNames(this)){const t=this[e];if(t instanceof XFAObject){this[e]=null;this[Hr](t)}}this[e[kr]]=e;this[Hs](e)}else this[i][Os]=e[Os]}[Pr](){if(this.exData)return"string"==typeof this.exData[Os]?this.exData[Os].trim():this.exData[Os][Pr]().trim();for(const e of Object.getOwnPropertyNames(this)){if("image"===e)continue;const t=this[e];if(t instanceof XFAObject)return(t[Os]||"").toString().trim()}return null}[jr](e){for(const t of Object.getOwnPropertyNames(this)){const i=this[t];if(i instanceof XFAObject)return i[jr](e)}return HTMLResult.EMPTY}}class Variables extends XFAObject{constructor(e){super(Nn,"variables",!0);this.id=e.id||"";this.use=e.use||"";this.usehref=e.usehref||"";this.boolean=new XFAObjectArray;this.date=new XFAObjectArray;this.dateTime=new XFAObjectArray;this.decimal=new XFAObjectArray;this.exData=new XFAObjectArray;this.float=new XFAObjectArray;this.image=new XFAObjectArray;this.integer=new XFAObjectArray;this.manifest=new XFAObjectArray;this.script=new XFAObjectArray;this.text=new XFAObjectArray;this.time=new XFAObjectArray}[Dr](){return!0}}class TemplateNamespace{static[zr](e,t){if(TemplateNamespace.hasOwnProperty(e)){const i=TemplateNamespace[e](t);i[Tr](t);return i}}static appearanceFilter(e){return new AppearanceFilter(e)}static arc(e){return new Arc(e)}static area(e){return new Area(e)}static assist(e){return new Assist(e)}static barcode(e){return new Barcode(e)}static bind(e){return new Bind(e)}static bindItems(e){return new BindItems(e)}static bookend(e){return new Bookend(e)}static boolean(e){return new BooleanElement(e)}static border(e){return new Border(e)}static break(e){return new Break(e)}static breakAfter(e){return new BreakAfter(e)}static breakBefore(e){return new BreakBefore(e)}static button(e){return new Button(e)}static calculate(e){return new Calculate(e)}static caption(e){return new Caption(e)}static certificate(e){return new Certificate(e)}static certificates(e){return new Certificates(e)}static checkButton(e){return new CheckButton(e)}static choiceList(e){return new ChoiceList(e)}static color(e){return new Color(e)}static comb(e){return new Comb(e)}static connect(e){return new Connect(e)}static contentArea(e){return new ContentArea(e)}static corner(e){return new Corner(e)}static date(e){return new DateElement(e)}static dateTime(e){return new DateTime(e)}static dateTimeEdit(e){return new DateTimeEdit(e)}static decimal(e){return new Decimal(e)}static defaultUi(e){return new DefaultUi(e)}static desc(e){return new Desc(e)}static digestMethod(e){return new DigestMethod(e)}static digestMethods(e){return new DigestMethods(e)}static draw(e){return new Draw(e)}static edge(e){return new Edge(e)}static encoding(e){return new Encoding(e)}static encodings(e){return new Encodings(e)}static encrypt(e){return new Encrypt(e)}static encryptData(e){return new EncryptData(e)}static encryption(e){return new Encryption(e)}static encryptionMethod(e){return new EncryptionMethod(e)}static encryptionMethods(e){return new EncryptionMethods(e)}static event(e){return new Event(e)}static exData(e){return new ExData(e)}static exObject(e){return new ExObject(e)}static exclGroup(e){return new ExclGroup(e)}static execute(e){return new Execute(e)}static extras(e){return new Extras(e)}static field(e){return new Field(e)}static fill(e){return new Fill(e)}static filter(e){return new Filter(e)}static float(e){return new Float(e)}static font(e){return new template_Font(e)}static format(e){return new Format(e)}static handler(e){return new Handler(e)}static hyphenation(e){return new Hyphenation(e)}static image(e){return new Image(e)}static imageEdit(e){return new ImageEdit(e)}static integer(e){return new Integer(e)}static issuers(e){return new Issuers(e)}static items(e){return new Items(e)}static keep(e){return new Keep(e)}static keyUsage(e){return new KeyUsage(e)}static line(e){return new Line(e)}static linear(e){return new Linear(e)}static lockDocument(e){return new LockDocument(e)}static manifest(e){return new Manifest(e)}static margin(e){return new Margin(e)}static mdp(e){return new Mdp(e)}static medium(e){return new Medium(e)}static message(e){return new Message(e)}static numericEdit(e){return new NumericEdit(e)}static occur(e){return new Occur(e)}static oid(e){return new Oid(e)}static oids(e){return new Oids(e)}static overflow(e){return new Overflow(e)}static pageArea(e){return new PageArea(e)}static pageSet(e){return new PageSet(e)}static para(e){return new Para(e)}static passwordEdit(e){return new PasswordEdit(e)}static pattern(e){return new template_Pattern(e)}static picture(e){return new Picture(e)}static proto(e){return new Proto(e)}static radial(e){return new Radial(e)}static reason(e){return new Reason(e)}static reasons(e){return new Reasons(e)}static rectangle(e){return new Rectangle(e)}static ref(e){return new RefElement(e)}static script(e){return new Script(e)}static setProperty(e){return new SetProperty(e)}static signData(e){return new SignData(e)}static signature(e){return new Signature(e)}static signing(e){return new Signing(e)}static solid(e){return new Solid(e)}static speak(e){return new Speak(e)}static stipple(e){return new Stipple(e)}static subform(e){return new Subform(e)}static subformSet(e){return new SubformSet(e)}static subjectDN(e){return new SubjectDN(e)}static subjectDNs(e){return new SubjectDNs(e)}static submit(e){return new Submit(e)}static template(e){return new Template(e)}static text(e){return new Text(e)}static textEdit(e){return new TextEdit(e)}static time(e){return new Time(e)}static timeStamp(e){return new TimeStamp(e)}static toolTip(e){return new ToolTip(e)}static traversal(e){return new Traversal(e)}static traverse(e){return new Traverse(e)}static ui(e){return new Ui(e)}static validate(e){return new Validate(e)}static value(e){return new Value(e)}static variables(e){return new Variables(e)}}const Ln=_r.datasets.id;function createText(e){const t=new Text({});t[Os]=e;return t}class Binder{constructor(e){this.root=e;this.datasets=e.datasets;this.data=e.datasets?.data||new XmlObject(_r.datasets.id,"data");this.emptyMerge=0===this.data[rr]().length;this.root.form=this.form=e.template[Ts]()}_isConsumeData(){return!this.emptyMerge&&this._mergeMode}_isMatchTemplate(){return!this._isConsumeData()}bind(){this._bindElement(this.form,this.data);return this.form}getData(){return this.data}_bindValue(e,t,i){e[Ws]=t;if(e[hr]())if(t[fr]()){const i=t[ir]();e[qr](createText(i))}else if(e instanceof Field&&"multiSelect"===e.ui?.choiceList?.open){const i=t[rr]().map((e=>e[Os].trim())).join("\n");e[qr](createText(i))}else this._isConsumeData()&&warn("XFA - Nodes haven't the same type.");else!t[fr]()||this._isMatchTemplate()?this._bindElement(e,t):warn("XFA - Nodes haven't the same type.")}_findDataByNameToConsume(e,t,i,a){if(!e)return null;let s,r;for(let a=0;a<3;a++){s=i[sr](e,!1,!0);for(;;){r=s.next().value;if(!r)break;if(t===r[fr]())return r}if(i[Sr]===_r.datasets.id&&"data"===i[kr])break;i=i[Ir]()}if(!a)return null;s=this.data[sr](e,!0,!1);r=s.next().value;if(r)return r;s=this.data[zs](e,!0);r=s.next().value;return r?.[fr]()?r:null}_setProperties(e,t){if(e.hasOwnProperty("setProperty"))for(const{ref:i,target:a,connection:s}of e.setProperty.children){if(s)continue;if(!i)continue;const r=searchNode(this.root,t,i,!1,!1);if(!r){warn(`XFA - Invalid reference: ${i}.`);continue}const[n]=r;if(!n[pr](this.data)){warn("XFA - Invalid node: must be a data node.");continue}const g=searchNode(this.root,e,a,!1,!1);if(!g){warn(`XFA - Invalid target: ${a}.`);continue}const[o]=g;if(!o[pr](e)){warn("XFA - Invalid target: must be a property or subproperty.");continue}const c=o[Ir]();if(o instanceof SetProperty||c instanceof SetProperty){warn("XFA - Invalid target: cannot be a setProperty or one of its properties.");continue}if(o instanceof BindItems||c instanceof BindItems){warn("XFA - Invalid target: cannot be a bindItems or one of its properties.");continue}const C=n[Pr](),h=o[kr];if(o instanceof XFAAttribute){const e=Object.create(null);e[h]=C;const t=Reflect.construct(Object.getPrototypeOf(c).constructor,[e]);c[h]=t[h]}else if(o.hasOwnProperty(Os)){o[Ws]=n;o[Os]=C;o[Zs]()}else warn("XFA - Invalid node to use in setProperty")}}_bindItems(e,t){if(!e.hasOwnProperty("items")||!e.hasOwnProperty("bindItems")||e.bindItems.isEmpty())return;for(const t of e.items.children)e[Hr](t);e.items.clear();const i=new Items({}),a=new Items({});e[Hs](i);e.items.push(i);e[Hs](a);e.items.push(a);for(const{ref:s,labelRef:r,valueRef:n,connection:g}of e.bindItems.children){if(g)continue;if(!s)continue;const e=searchNode(this.root,t,s,!1,!1);if(e)for(const t of e){if(!t[pr](this.datasets)){warn(`XFA - Invalid ref (${s}): must be a datasets child.`);continue}const e=searchNode(this.root,t,r,!0,!1);if(!e){warn(`XFA - Invalid label: ${r}.`);continue}const[g]=e;if(!g[pr](this.datasets)){warn("XFA - Invalid label: must be a datasets child.");continue}const o=searchNode(this.root,t,n,!0,!1);if(!o){warn(`XFA - Invalid value: ${n}.`);continue}const[c]=o;if(!c[pr](this.datasets)){warn("XFA - Invalid value: must be a datasets child.");continue}const C=createText(g[Pr]()),h=createText(c[Pr]());i[Hs](C);i.text.push(C);a[Hs](h);a.text.push(h)}else warn(`XFA - Invalid reference: ${s}.`)}}_bindOccurrences(e,t,i){let a;if(t.length>1){a=e[Ts]();a[Hr](a.occur);a.occur=null}this._bindValue(e,t[0],i);this._setProperties(e,t[0]);this._bindItems(e,t[0]);if(1===t.length)return;const s=e[Ir](),r=e[kr],n=s[Qr](e);for(let e=1,g=t.length;et.name===e.name)).length:i[a].children.length;const r=i[Qr](e)+1,n=t.initial-s;if(n){const t=e[Ts]();t[Hr](t.occur);t.occur=null;i[a].push(t);i[Er](r,t);for(let e=1;e0)this._bindOccurrences(a,[e[0]],null);else if(this.emptyMerge){const e=t[Sr]===Ln?-1:t[Sr],i=a[Ws]=new XmlObject(e,a.name||"root");t[Hs](i);this._bindElement(a,i)}continue}if(!a[dr]())continue;let e=!1,s=null,r=null,n=null;if(a.bind){switch(a.bind.match){case"none":this._setAndBind(a,t);continue;case"global":e=!0;break;case"dataRef":if(!a.bind.ref){warn(`XFA - ref is empty in node ${a[kr]}.`);this._setAndBind(a,t);continue}r=a.bind.ref}a.bind.picture&&(s=a.bind.picture[Os])}const[g,o]=this._getOccurInfo(a);if(r){n=searchNode(this.root,t,r,!0,!1);if(null===n){n=createDataNode(this.data,t,r);if(!n)continue;this._isConsumeData()&&(n[qs]=!0);this._setAndBind(a,n);continue}this._isConsumeData()&&(n=n.filter((e=>!e[qs])));n.length>o?n=n.slice(0,o):0===n.length&&(n=null);n&&this._isConsumeData()&&n.forEach((e=>{e[qs]=!0}))}else{if(!a.name){this._setAndBind(a,t);continue}if(this._isConsumeData()){const i=[];for(;i.length0?i:null}else{n=t[sr](a.name,!1,this.emptyMerge).next().value;if(!n){if(0===g){i.push(a);continue}const e=t[Sr]===Ln?-1:t[Sr];n=a[Ws]=new XmlObject(e,a.name);this.emptyMerge&&(n[qs]=!0);t[Hs](n);this._setAndBind(a,n);continue}this.emptyMerge&&(n[qs]=!0);n=[n]}}n?this._bindOccurrences(a,n,s):g>0?this._setAndBind(a,t):i.push(a)}i.forEach((e=>e[Ir]()[Hr](e)))}}class DataHandler{constructor(e,t){this.data=t;this.dataset=e.datasets||null}serialize(e){const t=[[-1,this.data[rr]()]];for(;t.length>0;){const i=t.at(-1),[a,s]=i;if(a+1===s.length){t.pop();continue}const r=s[++i[0]],n=e.get(r[Vr]);if(n)r[qr](n);else{const t=r[_s]();for(const i of t.values()){const t=e.get(i[Vr]);if(t){i[qr](t);break}}}const g=r[rr]();g.length>0&&t.push([-1,g])}const i=[''];if(this.dataset)for(const e of this.dataset[rr]())"data"!==e[kr]&&e[Xr](i);this.data[Xr](i);i.push("");return i.join("")}}const Hn=_r.config.id;class Acrobat extends XFAObject{constructor(e){super(Hn,"acrobat",!0);this.acrobat7=null;this.autoSave=null;this.common=null;this.validate=null;this.validateApprovalSignatures=null;this.submitUrl=new XFAObjectArray}}class Acrobat7 extends XFAObject{constructor(e){super(Hn,"acrobat7",!0);this.dynamicRender=null}}class ADBE_JSConsole extends OptionObject{constructor(e){super(Hn,"ADBE_JSConsole",["delegate","Enable","Disable"])}}class ADBE_JSDebugger extends OptionObject{constructor(e){super(Hn,"ADBE_JSDebugger",["delegate","Enable","Disable"])}}class AddSilentPrint extends Option01{constructor(e){super(Hn,"addSilentPrint")}}class AddViewerPreferences extends Option01{constructor(e){super(Hn,"addViewerPreferences")}}class AdjustData extends Option10{constructor(e){super(Hn,"adjustData")}}class AdobeExtensionLevel extends IntegerObject{constructor(e){super(Hn,"adobeExtensionLevel",0,(e=>e>=1&&e<=8))}}class Agent extends XFAObject{constructor(e){super(Hn,"agent",!0);this.name=e.name?e.name.trim():"";this.common=new XFAObjectArray}}class AlwaysEmbed extends ContentObject{constructor(e){super(Hn,"alwaysEmbed")}}class Amd extends StringObject{constructor(e){super(Hn,"amd")}}class config_Area extends XFAObject{constructor(e){super(Hn,"area");this.level=getInteger({data:e.level,defaultValue:0,validate:e=>e>=1&&e<=3});this.name=getStringOption(e.name,["","barcode","coreinit","deviceDriver","font","general","layout","merge","script","signature","sourceSet","templateCache"])}}class Attributes extends OptionObject{constructor(e){super(Hn,"attributes",["preserve","delegate","ignore"])}}class AutoSave extends OptionObject{constructor(e){super(Hn,"autoSave",["disabled","enabled"])}}class Base extends StringObject{constructor(e){super(Hn,"base")}}class BatchOutput extends XFAObject{constructor(e){super(Hn,"batchOutput");this.format=getStringOption(e.format,["none","concat","zip","zipCompress"])}}class BehaviorOverride extends ContentObject{constructor(e){super(Hn,"behaviorOverride")}[Zs](){this[Os]=new Map(this[Os].trim().split(/\s+/).filter((e=>e.includes(":"))).map((e=>e.split(":",2))))}}class Cache extends XFAObject{constructor(e){super(Hn,"cache",!0);this.templateCache=null}}class Change extends Option01{constructor(e){super(Hn,"change")}}class Common extends XFAObject{constructor(e){super(Hn,"common",!0);this.data=null;this.locale=null;this.localeSet=null;this.messaging=null;this.suppressBanner=null;this.template=null;this.validationMessaging=null;this.versionControl=null;this.log=new XFAObjectArray}}class Compress extends XFAObject{constructor(e){super(Hn,"compress");this.scope=getStringOption(e.scope,["imageOnly","document"])}}class CompressLogicalStructure extends Option01{constructor(e){super(Hn,"compressLogicalStructure")}}class CompressObjectStream extends Option10{constructor(e){super(Hn,"compressObjectStream")}}class Compression extends XFAObject{constructor(e){super(Hn,"compression",!0);this.compressLogicalStructure=null;this.compressObjectStream=null;this.level=null;this.type=null}}class Config extends XFAObject{constructor(e){super(Hn,"config",!0);this.acrobat=null;this.present=null;this.trace=null;this.agent=new XFAObjectArray}}class Conformance extends OptionObject{constructor(e){super(Hn,"conformance",["A","B"])}}class ContentCopy extends Option01{constructor(e){super(Hn,"contentCopy")}}class Copies extends IntegerObject{constructor(e){super(Hn,"copies",1,(e=>e>=1))}}class Creator extends StringObject{constructor(e){super(Hn,"creator")}}class CurrentPage extends IntegerObject{constructor(e){super(Hn,"currentPage",0,(e=>e>=0))}}class Data extends XFAObject{constructor(e){super(Hn,"data",!0);this.adjustData=null;this.attributes=null;this.incrementalLoad=null;this.outputXSL=null;this.range=null;this.record=null;this.startNode=null;this.uri=null;this.window=null;this.xsl=null;this.excludeNS=new XFAObjectArray;this.transform=new XFAObjectArray}}class Debug extends XFAObject{constructor(e){super(Hn,"debug",!0);this.uri=null}}class DefaultTypeface extends ContentObject{constructor(e){super(Hn,"defaultTypeface");this.writingScript=getStringOption(e.writingScript,["*","Arabic","Cyrillic","EastEuropeanRoman","Greek","Hebrew","Japanese","Korean","Roman","SimplifiedChinese","Thai","TraditionalChinese","Vietnamese"])}}class Destination extends OptionObject{constructor(e){super(Hn,"destination",["pdf","pcl","ps","webClient","zpl"])}}class DocumentAssembly extends Option01{constructor(e){super(Hn,"documentAssembly")}}class Driver extends XFAObject{constructor(e){super(Hn,"driver",!0);this.name=e.name?e.name.trim():"";this.fontInfo=null;this.xdc=null}}class DuplexOption extends OptionObject{constructor(e){super(Hn,"duplexOption",["simplex","duplexFlipLongEdge","duplexFlipShortEdge"])}}class DynamicRender extends OptionObject{constructor(e){super(Hn,"dynamicRender",["forbidden","required"])}}class Embed extends Option01{constructor(e){super(Hn,"embed")}}class config_Encrypt extends Option01{constructor(e){super(Hn,"encrypt")}}class config_Encryption extends XFAObject{constructor(e){super(Hn,"encryption",!0);this.encrypt=null;this.encryptionLevel=null;this.permissions=null}}class EncryptionLevel extends OptionObject{constructor(e){super(Hn,"encryptionLevel",["40bit","128bit"])}}class Enforce extends StringObject{constructor(e){super(Hn,"enforce")}}class Equate extends XFAObject{constructor(e){super(Hn,"equate");this.force=getInteger({data:e.force,defaultValue:1,validate:e=>0===e});this.from=e.from||"";this.to=e.to||""}}class EquateRange extends XFAObject{constructor(e){super(Hn,"equateRange");this.from=e.from||"";this.to=e.to||"";this._unicodeRange=e.unicodeRange||""}get unicodeRange(){const e=[],t=/U\+([0-9a-fA-F]+)/,i=this._unicodeRange;for(let a of i.split(",").map((e=>e.trim())).filter((e=>!!e))){a=a.split("-",2).map((e=>{const i=e.match(t);return i?parseInt(i[1],16):0}));1===a.length&&a.push(a[0]);e.push(a)}return shadow(this,"unicodeRange",e)}}class Exclude extends ContentObject{constructor(e){super(Hn,"exclude")}[Zs](){this[Os]=this[Os].trim().split(/\s+/).filter((e=>e&&["calculate","close","enter","exit","initialize","ready","validate"].includes(e)))}}class ExcludeNS extends StringObject{constructor(e){super(Hn,"excludeNS")}}class FlipLabel extends OptionObject{constructor(e){super(Hn,"flipLabel",["usePrinterSetting","on","off"])}}class config_FontInfo extends XFAObject{constructor(e){super(Hn,"fontInfo",!0);this.embed=null;this.map=null;this.subsetBelow=null;this.alwaysEmbed=new XFAObjectArray;this.defaultTypeface=new XFAObjectArray;this.neverEmbed=new XFAObjectArray}}class FormFieldFilling extends Option01{constructor(e){super(Hn,"formFieldFilling")}}class GroupParent extends StringObject{constructor(e){super(Hn,"groupParent")}}class IfEmpty extends OptionObject{constructor(e){super(Hn,"ifEmpty",["dataValue","dataGroup","ignore","remove"])}}class IncludeXDPContent extends StringObject{constructor(e){super(Hn,"includeXDPContent")}}class IncrementalLoad extends OptionObject{constructor(e){super(Hn,"incrementalLoad",["none","forwardOnly"])}}class IncrementalMerge extends Option01{constructor(e){super(Hn,"incrementalMerge")}}class Interactive extends Option01{constructor(e){super(Hn,"interactive")}}class Jog extends OptionObject{constructor(e){super(Hn,"jog",["usePrinterSetting","none","pageSet"])}}class LabelPrinter extends XFAObject{constructor(e){super(Hn,"labelPrinter",!0);this.name=getStringOption(e.name,["zpl","dpl","ipl","tcpl"]);this.batchOutput=null;this.flipLabel=null;this.fontInfo=null;this.xdc=null}}class Layout extends OptionObject{constructor(e){super(Hn,"layout",["paginate","panel"])}}class Level extends IntegerObject{constructor(e){super(Hn,"level",0,(e=>e>0))}}class Linearized extends Option01{constructor(e){super(Hn,"linearized")}}class Locale extends StringObject{constructor(e){super(Hn,"locale")}}class LocaleSet extends StringObject{constructor(e){super(Hn,"localeSet")}}class Log extends XFAObject{constructor(e){super(Hn,"log",!0);this.mode=null;this.threshold=null;this.to=null;this.uri=null}}class MapElement extends XFAObject{constructor(e){super(Hn,"map",!0);this.equate=new XFAObjectArray;this.equateRange=new XFAObjectArray}}class MediumInfo extends XFAObject{constructor(e){super(Hn,"mediumInfo",!0);this.map=null}}class config_Message extends XFAObject{constructor(e){super(Hn,"message",!0);this.msgId=null;this.severity=null}}class Messaging extends XFAObject{constructor(e){super(Hn,"messaging",!0);this.message=new XFAObjectArray}}class Mode extends OptionObject{constructor(e){super(Hn,"mode",["append","overwrite"])}}class ModifyAnnots extends Option01{constructor(e){super(Hn,"modifyAnnots")}}class MsgId extends IntegerObject{constructor(e){super(Hn,"msgId",1,(e=>e>=1))}}class NameAttr extends StringObject{constructor(e){super(Hn,"nameAttr")}}class NeverEmbed extends ContentObject{constructor(e){super(Hn,"neverEmbed")}}class NumberOfCopies extends IntegerObject{constructor(e){super(Hn,"numberOfCopies",null,(e=>e>=2&&e<=5))}}class OpenAction extends XFAObject{constructor(e){super(Hn,"openAction",!0);this.destination=null}}class Output extends XFAObject{constructor(e){super(Hn,"output",!0);this.to=null;this.type=null;this.uri=null}}class OutputBin extends StringObject{constructor(e){super(Hn,"outputBin")}}class OutputXSL extends XFAObject{constructor(e){super(Hn,"outputXSL",!0);this.uri=null}}class Overprint extends OptionObject{constructor(e){super(Hn,"overprint",["none","both","draw","field"])}}class Packets extends StringObject{constructor(e){super(Hn,"packets")}[Zs](){"*"!==this[Os]&&(this[Os]=this[Os].trim().split(/\s+/).filter((e=>["config","datasets","template","xfdf","xslt"].includes(e))))}}class PageOffset extends XFAObject{constructor(e){super(Hn,"pageOffset");this.x=getInteger({data:e.x,defaultValue:"useXDCSetting",validate:e=>!0});this.y=getInteger({data:e.y,defaultValue:"useXDCSetting",validate:e=>!0})}}class PageRange extends StringObject{constructor(e){super(Hn,"pageRange")}[Zs](){const e=this[Os].trim().split(/\s+/).map((e=>parseInt(e,10))),t=[];for(let i=0,a=e.length;i!1))}}class Pcl extends XFAObject{constructor(e){super(Hn,"pcl",!0);this.name=e.name||"";this.batchOutput=null;this.fontInfo=null;this.jog=null;this.mediumInfo=null;this.outputBin=null;this.pageOffset=null;this.staple=null;this.xdc=null}}class Pdf extends XFAObject{constructor(e){super(Hn,"pdf",!0);this.name=e.name||"";this.adobeExtensionLevel=null;this.batchOutput=null;this.compression=null;this.creator=null;this.encryption=null;this.fontInfo=null;this.interactive=null;this.linearized=null;this.openAction=null;this.pdfa=null;this.producer=null;this.renderPolicy=null;this.scriptModel=null;this.silentPrint=null;this.submitFormat=null;this.tagged=null;this.version=null;this.viewerPreferences=null;this.xdc=null}}class Pdfa extends XFAObject{constructor(e){super(Hn,"pdfa",!0);this.amd=null;this.conformance=null;this.includeXDPContent=null;this.part=null}}class Permissions extends XFAObject{constructor(e){super(Hn,"permissions",!0);this.accessibleContent=null;this.change=null;this.contentCopy=null;this.documentAssembly=null;this.formFieldFilling=null;this.modifyAnnots=null;this.plaintextMetadata=null;this.print=null;this.printHighQuality=null}}class PickTrayByPDFSize extends Option01{constructor(e){super(Hn,"pickTrayByPDFSize")}}class config_Picture extends StringObject{constructor(e){super(Hn,"picture")}}class PlaintextMetadata extends Option01{constructor(e){super(Hn,"plaintextMetadata")}}class Presence extends OptionObject{constructor(e){super(Hn,"presence",["preserve","dissolve","dissolveStructure","ignore","remove"])}}class Present extends XFAObject{constructor(e){super(Hn,"present",!0);this.behaviorOverride=null;this.cache=null;this.common=null;this.copies=null;this.destination=null;this.incrementalMerge=null;this.layout=null;this.output=null;this.overprint=null;this.pagination=null;this.paginationOverride=null;this.script=null;this.validate=null;this.xdp=null;this.driver=new XFAObjectArray;this.labelPrinter=new XFAObjectArray;this.pcl=new XFAObjectArray;this.pdf=new XFAObjectArray;this.ps=new XFAObjectArray;this.submitUrl=new XFAObjectArray;this.webClient=new XFAObjectArray;this.zpl=new XFAObjectArray}}class Print extends Option01{constructor(e){super(Hn,"print")}}class PrintHighQuality extends Option01{constructor(e){super(Hn,"printHighQuality")}}class PrintScaling extends OptionObject{constructor(e){super(Hn,"printScaling",["appdefault","noScaling"])}}class PrinterName extends StringObject{constructor(e){super(Hn,"printerName")}}class Producer extends StringObject{constructor(e){super(Hn,"producer")}}class Ps extends XFAObject{constructor(e){super(Hn,"ps",!0);this.name=e.name||"";this.batchOutput=null;this.fontInfo=null;this.jog=null;this.mediumInfo=null;this.outputBin=null;this.staple=null;this.xdc=null}}class Range extends ContentObject{constructor(e){super(Hn,"range")}[Zs](){this[Os]=this[Os].trim().split(/\s*,\s*/,2).map((e=>e.split("-").map((e=>parseInt(e.trim(),10))))).filter((e=>e.every((e=>!isNaN(e))))).map((e=>{1===e.length&&e.push(e[0]);return e}))}}class Record extends ContentObject{constructor(e){super(Hn,"record")}[Zs](){this[Os]=this[Os].trim();const e=parseInt(this[Os],10);!isNaN(e)&&e>=0&&(this[Os]=e)}}class Relevant extends ContentObject{constructor(e){super(Hn,"relevant")}[Zs](){this[Os]=this[Os].trim().split(/\s+/)}}class Rename extends ContentObject{constructor(e){super(Hn,"rename")}[Zs](){this[Os]=this[Os].trim();(this[Os].toLowerCase().startsWith("xml")||new RegExp("[\\p{L}_][\\p{L}\\d._\\p{M}-]*","u").test(this[Os]))&&warn("XFA - Rename: invalid XFA name")}}class RenderPolicy extends OptionObject{constructor(e){super(Hn,"renderPolicy",["server","client"])}}class RunScripts extends OptionObject{constructor(e){super(Hn,"runScripts",["both","client","none","server"])}}class config_Script extends XFAObject{constructor(e){super(Hn,"script",!0);this.currentPage=null;this.exclude=null;this.runScripts=null}}class ScriptModel extends OptionObject{constructor(e){super(Hn,"scriptModel",["XFA","none"])}}class Severity extends OptionObject{constructor(e){super(Hn,"severity",["ignore","error","information","trace","warning"])}}class SilentPrint extends XFAObject{constructor(e){super(Hn,"silentPrint",!0);this.addSilentPrint=null;this.printerName=null}}class Staple extends XFAObject{constructor(e){super(Hn,"staple");this.mode=getStringOption(e.mode,["usePrinterSetting","on","off"])}}class StartNode extends StringObject{constructor(e){super(Hn,"startNode")}}class StartPage extends IntegerObject{constructor(e){super(Hn,"startPage",0,(e=>!0))}}class SubmitFormat extends OptionObject{constructor(e){super(Hn,"submitFormat",["html","delegate","fdf","xml","pdf"])}}class SubmitUrl extends StringObject{constructor(e){super(Hn,"submitUrl")}}class SubsetBelow extends IntegerObject{constructor(e){super(Hn,"subsetBelow",100,(e=>e>=0&&e<=100))}}class SuppressBanner extends Option01{constructor(e){super(Hn,"suppressBanner")}}class Tagged extends Option01{constructor(e){super(Hn,"tagged")}}class config_Template extends XFAObject{constructor(e){super(Hn,"template",!0);this.base=null;this.relevant=null;this.startPage=null;this.uri=null;this.xsl=null}}class Threshold extends OptionObject{constructor(e){super(Hn,"threshold",["trace","error","information","warning"])}}class To extends OptionObject{constructor(e){super(Hn,"to",["null","memory","stderr","stdout","system","uri"])}}class TemplateCache extends XFAObject{constructor(e){super(Hn,"templateCache");this.maxEntries=getInteger({data:e.maxEntries,defaultValue:5,validate:e=>e>=0})}}class Trace extends XFAObject{constructor(e){super(Hn,"trace",!0);this.area=new XFAObjectArray}}class Transform extends XFAObject{constructor(e){super(Hn,"transform",!0);this.groupParent=null;this.ifEmpty=null;this.nameAttr=null;this.picture=null;this.presence=null;this.rename=null;this.whitespace=null}}class Type extends OptionObject{constructor(e){super(Hn,"type",["none","ascii85","asciiHex","ccittfax","flate","lzw","runLength","native","xdp","mergedXDP"])}}class Uri extends StringObject{constructor(e){super(Hn,"uri")}}class config_Validate extends OptionObject{constructor(e){super(Hn,"validate",["preSubmit","prePrint","preExecute","preSave"])}}class ValidateApprovalSignatures extends ContentObject{constructor(e){super(Hn,"validateApprovalSignatures")}[Zs](){this[Os]=this[Os].trim().split(/\s+/).filter((e=>["docReady","postSign"].includes(e)))}}class ValidationMessaging extends OptionObject{constructor(e){super(Hn,"validationMessaging",["allMessagesIndividually","allMessagesTogether","firstMessageOnly","noMessages"])}}class Version extends OptionObject{constructor(e){super(Hn,"version",["1.7","1.6","1.5","1.4","1.3","1.2"])}}class VersionControl extends XFAObject{constructor(e){super(Hn,"VersionControl");this.outputBelow=getStringOption(e.outputBelow,["warn","error","update"]);this.sourceAbove=getStringOption(e.sourceAbove,["warn","error"]);this.sourceBelow=getStringOption(e.sourceBelow,["update","maintain"])}}class ViewerPreferences extends XFAObject{constructor(e){super(Hn,"viewerPreferences",!0);this.ADBE_JSConsole=null;this.ADBE_JSDebugger=null;this.addViewerPreferences=null;this.duplexOption=null;this.enforce=null;this.numberOfCopies=null;this.pageRange=null;this.pickTrayByPDFSize=null;this.printScaling=null}}class WebClient extends XFAObject{constructor(e){super(Hn,"webClient",!0);this.name=e.name?e.name.trim():"";this.fontInfo=null;this.xdc=null}}class Whitespace extends OptionObject{constructor(e){super(Hn,"whitespace",["preserve","ltrim","normalize","rtrim","trim"])}}class Window extends ContentObject{constructor(e){super(Hn,"window")}[Zs](){const e=this[Os].trim().split(/\s*,\s*/,2).map((e=>parseInt(e,10)));if(e.some((e=>isNaN(e))))this[Os]=[0,0];else{1===e.length&&e.push(e[0]);this[Os]=e}}}class Xdc extends XFAObject{constructor(e){super(Hn,"xdc",!0);this.uri=new XFAObjectArray;this.xsl=new XFAObjectArray}}class Xdp extends XFAObject{constructor(e){super(Hn,"xdp",!0);this.packets=null}}class Xsl extends XFAObject{constructor(e){super(Hn,"xsl",!0);this.debug=null;this.uri=null}}class Zpl extends XFAObject{constructor(e){super(Hn,"zpl",!0);this.name=e.name?e.name.trim():"";this.batchOutput=null;this.flipLabel=null;this.fontInfo=null;this.xdc=null}}class ConfigNamespace{static[zr](e,t){if(ConfigNamespace.hasOwnProperty(e))return ConfigNamespace[e](t)}static acrobat(e){return new Acrobat(e)}static acrobat7(e){return new Acrobat7(e)}static ADBE_JSConsole(e){return new ADBE_JSConsole(e)}static ADBE_JSDebugger(e){return new ADBE_JSDebugger(e)}static addSilentPrint(e){return new AddSilentPrint(e)}static addViewerPreferences(e){return new AddViewerPreferences(e)}static adjustData(e){return new AdjustData(e)}static adobeExtensionLevel(e){return new AdobeExtensionLevel(e)}static agent(e){return new Agent(e)}static alwaysEmbed(e){return new AlwaysEmbed(e)}static amd(e){return new Amd(e)}static area(e){return new config_Area(e)}static attributes(e){return new Attributes(e)}static autoSave(e){return new AutoSave(e)}static base(e){return new Base(e)}static batchOutput(e){return new BatchOutput(e)}static behaviorOverride(e){return new BehaviorOverride(e)}static cache(e){return new Cache(e)}static change(e){return new Change(e)}static common(e){return new Common(e)}static compress(e){return new Compress(e)}static compressLogicalStructure(e){return new CompressLogicalStructure(e)}static compressObjectStream(e){return new CompressObjectStream(e)}static compression(e){return new Compression(e)}static config(e){return new Config(e)}static conformance(e){return new Conformance(e)}static contentCopy(e){return new ContentCopy(e)}static copies(e){return new Copies(e)}static creator(e){return new Creator(e)}static currentPage(e){return new CurrentPage(e)}static data(e){return new Data(e)}static debug(e){return new Debug(e)}static defaultTypeface(e){return new DefaultTypeface(e)}static destination(e){return new Destination(e)}static documentAssembly(e){return new DocumentAssembly(e)}static driver(e){return new Driver(e)}static duplexOption(e){return new DuplexOption(e)}static dynamicRender(e){return new DynamicRender(e)}static embed(e){return new Embed(e)}static encrypt(e){return new config_Encrypt(e)}static encryption(e){return new config_Encryption(e)}static encryptionLevel(e){return new EncryptionLevel(e)}static enforce(e){return new Enforce(e)}static equate(e){return new Equate(e)}static equateRange(e){return new EquateRange(e)}static exclude(e){return new Exclude(e)}static excludeNS(e){return new ExcludeNS(e)}static flipLabel(e){return new FlipLabel(e)}static fontInfo(e){return new config_FontInfo(e)}static formFieldFilling(e){return new FormFieldFilling(e)}static groupParent(e){return new GroupParent(e)}static ifEmpty(e){return new IfEmpty(e)}static includeXDPContent(e){return new IncludeXDPContent(e)}static incrementalLoad(e){return new IncrementalLoad(e)}static incrementalMerge(e){return new IncrementalMerge(e)}static interactive(e){return new Interactive(e)}static jog(e){return new Jog(e)}static labelPrinter(e){return new LabelPrinter(e)}static layout(e){return new Layout(e)}static level(e){return new Level(e)}static linearized(e){return new Linearized(e)}static locale(e){return new Locale(e)}static localeSet(e){return new LocaleSet(e)}static log(e){return new Log(e)}static map(e){return new MapElement(e)}static mediumInfo(e){return new MediumInfo(e)}static message(e){return new config_Message(e)}static messaging(e){return new Messaging(e)}static mode(e){return new Mode(e)}static modifyAnnots(e){return new ModifyAnnots(e)}static msgId(e){return new MsgId(e)}static nameAttr(e){return new NameAttr(e)}static neverEmbed(e){return new NeverEmbed(e)}static numberOfCopies(e){return new NumberOfCopies(e)}static openAction(e){return new OpenAction(e)}static output(e){return new Output(e)}static outputBin(e){return new OutputBin(e)}static outputXSL(e){return new OutputXSL(e)}static overprint(e){return new Overprint(e)}static packets(e){return new Packets(e)}static pageOffset(e){return new PageOffset(e)}static pageRange(e){return new PageRange(e)}static pagination(e){return new Pagination(e)}static paginationOverride(e){return new PaginationOverride(e)}static part(e){return new Part(e)}static pcl(e){return new Pcl(e)}static pdf(e){return new Pdf(e)}static pdfa(e){return new Pdfa(e)}static permissions(e){return new Permissions(e)}static pickTrayByPDFSize(e){return new PickTrayByPDFSize(e)}static picture(e){return new config_Picture(e)}static plaintextMetadata(e){return new PlaintextMetadata(e)}static presence(e){return new Presence(e)}static present(e){return new Present(e)}static print(e){return new Print(e)}static printHighQuality(e){return new PrintHighQuality(e)}static printScaling(e){return new PrintScaling(e)}static printerName(e){return new PrinterName(e)}static producer(e){return new Producer(e)}static ps(e){return new Ps(e)}static range(e){return new Range(e)}static record(e){return new Record(e)}static relevant(e){return new Relevant(e)}static rename(e){return new Rename(e)}static renderPolicy(e){return new RenderPolicy(e)}static runScripts(e){return new RunScripts(e)}static script(e){return new config_Script(e)}static scriptModel(e){return new ScriptModel(e)}static severity(e){return new Severity(e)}static silentPrint(e){return new SilentPrint(e)}static staple(e){return new Staple(e)}static startNode(e){return new StartNode(e)}static startPage(e){return new StartPage(e)}static submitFormat(e){return new SubmitFormat(e)}static submitUrl(e){return new SubmitUrl(e)}static subsetBelow(e){return new SubsetBelow(e)}static suppressBanner(e){return new SuppressBanner(e)}static tagged(e){return new Tagged(e)}static template(e){return new config_Template(e)}static templateCache(e){return new TemplateCache(e)}static threshold(e){return new Threshold(e)}static to(e){return new To(e)}static trace(e){return new Trace(e)}static transform(e){return new Transform(e)}static type(e){return new Type(e)}static uri(e){return new Uri(e)}static validate(e){return new config_Validate(e)}static validateApprovalSignatures(e){return new ValidateApprovalSignatures(e)}static validationMessaging(e){return new ValidationMessaging(e)}static version(e){return new Version(e)}static versionControl(e){return new VersionControl(e)}static viewerPreferences(e){return new ViewerPreferences(e)}static webClient(e){return new WebClient(e)}static whitespace(e){return new Whitespace(e)}static window(e){return new Window(e)}static xdc(e){return new Xdc(e)}static xdp(e){return new Xdp(e)}static xsl(e){return new Xsl(e)}static zpl(e){return new Zpl(e)}}const Jn=_r.connectionSet.id;class ConnectionSet extends XFAObject{constructor(e){super(Jn,"connectionSet",!0);this.wsdlConnection=new XFAObjectArray;this.xmlConnection=new XFAObjectArray;this.xsdConnection=new XFAObjectArray}}class EffectiveInputPolicy extends XFAObject{constructor(e){super(Jn,"effectiveInputPolicy");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class EffectiveOutputPolicy extends XFAObject{constructor(e){super(Jn,"effectiveOutputPolicy");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class Operation extends StringObject{constructor(e){super(Jn,"operation");this.id=e.id||"";this.input=e.input||"";this.name=e.name||"";this.output=e.output||"";this.use=e.use||"";this.usehref=e.usehref||""}}class RootElement extends StringObject{constructor(e){super(Jn,"rootElement");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class SoapAction extends StringObject{constructor(e){super(Jn,"soapAction");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class SoapAddress extends StringObject{constructor(e){super(Jn,"soapAddress");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class connection_set_Uri extends StringObject{constructor(e){super(Jn,"uri");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class WsdlAddress extends StringObject{constructor(e){super(Jn,"wsdlAddress");this.id=e.id||"";this.name=e.name||"";this.use=e.use||"";this.usehref=e.usehref||""}}class WsdlConnection extends XFAObject{constructor(e){super(Jn,"wsdlConnection",!0);this.dataDescription=e.dataDescription||"";this.name=e.name||"";this.effectiveInputPolicy=null;this.effectiveOutputPolicy=null;this.operation=null;this.soapAction=null;this.soapAddress=null;this.wsdlAddress=null}}class XmlConnection extends XFAObject{constructor(e){super(Jn,"xmlConnection",!0);this.dataDescription=e.dataDescription||"";this.name=e.name||"";this.uri=null}}class XsdConnection extends XFAObject{constructor(e){super(Jn,"xsdConnection",!0);this.dataDescription=e.dataDescription||"";this.name=e.name||"";this.rootElement=null;this.uri=null}}class ConnectionSetNamespace{static[zr](e,t){if(ConnectionSetNamespace.hasOwnProperty(e))return ConnectionSetNamespace[e](t)}static connectionSet(e){return new ConnectionSet(e)}static effectiveInputPolicy(e){return new EffectiveInputPolicy(e)}static effectiveOutputPolicy(e){return new EffectiveOutputPolicy(e)}static operation(e){return new Operation(e)}static rootElement(e){return new RootElement(e)}static soapAction(e){return new SoapAction(e)}static soapAddress(e){return new SoapAddress(e)}static uri(e){return new connection_set_Uri(e)}static wsdlAddress(e){return new WsdlAddress(e)}static wsdlConnection(e){return new WsdlConnection(e)}static xmlConnection(e){return new XmlConnection(e)}static xsdConnection(e){return new XsdConnection(e)}}const Yn=_r.datasets.id;class datasets_Data extends XmlObject{constructor(e){super(Yn,"data",e)}[mr](){return!0}}class Datasets extends XFAObject{constructor(e){super(Yn,"datasets",!0);this.data=null;this.Signature=null}[Nr](e){const t=e[kr];("data"===t&&e[Sr]===Yn||"Signature"===t&&e[Sr]===_r.signature.id)&&(this[t]=e);this[Hs](e)}}class DatasetsNamespace{static[zr](e,t){if(DatasetsNamespace.hasOwnProperty(e))return DatasetsNamespace[e](t)}static datasets(e){return new Datasets(e)}static data(e){return new datasets_Data(e)}}const vn=_r.localeSet.id;class CalendarSymbols extends XFAObject{constructor(e){super(vn,"calendarSymbols",!0);this.name="gregorian";this.dayNames=new XFAObjectArray(2);this.eraNames=null;this.meridiemNames=null;this.monthNames=new XFAObjectArray(2)}}class CurrencySymbol extends StringObject{constructor(e){super(vn,"currencySymbol");this.name=getStringOption(e.name,["symbol","isoname","decimal"])}}class CurrencySymbols extends XFAObject{constructor(e){super(vn,"currencySymbols",!0);this.currencySymbol=new XFAObjectArray(3)}}class DatePattern extends StringObject{constructor(e){super(vn,"datePattern");this.name=getStringOption(e.name,["full","long","med","short"])}}class DatePatterns extends XFAObject{constructor(e){super(vn,"datePatterns",!0);this.datePattern=new XFAObjectArray(4)}}class DateTimeSymbols extends ContentObject{constructor(e){super(vn,"dateTimeSymbols")}}class Day extends StringObject{constructor(e){super(vn,"day")}}class DayNames extends XFAObject{constructor(e){super(vn,"dayNames",!0);this.abbr=getInteger({data:e.abbr,defaultValue:0,validate:e=>1===e});this.day=new XFAObjectArray(7)}}class Era extends StringObject{constructor(e){super(vn,"era")}}class EraNames extends XFAObject{constructor(e){super(vn,"eraNames",!0);this.era=new XFAObjectArray(2)}}class locale_set_Locale extends XFAObject{constructor(e){super(vn,"locale",!0);this.desc=e.desc||"";this.name="isoname";this.calendarSymbols=null;this.currencySymbols=null;this.datePatterns=null;this.dateTimeSymbols=null;this.numberPatterns=null;this.numberSymbols=null;this.timePatterns=null;this.typeFaces=null}}class locale_set_LocaleSet extends XFAObject{constructor(e){super(vn,"localeSet",!0);this.locale=new XFAObjectArray}}class Meridiem extends StringObject{constructor(e){super(vn,"meridiem")}}class MeridiemNames extends XFAObject{constructor(e){super(vn,"meridiemNames",!0);this.meridiem=new XFAObjectArray(2)}}class Month extends StringObject{constructor(e){super(vn,"month")}}class MonthNames extends XFAObject{constructor(e){super(vn,"monthNames",!0);this.abbr=getInteger({data:e.abbr,defaultValue:0,validate:e=>1===e});this.month=new XFAObjectArray(12)}}class NumberPattern extends StringObject{constructor(e){super(vn,"numberPattern");this.name=getStringOption(e.name,["full","long","med","short"])}}class NumberPatterns extends XFAObject{constructor(e){super(vn,"numberPatterns",!0);this.numberPattern=new XFAObjectArray(4)}}class NumberSymbol extends StringObject{constructor(e){super(vn,"numberSymbol");this.name=getStringOption(e.name,["decimal","grouping","percent","minus","zero"])}}class NumberSymbols extends XFAObject{constructor(e){super(vn,"numberSymbols",!0);this.numberSymbol=new XFAObjectArray(5)}}class TimePattern extends StringObject{constructor(e){super(vn,"timePattern");this.name=getStringOption(e.name,["full","long","med","short"])}}class TimePatterns extends XFAObject{constructor(e){super(vn,"timePatterns",!0);this.timePattern=new XFAObjectArray(4)}}class TypeFace extends XFAObject{constructor(e){super(vn,"typeFace",!0);this.name=""|e.name}}class TypeFaces extends XFAObject{constructor(e){super(vn,"typeFaces",!0);this.typeFace=new XFAObjectArray}}class LocaleSetNamespace{static[zr](e,t){if(LocaleSetNamespace.hasOwnProperty(e))return LocaleSetNamespace[e](t)}static calendarSymbols(e){return new CalendarSymbols(e)}static currencySymbol(e){return new CurrencySymbol(e)}static currencySymbols(e){return new CurrencySymbols(e)}static datePattern(e){return new DatePattern(e)}static datePatterns(e){return new DatePatterns(e)}static dateTimeSymbols(e){return new DateTimeSymbols(e)}static day(e){return new Day(e)}static dayNames(e){return new DayNames(e)}static era(e){return new Era(e)}static eraNames(e){return new EraNames(e)}static locale(e){return new locale_set_Locale(e)}static localeSet(e){return new locale_set_LocaleSet(e)}static meridiem(e){return new Meridiem(e)}static meridiemNames(e){return new MeridiemNames(e)}static month(e){return new Month(e)}static monthNames(e){return new MonthNames(e)}static numberPattern(e){return new NumberPattern(e)}static numberPatterns(e){return new NumberPatterns(e)}static numberSymbol(e){return new NumberSymbol(e)}static numberSymbols(e){return new NumberSymbols(e)}static timePattern(e){return new TimePattern(e)}static timePatterns(e){return new TimePatterns(e)}static typeFace(e){return new TypeFace(e)}static typeFaces(e){return new TypeFaces(e)}}const Kn=_r.signature.id;class signature_Signature extends XFAObject{constructor(e){super(Kn,"signature",!0)}}class SignatureNamespace{static[zr](e,t){if(SignatureNamespace.hasOwnProperty(e))return SignatureNamespace[e](t)}static signature(e){return new signature_Signature(e)}}const Tn=_r.stylesheet.id;class Stylesheet extends XFAObject{constructor(e){super(Tn,"stylesheet",!0)}}class StylesheetNamespace{static[zr](e,t){if(StylesheetNamespace.hasOwnProperty(e))return StylesheetNamespace[e](t)}static stylesheet(e){return new Stylesheet(e)}}const qn=_r.xdp.id;class xdp_Xdp extends XFAObject{constructor(e){super(qn,"xdp",!0);this.uuid=e.uuid||"";this.timeStamp=e.timeStamp||"";this.config=null;this.connectionSet=null;this.datasets=null;this.localeSet=null;this.stylesheet=new XFAObjectArray;this.template=null}[Gr](e){const t=_r[e[kr]];return t&&e[Sr]===t.id}}class XdpNamespace{static[zr](e,t){if(XdpNamespace.hasOwnProperty(e))return XdpNamespace[e](t)}static xdp(e){return new xdp_Xdp(e)}}const On=_r.xhtml.id,Pn=Symbol(),Wn=new Set(["color","font","font-family","font-size","font-stretch","font-style","font-weight","margin","margin-bottom","margin-left","margin-right","margin-top","letter-spacing","line-height","orphans","page-break-after","page-break-before","page-break-inside","tab-interval","tab-stop","text-align","text-decoration","text-indent","vertical-align","widows","kerning-mode","xfa-font-horizontal-scale","xfa-font-vertical-scale","xfa-spacerun","xfa-tab-stops"]),jn=new Map([["page-break-after","breakAfter"],["page-break-before","breakBefore"],["page-break-inside","breakInside"],["kerning-mode",e=>"none"===e?"none":"normal"],["xfa-font-horizontal-scale",e=>`scaleX(${Math.max(0,Math.min(parseInt(e)/100)).toFixed(2)})`],["xfa-font-vertical-scale",e=>`scaleY(${Math.max(0,Math.min(parseInt(e)/100)).toFixed(2)})`],["xfa-spacerun",""],["xfa-tab-stops",""],["font-size",(e,t)=>measureToString(.99*(e=t.fontSize=Math.abs(getMeasurement(e))))],["letter-spacing",e=>measureToString(getMeasurement(e))],["line-height",e=>measureToString(getMeasurement(e))],["margin",e=>measureToString(getMeasurement(e))],["margin-bottom",e=>measureToString(getMeasurement(e))],["margin-left",e=>measureToString(getMeasurement(e))],["margin-right",e=>measureToString(getMeasurement(e))],["margin-top",e=>measureToString(getMeasurement(e))],["text-indent",e=>measureToString(getMeasurement(e))],["font-family",e=>e],["vertical-align",e=>measureToString(getMeasurement(e))]]),Xn=/\s+/g,Zn=/[\r\n]+/g,Vn=/\r\n?/g;function mapStyle(e,t,i){const a=Object.create(null);if(!e)return a;const s=Object.create(null);for(const[t,i]of e.split(";").map((e=>e.split(":",2)))){const e=jn.get(t);if(""===e)continue;let r=i;e&&(r="string"==typeof e?e:e(i,s));t.endsWith("scale")?a.transform=a.transform?`${a[t]} ${r}`:r:a[t.replaceAll(/-([a-zA-Z])/g,((e,t)=>t.toUpperCase()))]=r}a.fontFamily&&setFontFamily({typeface:a.fontFamily,weight:a.fontWeight||"normal",posture:a.fontStyle||"normal",size:s.fontSize||0},t,t[Cr].fontFinder,a);if(i&&a.verticalAlign&&"0px"!==a.verticalAlign&&a.fontSize){const e=.583,t=.333,i=getMeasurement(a.fontSize);a.fontSize=measureToString(i*e);a.verticalAlign=measureToString(Math.sign(getMeasurement(a.verticalAlign))*i*t)}i&&a.fontSize&&(a.fontSize=`calc(${a.fontSize} * var(--scale-factor))`);fixTextIndent(a);return a}const zn=new Set(["body","html"]);class XhtmlObject extends XmlObject{constructor(e,t){super(On,t);this[Pn]=!1;this.style=e.style||""}[Ys](e){super[Ys](e);this.style=function checkStyle(e){return e.style?e.style.trim().split(/\s*;\s*/).filter((e=>!!e)).map((e=>e.split(/\s*:\s*/,2))).filter((([t,i])=>{"font-family"===t&&e[Cr].usedTypefaces.add(i);return Wn.has(t)})).map((e=>e.join(":"))).join(";"):""}(this)}[xs](){return!zn.has(this[kr])}[Mr](e,t=!1){if(t)this[Pn]=!0;else{e=e.replaceAll(Zn,"");this.style.includes("xfa-spacerun:yes")||(e=e.replaceAll(Xn," "))}e&&(this[Os]+=e)}[Ur](e,t=!0){const i=Object.create(null),a={top:NaN,bottom:NaN,left:NaN,right:NaN};let s=null;for(const[e,t]of this.style.split(";").map((e=>e.split(":",2))))switch(e){case"font-family":i.typeface=stripQuotes(t);break;case"font-size":i.size=getMeasurement(t);break;case"font-weight":i.weight=t;break;case"font-style":i.posture=t;break;case"letter-spacing":i.letterSpacing=getMeasurement(t);break;case"margin":const e=t.split(/ \t/).map((e=>getMeasurement(e)));switch(e.length){case 1:a.top=a.bottom=a.left=a.right=e[0];break;case 2:a.top=a.bottom=e[0];a.left=a.right=e[1];break;case 3:a.top=e[0];a.bottom=e[2];a.left=a.right=e[1];break;case 4:a.top=e[0];a.left=e[1];a.bottom=e[2];a.right=e[3]}break;case"margin-top":a.top=getMeasurement(t);break;case"margin-bottom":a.bottom=getMeasurement(t);break;case"margin-left":a.left=getMeasurement(t);break;case"margin-right":a.right=getMeasurement(t);break;case"line-height":s=getMeasurement(t)}e.pushData(i,a,s);if(this[Os])e.addString(this[Os]);else for(const t of this[rr]())"#text"!==t[kr]?t[Ur](e):e.addString(t[Os]);t&&e.popFont()}[jr](e){const t=[];this[Xs]={children:t};this[Js]({});if(0===t.length&&!this[Os])return HTMLResult.EMPTY;let i;i=this[Pn]?this[Os]?this[Os].replaceAll(Vn,"\n"):void 0:this[Os]||void 0;return HTMLResult.success({name:this[kr],attributes:{href:this.href,style:mapStyle(this.style,this,this[Pn])},children:t,value:i})}}class A extends XhtmlObject{constructor(e){super(e,"a");this.href=fixURL(e.href)||""}}class B extends XhtmlObject{constructor(e){super(e,"b")}[Ur](e){e.pushFont({weight:"bold"});super[Ur](e);e.popFont()}}class Body extends XhtmlObject{constructor(e){super(e,"body")}[jr](e){const t=super[jr](e),{html:i}=t;if(!i)return HTMLResult.EMPTY;i.name="div";i.attributes.class=["xfaRich"];return t}}class Br extends XhtmlObject{constructor(e){super(e,"br")}[Pr](){return"\n"}[Ur](e){e.addString("\n")}[jr](e){return HTMLResult.success({name:"br"})}}class Html extends XhtmlObject{constructor(e){super(e,"html")}[jr](e){const t=[];this[Xs]={children:t};this[Js]({});if(0===t.length)return HTMLResult.success({name:"div",attributes:{class:["xfaRich"],style:{}},value:this[Os]||""});if(1===t.length){const e=t[0];if(e.attributes?.class.includes("xfaRich"))return HTMLResult.success(e)}return HTMLResult.success({name:"div",attributes:{class:["xfaRich"],style:{}},children:t})}}class I extends XhtmlObject{constructor(e){super(e,"i")}[Ur](e){e.pushFont({posture:"italic"});super[Ur](e);e.popFont()}}class Li extends XhtmlObject{constructor(e){super(e,"li")}}class Ol extends XhtmlObject{constructor(e){super(e,"ol")}}class P extends XhtmlObject{constructor(e){super(e,"p")}[Ur](e){super[Ur](e,!1);e.addString("\n");e.addPara();e.popFont()}[Pr](){return this[Ir]()[rr]().at(-1)===this?super[Pr]():super[Pr]()+"\n"}}class Span extends XhtmlObject{constructor(e){super(e,"span")}}class Sub extends XhtmlObject{constructor(e){super(e,"sub")}}class Sup extends XhtmlObject{constructor(e){super(e,"sup")}}class Ul extends XhtmlObject{constructor(e){super(e,"ul")}}class XhtmlNamespace{static[zr](e,t){if(XhtmlNamespace.hasOwnProperty(e))return XhtmlNamespace[e](t)}static a(e){return new A(e)}static b(e){return new B(e)}static body(e){return new Body(e)}static br(e){return new Br(e)}static html(e){return new Html(e)}static i(e){return new I(e)}static li(e){return new Li(e)}static ol(e){return new Ol(e)}static p(e){return new P(e)}static span(e){return new Span(e)}static sub(e){return new Sub(e)}static sup(e){return new Sup(e)}static ul(e){return new Ul(e)}}const _n={config:ConfigNamespace,connection:ConnectionSetNamespace,datasets:DatasetsNamespace,localeSet:LocaleSetNamespace,signature:SignatureNamespace,stylesheet:StylesheetNamespace,template:TemplateNamespace,xdp:XdpNamespace,xhtml:XhtmlNamespace};class UnknownNamespace{constructor(e){this.namespaceId=e}[zr](e,t){return new XmlObject(this.namespaceId,e,t)}}class Root extends XFAObject{constructor(e){super(-1,"root",Object.create(null));this.element=null;this[lr]=e}[Nr](e){this.element=e;return!0}[Zs](){super[Zs]();if(this.element.template instanceof Template){this[lr].set(Jr,this.element);this.element.template[Yr](this[lr]);this.element.template[lr]=this[lr]}}}class Empty extends XFAObject{constructor(){super(-1,"",Object.create(null))}[Nr](e){return!1}}class Builder{constructor(e=null){this._namespaceStack=[];this._nsAgnosticLevel=0;this._namespacePrefixes=new Map;this._namespaces=new Map;this._nextNsId=Math.max(...Object.values(_r).map((({id:e})=>e)));this._currentNamespace=e||new UnknownNamespace(++this._nextNsId)}buildRoot(e){return new Root(e)}build({nsPrefix:e,name:t,attributes:i,namespace:a,prefixes:s}){const r=null!==a;if(r){this._namespaceStack.push(this._currentNamespace);this._currentNamespace=this._searchNamespace(a)}s&&this._addNamespacePrefix(s);if(i.hasOwnProperty(Rr)){const e=_n.datasets,t=i[Rr];let a=null;for(const[i,s]of Object.entries(t)){if(this._getNamespaceToUse(i)===e){a={xfa:s};break}}a?i[Rr]=a:delete i[Rr]}const n=this._getNamespaceToUse(e),g=n?.[zr](t,i)||new Empty;g[mr]()&&this._nsAgnosticLevel++;(r||s||g[mr]())&&(g[Ks]={hasNamespace:r,prefixes:s,nsAgnostic:g[mr]()});return g}isNsAgnostic(){return this._nsAgnosticLevel>0}_searchNamespace(e){let t=this._namespaces.get(e);if(t)return t;for(const[i,{check:a}]of Object.entries(_r))if(a(e)){t=_n[i];if(t){this._namespaces.set(e,t);return t}break}t=new UnknownNamespace(++this._nextNsId);this._namespaces.set(e,t);return t}_addNamespacePrefix(e){for(const{prefix:t,value:i}of e){const e=this._searchNamespace(i);let a=this._namespacePrefixes.get(t);if(!a){a=[];this._namespacePrefixes.set(t,a)}a.push(e)}}_getNamespaceToUse(e){if(!e)return this._currentNamespace;const t=this._namespacePrefixes.get(e);if(t?.length>0)return t.at(-1);warn(`Unknown namespace prefix: ${e}.`);return null}clean(e){const{hasNamespace:t,prefixes:i,nsAgnostic:a}=e;t&&(this._currentNamespace=this._namespaceStack.pop());i&&i.forEach((({prefix:e})=>{this._namespacePrefixes.get(e).pop()}));a&&this._nsAgnosticLevel--}}class XFAParser extends XMLParserBase{constructor(e=null,t=!1){super();this._builder=new Builder(e);this._stack=[];this._globalData={usedTypefaces:new Set};this._ids=new Map;this._current=this._builder.buildRoot(this._ids);this._errorCode=ys;this._whiteRegex=/^\s+$/;this._nbsps=/\xa0+/g;this._richText=t}parse(e){this.parseXml(e);if(this._errorCode===ys){this._current[Zs]();return this._current.element}}onText(e){e=e.replace(this._nbsps,(e=>e.slice(1)+" "));this._richText||this._current[xs]()?this._current[Mr](e,this._richText):this._whiteRegex.test(e)||this._current[Mr](e.trim())}onCdata(e){this._current[Mr](e)}_mkAttributes(e,t){let i=null,a=null;const s=Object.create({});for(const{name:r,value:n}of e)if("xmlns"===r)i?warn(`XFA - multiple namespace definition in <${t}>`):i=n;else if(r.startsWith("xmlns:")){const e=r.substring(6);a||(a=[]);a.push({prefix:e,value:n})}else{const e=r.indexOf(":");if(-1===e)s[r]=n;else{let t=s[Rr];t||(t=s[Rr]=Object.create(null));const[i,a]=[r.slice(0,e),r.slice(e+1)];(t[i]||=Object.create(null))[a]=n}}return[i,a,s]}_getNameAndPrefix(e,t){const i=e.indexOf(":");return-1===i?[e,null]:[e.substring(i+1),t?"":e.substring(0,i)]}onBeginElement(e,t,i){const[a,s,r]=this._mkAttributes(t,e),[n,g]=this._getNameAndPrefix(e,this._builder.isNsAgnostic()),o=this._builder.build({nsPrefix:g,name:n,attributes:r,namespace:a,prefixes:s});o[Cr]=this._globalData;if(i){o[Zs]();this._current[Nr](o)&&o[Kr](this._ids);o[Ys](this._builder)}else{this._stack.push(this._current);this._current=o}}onEndElement(e){const t=this._current;if(t[ur]()&&"string"==typeof t[Os]){const e=new XFAParser;e._globalData=this._globalData;const i=e.parse(t[Os]);t[Os]=null;t[Nr](i)}t[Zs]();this._current=this._stack.pop();this._current[Nr](t)&&t[Kr](this._ids);t[Ys](this._builder)}onError(e){this._errorCode=e}}class XFAFactory{constructor(e){try{this.root=(new XFAParser).parse(XFAFactory._createDocument(e));const t=new Binder(this.root);this.form=t.bind();this.dataHandler=new DataHandler(this.root,t.getData());this.form[Cr].template=this.form}catch(e){warn(`XFA - an error occurred during parsing and binding: ${e}`)}}isValid(){return this.root&&this.form}_createPagesHelper(){const e=this.form[Wr]();return new Promise(((t,i)=>{const nextIteration=()=>{try{const i=e.next();i.done?t(i.value):setTimeout(nextIteration,0)}catch(e){i(e)}};setTimeout(nextIteration,0)}))}async _createPages(){try{this.pages=await this._createPagesHelper();this.dims=this.pages.children.map((e=>{const{width:t,height:i}=e.attributes.style;return[0,0,parseInt(t),parseInt(i)]}))}catch(e){warn(`XFA - an error occurred during layout: ${e}`)}}getBoundingBox(e){return this.dims[e]}async getNumPages(){this.pages||await this._createPages();return this.dims.length}setImages(e){this.form[Cr].images=e}setFonts(e){this.form[Cr].fontFinder=new FontFinder(e);const t=[];for(let e of this.form[Cr].usedTypefaces){e=stripQuotes(e);this.form[Cr].fontFinder.find(e)||t.push(e)}return t.length>0?t:null}appendFonts(e,t){this.form[Cr].fontFinder.add(e,t)}async getPages(){this.pages||await this._createPages();const e=this.pages;this.pages=null;return e}serializeData(e){return this.dataHandler.serialize(e)}static _createDocument(e){return e["/xdp:xdp"]?Object.values(e).join(""):e["xdp:xdp"]}static getRichTextAsHtml(e){if(!e||"string"!=typeof e)return null;try{let t=new XFAParser(XhtmlNamespace,!0).parse(e);if(!["body","xhtml"].includes(t[kr])){const e=XhtmlNamespace.body({});e[Hs](t);t=e}const i=t[jr]();if(!i.success)return null;const{html:a}=i,{attributes:s}=a;if(s){s.class&&(s.class=s.class.filter((e=>!e.startsWith("xfa"))));s.dir="auto"}return{html:a,str:t[Pr]()}}catch(e){warn(`XFA - an error occurred during parsing of rich text: ${e}`)}return null}}class AnnotationFactory{static createGlobals(e){return Promise.all([e.ensureCatalog("acroForm"),e.ensureDoc("xfaDatasets"),e.ensureCatalog("structTreeRoot"),e.ensureCatalog("baseUrl"),e.ensureCatalog("attachments")]).then((([t,i,a,s,r])=>({pdfManager:e,acroForm:t instanceof Dict?t:Dict.empty,xfaDatasets:i,structTreeRoot:a,baseUrl:s,attachments:r})),(e=>{warn(`createGlobals: "${e}".`);return null}))}static async create(e,t,i,a,s,r,n){const g=s?await this._getPageIndex(e,t,i.pdfManager):null;return i.pdfManager.ensure(this,"_create",[e,t,i,a,s,r,g,n])}static _create(e,t,i,a,s=!1,r=null,n=null,g=null){const o=e.fetchIfRef(t);if(!(o instanceof Dict))return;const{acroForm:c,pdfManager:C}=i,h=t instanceof Ref?t.toString():`annot_${a.createObjId()}`;let l=o.get("Subtype");l=l instanceof Name?l.name:null;const Q={xref:e,ref:t,dict:o,subtype:l,id:h,annotationGlobals:i,collectFields:s,orphanFields:r,needAppearances:!s&&!0===c.get("NeedAppearances"),pageIndex:n,evaluatorOptions:C.evaluatorOptions,pageRef:g};switch(l){case"Link":return new LinkAnnotation(Q);case"Text":return new TextAnnotation(Q);case"Widget":let e=getInheritableProperty({dict:o,key:"FT"});e=e instanceof Name?e.name:null;switch(e){case"Tx":return new TextWidgetAnnotation(Q);case"Btn":return new ButtonWidgetAnnotation(Q);case"Ch":return new ChoiceWidgetAnnotation(Q);case"Sig":return new SignatureWidgetAnnotation(Q)}warn(`Unimplemented widget field type "${e}", falling back to base field type.`);return new WidgetAnnotation(Q);case"Popup":return new PopupAnnotation(Q);case"FreeText":return new FreeTextAnnotation(Q);case"Line":return new LineAnnotation(Q);case"Square":return new SquareAnnotation(Q);case"Circle":return new CircleAnnotation(Q);case"PolyLine":return new PolylineAnnotation(Q);case"Polygon":return new PolygonAnnotation(Q);case"Caret":return new CaretAnnotation(Q);case"Ink":return new InkAnnotation(Q);case"Highlight":return new HighlightAnnotation(Q);case"Underline":return new UnderlineAnnotation(Q);case"Squiggly":return new SquigglyAnnotation(Q);case"StrikeOut":return new StrikeOutAnnotation(Q);case"Stamp":return new StampAnnotation(Q);case"FileAttachment":return new FileAttachmentAnnotation(Q);default:s||warn(l?`Unimplemented annotation type "${l}", falling back to base annotation.`:"Annotation is missing the required /Subtype.");return new Annotation(Q)}}static async _getPageIndex(e,t,i){try{const a=await e.fetchIfRefAsync(t);if(!(a instanceof Dict))return-1;const s=a.getRaw("P");if(s instanceof Ref)try{return await i.ensureCatalog("getPageIndex",[s])}catch(e){info(`_getPageIndex -- not a valid page reference: "${e}".`)}if(a.has("Kids"))return-1;const r=await i.ensureDoc("numPages");for(let e=0;ee/255))}function getQuadPoints(e,t){const i=e.getArray("QuadPoints");if(!isNumberArray(i,null)||0===i.length||i.length%8>0)return null;const a=new Float32Array(i.length);for(let e=0,s=i.length;et[2]||Et[3]))return null;a.set([l,u,Q,u,l,E,Q,E],e)}return a}function getTransformMatrix(e,t,i){const[a,s,r,n]=Util.getAxialAlignedBoundingBox(t,i);if(a===r||s===n)return[1,0,0,1,e[0],e[1]];const g=(e[2]-e[0])/(r-a),o=(e[3]-e[1])/(n-s);return[g,0,0,o,e[0]-a*g,e[1]-s*o]}class Annotation{constructor(e){const{dict:t,xref:i,annotationGlobals:a,ref:s,orphanFields:r}=e,n=r?.get(s);n&&t.set("Parent",n);this.setTitle(t.get("T"));this.setContents(t.get("Contents"));this.setModificationDate(t.get("M"));this.setFlags(t.get("F"));this.setRectangle(t.getArray("Rect"));this.setColor(t.getArray("C"));this.setBorderStyle(t);this.setAppearance(t);this.setOptionalContent(t);const g=t.get("MK");this.setBorderAndBackgroundColors(g);this.setRotation(g,t);this.ref=e.ref instanceof Ref?e.ref:null;this._streams=[];this.appearance&&this._streams.push(this.appearance);const o=!!(this.flags&eA),c=!!(this.flags&tA);this.data={annotationFlags:this.flags,borderStyle:this.borderStyle,color:this.color,backgroundColor:this.backgroundColor,borderColor:this.borderColor,rotation:this.rotation,contentsObj:this._contents,hasAppearance:!!this.appearance,id:e.id,modificationDate:this.modificationDate,rect:this.rectangle,subtype:e.subtype,hasOwnCanvas:!1,noRotate:!!(this.flags&$),noHTML:o&&c,isEditable:!1,structParent:-1};if(a.structTreeRoot){let i=t.get("StructParent");this.data.structParent=i=Number.isInteger(i)&&i>=0?i:-1;a.structTreeRoot.addAnnotationIdToPage(e.pageRef,i)}if(e.collectFields){const a=t.get("Kids");if(Array.isArray(a)){const e=[];for(const t of a)t instanceof Ref&&e.push(t.toString());0!==e.length&&(this.data.kidIds=e)}this.data.actions=collectActions(i,t,dA);this.data.fieldName=this._constructFieldName(t);this.data.pageIndex=e.pageIndex}const C=t.get("IT");C instanceof Name&&(this.data.it=C.name);this._isOffscreenCanvasSupported=e.evaluatorOptions.isOffscreenCanvasSupported;this._fallbackFontDict=null;this._needAppearances=!1}_hasFlag(e,t){return!!(e&t)}_buildFlags(e,t){let{flags:i}=this;if(void 0===e){if(void 0===t)return;return t?i&~_:i&~z|_}if(e){i|=_;return t?i&~AA|z:i&~z|AA}i&=~(z|AA);return t?i&~_:i|_}_isViewable(e){return!this._hasFlag(e,V)&&!this._hasFlag(e,AA)}_isPrintable(e){return this._hasFlag(e,_)&&!this._hasFlag(e,z)&&!this._hasFlag(e,V)}mustBeViewed(e,t){const i=e?.get(this.data.id)?.noView;return void 0!==i?!i:this.viewable&&!this._hasFlag(this.flags,z)}mustBePrinted(e){const t=e?.get(this.data.id)?.noPrint;return void 0!==t?!t:this.printable}mustBeViewedWhenEditing(e,t=null){return e?!this.data.isEditable:!t?.has(this.data.id)}get viewable(){return null!==this.data.quadPoints&&(0===this.flags||this._isViewable(this.flags))}get printable(){return null!==this.data.quadPoints&&(0!==this.flags&&this._isPrintable(this.flags))}_parseStringHelper(e){const t="string"==typeof e?stringToPDFString(e):"";return{str:t,dir:t&&"rtl"===bidi(t).dir?"rtl":"ltr"}}setDefaultAppearance(e){const{dict:t,annotationGlobals:i}=e,a=getInheritableProperty({dict:t,key:"DA"})||i.acroForm.get("DA");this._defaultAppearance="string"==typeof a?a:"";this.data.defaultAppearanceData=parseDefaultAppearance(this._defaultAppearance)}setTitle(e){this._title=this._parseStringHelper(e)}setContents(e){this._contents=this._parseStringHelper(e)}setModificationDate(e){this.modificationDate="string"==typeof e?e:null}setFlags(e){this.flags=Number.isInteger(e)&&e>0?e:0;this.flags&V&&"Annotation"!==this.constructor.name&&(this.flags^=V)}hasFlag(e){return this._hasFlag(this.flags,e)}setRectangle(e){this.rectangle=lookupNormalRect(e,[0,0,0,0])}setColor(e){this.color=getRgbColor(e)}setLineEndings(e){this.lineEndings=["None","None"];if(Array.isArray(e)&&2===e.length)for(let t=0;t<2;t++){const i=e[t];if(i instanceof Name)switch(i.name){case"None":continue;case"Square":case"Circle":case"Diamond":case"OpenArrow":case"ClosedArrow":case"Butt":case"ROpenArrow":case"RClosedArrow":case"Slash":this.lineEndings[t]=i.name;continue}warn(`Ignoring invalid lineEnding: ${i}`)}}setRotation(e,t){this.rotation=0;let i=e instanceof Dict?e.get("R")||0:t.get("Rotate")||0;if(Number.isInteger(i)&&0!==i){i%=360;i<0&&(i+=360);i%90==0&&(this.rotation=i)}}setBorderAndBackgroundColors(e){if(e instanceof Dict){this.borderColor=getRgbColor(e.getArray("BC"),null);this.backgroundColor=getRgbColor(e.getArray("BG"),null)}else this.borderColor=this.backgroundColor=null}setBorderStyle(e){this.borderStyle=new AnnotationBorderStyle;if(e instanceof Dict)if(e.has("BS")){const t=e.get("BS");if(t instanceof Dict){const e=t.get("Type");if(!e||isName(e,"Border")){this.borderStyle.setWidth(t.get("W"),this.rectangle);this.borderStyle.setStyle(t.get("S"));this.borderStyle.setDashArray(t.getArray("D"))}}}else if(e.has("Border")){const t=e.getArray("Border");if(Array.isArray(t)&&t.length>=3){this.borderStyle.setHorizontalCornerRadius(t[0]);this.borderStyle.setVerticalCornerRadius(t[1]);this.borderStyle.setWidth(t[2],this.rectangle);4===t.length&&this.borderStyle.setDashArray(t[3],!0)}}else this.borderStyle.setWidth(0)}setAppearance(e){this.appearance=null;const t=e.get("AP");if(!(t instanceof Dict))return;const i=t.get("N");if(i instanceof BaseStream){this.appearance=i;return}if(!(i instanceof Dict))return;const a=e.get("AS");if(!(a instanceof Name&&i.has(a.name)))return;const s=i.get(a.name);s instanceof BaseStream&&(this.appearance=s)}setOptionalContent(e){this.oc=null;const t=e.get("OC");t instanceof Name?warn("setOptionalContent: Support for /Name-entry is not implemented."):t instanceof Dict&&(this.oc=t)}loadResources(e,t){return t.dict.getAsync("Resources").then((t=>{if(!t)return;return new ObjectLoader(t,e,t.xref).load().then((function(){return t}))}))}async getOperatorList(e,t,a,s){const{hasOwnCanvas:r,id:n,rect:g}=this.data;let c=this.appearance;const C=!!(r&&a&o);if(C&&(g[0]===g[2]||g[1]===g[3])){this.data.hasOwnCanvas=!1;return{opList:new OperatorList,separateForm:!1,separateCanvas:!1}}if(!c){if(!C)return{opList:new OperatorList,separateForm:!1,separateCanvas:!1};c=new StringStream("");c.dict=new Dict}const h=c.dict,l=await this.loadResources(["ExtGState","ColorSpace","Pattern","Shading","XObject","Font"],c),Q=lookupRect(h.getArray("BBox"),[0,0,1,1]),E=lookupMatrix(h.getArray("Matrix"),i),u=getTransformMatrix(g,Q,E),d=new OperatorList;let f;this.oc&&(f=await e.parseMarkedContentProps(this.oc,null));void 0!==f&&d.addOp(Ye,["OC",f]);d.addOp(je,[n,g,u,E,C]);await e.getOperatorList({stream:c,task:t,resources:l,operatorList:d,fallbackFontDict:this._fallbackFontDict});d.addOp(Xe,[]);void 0!==f&&d.addOp(ve,[]);this.reset();return{opList:d,separateForm:!1,separateCanvas:C}}async save(e,t,i,a){return null}get hasTextContent(){return!1}async extractTextContent(e,t,i){if(!this.appearance)return;const a=await this.loadResources(["ExtGState","Font","Properties","XObject"],this.appearance),s=[],r=[];let n=null;const g={desiredSize:Math.Infinity,ready:!0,enqueue(e,t){for(const t of e.items)if(void 0!==t.str){n||=t.transform.slice(-2);r.push(t.str);if(t.hasEOL){s.push(r.join("").trimEnd());r.length=0}}}};await e.getTextContent({stream:this.appearance,task:t,resources:a,includeMarkedContent:!0,keepWhiteSpace:!0,sink:g,viewBox:i});this.reset();r.length&&s.push(r.join("").trimEnd());if(s.length>1||s[0]){const e=this.appearance.dict,t=lookupRect(e.getArray("BBox"),null),i=lookupMatrix(e.getArray("Matrix"),null);this.data.textPosition=this._transformPoint(n,t,i);this.data.textContent=s}}_transformPoint(e,t,i){const{rect:a}=this.data;t||=[0,0,1,1];i||=[1,0,0,1,0,0];const s=getTransformMatrix(a,t,i);s[4]-=a[0];s[5]-=a[1];e=Util.applyTransform(e,s);return Util.applyTransform(e,i)}getFieldObject(){return this.data.kidIds?{id:this.data.id,actions:this.data.actions,name:this.data.fieldName,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,type:"",kidIds:this.data.kidIds,page:this.data.pageIndex,rotation:this.rotation}:null}reset(){for(const e of this._streams)e.reset()}_constructFieldName(e){if(!e.has("T")&&!e.has("Parent")){warn("Unknown field name, falling back to empty field name.");return""}if(!e.has("Parent"))return stringToPDFString(e.get("T"));const t=[];e.has("T")&&t.unshift(stringToPDFString(e.get("T")));let i=e;const a=new RefSet;e.objId&&a.put(e.objId);for(;i.has("Parent");){i=i.get("Parent");if(!(i instanceof Dict)||i.objId&&a.has(i.objId))break;i.objId&&a.put(i.objId);i.has("T")&&t.unshift(stringToPDFString(i.get("T")))}return t.join(".")}}class AnnotationBorderStyle{constructor(){this.width=1;this.rawWidth=1;this.style=lA;this.dashArray=[3];this.horizontalCornerRadius=0;this.verticalCornerRadius=0}setWidth(e,t=[0,0,0,0]){if(e instanceof Name)this.width=0;else if("number"==typeof e){if(e>0){this.rawWidth=e;const i=(t[2]-t[0])/2,a=(t[3]-t[1])/2;if(i>0&&a>0&&(e>i||e>a)){warn(`AnnotationBorderStyle.setWidth - ignoring width: ${e}`);e=1}}this.width=e}}setStyle(e){if(e instanceof Name)switch(e.name){case"S":this.style=lA;break;case"D":this.style=BA;break;case"B":this.style=QA;break;case"I":this.style=EA;break;case"U":this.style=uA}}setDashArray(e,t=!1){if(Array.isArray(e)){let i=!0,a=!0;for(const t of e){if(!(+t>=0)){i=!1;break}t>0&&(a=!1)}if(0===e.length||i&&!a){this.dashArray=e;t&&this.setStyle(Name.get("D"))}else this.width=0}else e&&(this.width=0)}setHorizontalCornerRadius(e){Number.isInteger(e)&&(this.horizontalCornerRadius=e)}setVerticalCornerRadius(e){Number.isInteger(e)&&(this.verticalCornerRadius=e)}}class MarkupAnnotation extends Annotation{constructor(e){super(e);const{dict:t}=e;if(t.has("IRT")){const e=t.getRaw("IRT");this.data.inReplyTo=e instanceof Ref?e.toString():null;const i=t.get("RT");this.data.replyType=i instanceof Name?i.name:Z}let i=null;if(this.data.replyType===X){const e=t.get("IRT");this.setTitle(e.get("T"));this.data.titleObj=this._title;this.setContents(e.get("Contents"));this.data.contentsObj=this._contents;if(e.has("CreationDate")){this.setCreationDate(e.get("CreationDate"));this.data.creationDate=this.creationDate}else this.data.creationDate=null;if(e.has("M")){this.setModificationDate(e.get("M"));this.data.modificationDate=this.modificationDate}else this.data.modificationDate=null;i=e.getRaw("Popup");if(e.has("C")){this.setColor(e.getArray("C"));this.data.color=this.color}else this.data.color=null}else{this.data.titleObj=this._title;this.setCreationDate(t.get("CreationDate"));this.data.creationDate=this.creationDate;i=t.getRaw("Popup");t.has("C")||(this.data.color=null)}this.data.popupRef=i instanceof Ref?i.toString():null;t.has("RC")&&(this.data.richText=XFAFactory.getRichTextAsHtml(t.get("RC")))}setCreationDate(e){this.creationDate="string"==typeof e?e:null}_setDefaultAppearance({xref:e,extra:t,strokeColor:i,fillColor:a,blendMode:s,strokeAlpha:r,fillAlpha:n,pointsCallback:g}){let o=Number.MAX_VALUE,c=Number.MAX_VALUE,C=Number.MIN_VALUE,h=Number.MIN_VALUE;const l=["q"];t&&l.push(t);i&&l.push(`${i[0]} ${i[1]} ${i[2]} RG`);a&&l.push(`${a[0]} ${a[1]} ${a[2]} rg`);const Q=this.data.quadPoints||Float32Array.from([this.rectangle[0],this.rectangle[3],this.rectangle[2],this.rectangle[3],this.rectangle[0],this.rectangle[1],this.rectangle[2],this.rectangle[1]]);for(let e=0,t=Q.length;e"string"==typeof e)).map((e=>stringToPDFString(e))):e instanceof Name?stringToPDFString(e.name):"string"==typeof e?stringToPDFString(e):null}hasFieldFlag(e){return!!(this.data.fieldFlags&e)}_isViewable(e){return!0}mustBeViewed(e,t){return t?this.viewable:super.mustBeViewed(e,t)&&!this._hasFlag(this.flags,AA)}getRotationMatrix(e){let t=e?.get(this.data.id)?.rotation;void 0===t&&(t=this.rotation);if(0===t)return i;return getRotationMatrix(t,this.data.rect[2]-this.data.rect[0],this.data.rect[3]-this.data.rect[1])}getBorderAndBackgroundAppearances(e){let t=e?.get(this.data.id)?.rotation;void 0===t&&(t=this.rotation);if(!this.backgroundColor&&!this.borderColor)return"";const i=this.data.rect[2]-this.data.rect[0],a=this.data.rect[3]-this.data.rect[1],s=0===t||180===t?`0 0 ${i} ${a} re`:`0 0 ${a} ${i} re`;let r="";this.backgroundColor&&(r=`${getPdfColor(this.backgroundColor,!0)} ${s} f `);if(this.borderColor){r+=`${this.borderStyle.width||1} w ${getPdfColor(this.borderColor,!1)} ${s} S `}return r}async getOperatorList(e,t,i,a){if(i&h&&!(this instanceof SignatureWidgetAnnotation)&&!this.data.noHTML&&!this.data.hasOwnCanvas)return{opList:new OperatorList,separateForm:!0,separateCanvas:!1};if(!this._hasText)return super.getOperatorList(e,t,i,a);const s=await this._getAppearance(e,t,i,a);if(this.appearance&&null===s)return super.getOperatorList(e,t,i,a);const r=new OperatorList;if(!this._defaultAppearance||null===s)return{opList:r,separateForm:!1,separateCanvas:!1};const n=!!(this.data.hasOwnCanvas&&i&o),g=[0,0,this.data.rect[2]-this.data.rect[0],this.data.rect[3]-this.data.rect[1]],c=getTransformMatrix(this.data.rect,g,[1,0,0,1,0,0]);let C;this.oc&&(C=await e.parseMarkedContentProps(this.oc,null));void 0!==C&&r.addOp(Ye,["OC",C]);r.addOp(je,[this.data.id,this.data.rect,c,this.getRotationMatrix(a),n]);const l=new StringStream(s);await e.getOperatorList({stream:l,task:t,resources:this._fieldResources.mergedResources,operatorList:r});r.addOp(Xe,[]);void 0!==C&&r.addOp(ve,[]);return{opList:r,separateForm:!1,separateCanvas:n}}_getMKDict(e){const t=new Dict(null);e&&t.set("R",e);this.borderColor&&t.set("BC",getPdfColorArray(this.borderColor));this.backgroundColor&&t.set("BG",getPdfColorArray(this.backgroundColor));return t.size>0?t:null}amendSavedDict(e,t){}setValue(e,t,i,a){const{dict:s,ref:r}=function getParentToUpdate(e,t,i){const a=new RefSet,s=e,r={dict:null,ref:null};for(;e instanceof Dict&&!a.has(t);){a.put(t);if(e.has("T"))break;if(!((t=e.getRaw("Parent"))instanceof Ref))return r;e=i.fetch(t)}if(e instanceof Dict&&e!==s){r.dict=e;r.ref=t}return r}(e,this.ref,i);if(s){if(!a.has(r)){const e=s.clone();e.set("V",t);a.put(r,{data:e});return e}}else e.set("V",t);return null}async save(e,t,a,s){const r=a?.get(this.data.id),n=this._buildFlags(r?.noView,r?.noPrint);let g=r?.value,o=r?.rotation;if(g===this.data.fieldValue||void 0===g){if(!this._hasValueFromXFA&&void 0===o&&void 0===n)return;g||=this.data.fieldValue}if(void 0===o&&!this._hasValueFromXFA&&Array.isArray(g)&&Array.isArray(this.data.fieldValue)&&isArrayEqual(g,this.data.fieldValue)&&void 0===n)return;void 0===o&&(o=this.rotation);let c=null;if(!this._needAppearances){c=await this._getAppearance(e,t,C,a);if(null===c&&void 0===n)return}let h=!1;if(c?.needAppearances){h=!0;c=null}const{xref:l}=e,Q=l.fetchIfRef(this.ref);if(!(Q instanceof Dict))return;const E=new Dict(l);for(const e of Q.getKeys())"AP"!==e&&E.set(e,Q.getRaw(e));if(void 0!==n){E.set("F",n);if(null===c&&!h){const e=Q.getRaw("AP");e&&E.set("AP",e)}}const u={path:this.data.fieldName,value:g},d=this.setValue(E,Array.isArray(g)?g.map(stringToAsciiOrUTF16BE):stringToAsciiOrUTF16BE(g),l,s);this.amendSavedDict(a,d||E);const f=this._getMKDict(o);f&&E.set("MK",f);s.put(this.ref,{data:E,xfa:u,needAppearances:h});if(null!==c){const e=l.getNewTemporaryRef(),t=new Dict(l);E.set("AP",t);t.set("N",e);const r=this._getSaveFieldResources(l),n=new StringStream(c),g=n.dict=new Dict(l);g.set("Subtype",Name.get("Form"));g.set("Resources",r);g.set("BBox",[0,0,this.data.rect[2]-this.data.rect[0],this.data.rect[3]-this.data.rect[1]]);const o=this.getRotationMatrix(a);o!==i&&g.set("Matrix",o);s.put(e,{data:n,xfa:null,needAppearances:!1})}E.set("M",`D:${getModificationDate()}`)}async _getAppearance(e,t,i,a){if(this.hasFieldFlag(rA))return null;const s=a?.get(this.data.id);let r,g;if(s){r=s.formattedValue||s.value;g=s.rotation}if(void 0===g&&void 0===r&&!this._needAppearances&&(!this._hasValueFromXFA||this.appearance))return null;const o=this.getBorderAndBackgroundAppearances(a);if(void 0===r){r=this.data.fieldValue;if(!r)return`/Tx BMC q ${o}Q EMC`}Array.isArray(r)&&1===r.length&&(r=r[0]);assert("string"==typeof r,"Expected `value` to be a string.");r=r.trimEnd();if(this.data.combo){const e=this.data.options.find((({exportValue:e})=>r===e));r=e?.displayValue||r}if(""===r)return`/Tx BMC q ${o}Q EMC`;void 0===g&&(g=this.rotation);let c,h=-1;if(this.data.multiLine){c=r.split(/\r\n?|\n/).map((e=>e.normalize("NFC")));h=c.length}else c=[r.replace(/\r\n?|\n/,"").normalize("NFC")];let l=this.data.rect[3]-this.data.rect[1],Q=this.data.rect[2]-this.data.rect[0];90!==g&&270!==g||([Q,l]=[l,Q]);this._defaultAppearance||(this.data.defaultAppearanceData=parseDefaultAppearance(this._defaultAppearance="/Helvetica 0 Tf 0 g"));let E,u,d,f=await WidgetAnnotation._getFontData(e,t,this.data.defaultAppearanceData,this._fieldResources.mergedResources);const p=[];let m=!1;for(const e of c){const t=f.encodeString(e);t.length>1&&(m=!0);p.push(t.join(""))}if(m&&i&C)return{needAppearances:!0};if(m&&this._isOffscreenCanvasSupported){const i=this.data.comb?"monospace":"sans-serif",a=new FakeUnicodeFont(e.xref,i),s=a.createFontResources(c.join("")),n=s.getRaw("Font");if(this._fieldResources.mergedResources.has("Font")){const e=this._fieldResources.mergedResources.get("Font");for(const t of n.getKeys())e.set(t,n.getRaw(t))}else this._fieldResources.mergedResources.set("Font",n);const g=a.fontName.name;f=await WidgetAnnotation._getFontData(e,t,{fontName:g,fontSize:0},s);for(let e=0,t=p.length;e2)return`/Tx BMC q ${o}BT `+E+` 1 0 0 1 ${numberToString(2)} ${numberToString(b)} Tm (${escapeString(p[0])}) Tj ET Q EMC`;return`/Tx BMC q ${o}BT `+E+` 1 0 0 1 0 0 Tm ${this._renderText(p[0],f,u,Q,D,{shift:0},2,b)} ET Q EMC`}static async _getFontData(e,t,i,a){const s=new OperatorList,r={font:null,clone(){return this}},{fontName:n,fontSize:g}=i;await e.handleSetFont(a,[n&&Name.get(n),g],null,s,t,r,null);return r.font}_getTextWidth(e,t){return t.charsToGlyphs(e).reduce(((e,t)=>e+t.width),0)/1e3}_computeFontSize(e,t,i,a,r){let{fontSize:n}=this.data.defaultAppearanceData,g=(n||12)*s,o=Math.round(e/g);if(!n){const roundWithTwoDigits=e=>Math.floor(100*e)/100;if(-1===r){const r=this._getTextWidth(i,a);n=roundWithTwoDigits(Math.min(e/s,t/r));o=1}else{const c=i.split(/\r\n?|\n/),C=[];for(const e of c){const t=a.encodeString(e).join(""),i=a.charsToGlyphs(t),s=a.getCharPositions(t);C.push({line:t,glyphs:i,positions:s})}const isTooBig=i=>{let s=0;for(const r of C){s+=this._splitLine(null,a,i,t,r).length*i;if(s>e)return!0}return!1};o=Math.max(o,r);for(;;){g=e/o;n=roundWithTwoDigits(g/s);if(!isTooBig(n))break;o++}}const{fontName:c,fontColor:C}=this.data.defaultAppearanceData;this._defaultAppearance=function createDefaultAppearance({fontSize:e,fontName:t,fontColor:i}){return`/${escapePDFName(t)} ${e} Tf ${getPdfColor(i,!0)}`}({fontSize:n,fontName:c,fontColor:C})}return[this._defaultAppearance,n,e/o]}_renderText(e,t,i,a,s,r,n,g){let o;if(1===s){o=(a-this._getTextWidth(e,t)*i)/2}else if(2===s){o=a-this._getTextWidth(e,t)*i-n}else o=n;const c=numberToString(o-r.shift);r.shift=o;return`${c} ${g=numberToString(g)} Td (${escapeString(e)}) Tj`}_getSaveFieldResources(e){const{localResources:t,appearanceResources:i,acroFormResources:a}=this._fieldResources,s=this.data.defaultAppearanceData?.fontName;if(!s)return t||Dict.empty;for(const e of[t,i])if(e instanceof Dict){const t=e.get("Font");if(t instanceof Dict&&t.has(s))return e}if(a instanceof Dict){const i=a.get("Font");if(i instanceof Dict&&i.has(s)){const a=new Dict(e);a.set(s,i.getRaw(s));const r=new Dict(e);r.set("Font",a);return Dict.merge({xref:e,dictArray:[r,t],mergeSubDicts:!0})}}return t||Dict.empty}getFieldObject(){return null}}class TextWidgetAnnotation extends WidgetAnnotation{constructor(e){super(e);const{dict:t}=e;if(t.has("PMD")){this.flags|=z;this.data.hidden=!0;warn("Barcodes are not supported")}this.data.hasOwnCanvas=this.data.readOnly&&!this.data.noHTML;this._hasText=!0;"string"!=typeof this.data.fieldValue&&(this.data.fieldValue="");let i=getInheritableProperty({dict:t,key:"Q"});(!Number.isInteger(i)||i<0||i>2)&&(i=null);this.data.textAlignment=i;let a=getInheritableProperty({dict:t,key:"MaxLen"});(!Number.isInteger(a)||a<0)&&(a=0);this.data.maxLen=a;this.data.multiLine=this.hasFieldFlag(sA);this.data.comb=this.hasFieldFlag(hA)&&!this.hasFieldFlag(sA)&&!this.hasFieldFlag(rA)&&!this.hasFieldFlag(IA)&&0!==this.data.maxLen;this.data.doNotScroll=this.hasFieldFlag(CA)}get hasTextContent(){return!!this.appearance&&!this._needAppearances}_getCombAppearance(e,t,i,a,s,r,n,g,o,c,C){const h=s/this.data.maxLen,l=this.getBorderAndBackgroundAppearances(C),Q=[],E=t.getCharPositions(i);for(const[e,t]of E)Q.push(`(${escapeString(i.substring(e,t))}) Tj`);const u=Q.join(` ${numberToString(h)} 0 Td `);return`/Tx BMC q ${l}BT `+e+` 1 0 0 1 ${numberToString(n)} ${numberToString(g+o)} Tm ${u} ET Q EMC`}_getMultilineAppearance(e,t,i,a,s,r,n,g,o,c,C,h){const l=[],Q=s-2*g,E={shift:0};for(let e=0,r=t.length;ea){o.push(e.substring(l,i));l=i;Q=u;c=-1;h=-1}else{Q+=u;c=i;C=s;h=t}else if(Q+u>a)if(-1!==c){o.push(e.substring(l,C));l=C;t=h+1;c=-1;Q=0}else{o.push(e.substring(l,i));l=i;Q=u}else Q+=u}lt?`\\${t}`:"\\s+"));new RegExp(`^\\s*${r}\\s*$`).test(this.data.fieldValue)&&(this.data.textContent=this.data.fieldValue.split("\n"))}getFieldObject(){return{id:this.data.id,value:this.data.fieldValue,defaultValue:this.data.defaultFieldValue||"",multiline:this.data.multiLine,password:this.hasFieldFlag(rA),charLimit:this.data.maxLen,comb:this.data.comb,editable:!this.data.readOnly,hidden:this.data.hidden,name:this.data.fieldName,rect:this.data.rect,actions:this.data.actions,page:this.data.pageIndex,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,rotation:this.rotation,type:"text"}}}class ButtonWidgetAnnotation extends WidgetAnnotation{constructor(e){super(e);this.checkedAppearance=null;this.uncheckedAppearance=null;this.data.checkBox=!this.hasFieldFlag(nA)&&!this.hasFieldFlag(gA);this.data.radioButton=this.hasFieldFlag(nA)&&!this.hasFieldFlag(gA);this.data.pushButton=this.hasFieldFlag(gA);this.data.isTooltipOnly=!1;if(this.data.checkBox)this._processCheckBox(e);else if(this.data.radioButton)this._processRadioButton(e);else if(this.data.pushButton){this.data.hasOwnCanvas=!0;this.data.noHTML=!1;this._processPushButton(e)}else warn("Invalid field flags for button widget annotation")}async getOperatorList(e,t,a,s){if(this.data.pushButton)return super.getOperatorList(e,t,a,!1,s);let r=null,n=null;if(s){const e=s.get(this.data.id);r=e?e.value:null;n=e?e.rotation:null}if(null===r&&this.appearance)return super.getOperatorList(e,t,a,s);null==r&&(r=this.data.checkBox?this.data.fieldValue===this.data.exportValue:this.data.fieldValue===this.data.buttonValue);const g=r?this.checkedAppearance:this.uncheckedAppearance;if(g){const r=this.appearance,o=lookupMatrix(g.dict.getArray("Matrix"),i);n&&g.dict.set("Matrix",this.getRotationMatrix(s));this.appearance=g;const c=super.getOperatorList(e,t,a,s);this.appearance=r;g.dict.set("Matrix",o);return c}return{opList:new OperatorList,separateForm:!1,separateCanvas:!1}}async save(e,t,i,a){this.data.checkBox?this._saveCheckbox(e,t,i,a):this.data.radioButton&&this._saveRadioButton(e,t,i,a)}async _saveCheckbox(e,t,i,a){if(!i)return;const s=i.get(this.data.id),r=this._buildFlags(s?.noView,s?.noPrint);let n=s?.rotation,g=s?.value;if(void 0===n&&void 0===r){if(void 0===g)return;if(this.data.fieldValue===this.data.exportValue===g)return}let o=e.xref.fetchIfRef(this.ref);if(!(o instanceof Dict))return;o=o.clone();void 0===n&&(n=this.rotation);void 0===g&&(g=this.data.fieldValue===this.data.exportValue);const c={path:this.data.fieldName,value:g?this.data.exportValue:""},C=Name.get(g?this.data.exportValue:"Off");this.setValue(o,C,e.xref,a);o.set("AS",C);o.set("M",`D:${getModificationDate()}`);void 0!==r&&o.set("F",r);const h=this._getMKDict(n);h&&o.set("MK",h);a.put(this.ref,{data:o,xfa:c,needAppearances:!1})}async _saveRadioButton(e,t,i,a){if(!i)return;const s=i.get(this.data.id),r=this._buildFlags(s?.noView,s?.noPrint);let n=s?.rotation,g=s?.value;if(void 0===n&&void 0===r){if(void 0===g)return;if(this.data.fieldValue===this.data.buttonValue===g)return}let o=e.xref.fetchIfRef(this.ref);if(!(o instanceof Dict))return;o=o.clone();void 0===g&&(g=this.data.fieldValue===this.data.buttonValue);void 0===n&&(n=this.rotation);const c={path:this.data.fieldName,value:g?this.data.buttonValue:""},C=Name.get(g?this.data.buttonValue:"Off");g&&this.setValue(o,C,e.xref,a);o.set("AS",C);o.set("M",`D:${getModificationDate()}`);void 0!==r&&o.set("F",r);const h=this._getMKDict(n);h&&o.set("MK",h);a.put(this.ref,{data:o,xfa:c,needAppearances:!1})}_getDefaultCheckedAppearance(e,t){const i=this.data.rect[2]-this.data.rect[0],a=this.data.rect[3]-this.data.rect[1],s=[0,0,i,a],r=.8*Math.min(i,a);let n,g;if("check"===t){n={width:.755*r,height:.705*r};g="3"}else if("disc"===t){n={width:.791*r,height:.705*r};g="l"}else unreachable(`_getDefaultCheckedAppearance - unsupported type: ${t}`);const o=`q BT /PdfJsZaDb ${r} Tf 0 g ${numberToString((i-n.width)/2)} ${numberToString((a-n.height)/2)} Td (${g}) Tj ET Q`,c=new Dict(e.xref);c.set("FormType",1);c.set("Subtype",Name.get("Form"));c.set("Type",Name.get("XObject"));c.set("BBox",s);c.set("Matrix",[1,0,0,1,0,0]);c.set("Length",o.length);const C=new Dict(e.xref),h=new Dict(e.xref);h.set("PdfJsZaDb",this.fallbackFontDict);C.set("Font",h);c.set("Resources",C);this.checkedAppearance=new StringStream(o);this.checkedAppearance.dict=c;this._streams.push(this.checkedAppearance)}_processCheckBox(e){const t=e.dict.get("AP");if(!(t instanceof Dict))return;const i=t.get("N");if(!(i instanceof Dict))return;const a=this._decodeFormValue(e.dict.get("AS"));"string"==typeof a&&(this.data.fieldValue=a);const s=null!==this.data.fieldValue&&"Off"!==this.data.fieldValue?this.data.fieldValue:"Yes",r=i.getKeys();if(0===r.length)r.push("Off",s);else if(1===r.length)"Off"===r[0]?r.push(s):r.unshift("Off");else if(r.includes(s)){r.length=0;r.push("Off",s)}else{const e=r.find((e=>"Off"!==e));r.length=0;r.push("Off",e)}r.includes(this.data.fieldValue)||(this.data.fieldValue="Off");this.data.exportValue=r[1];const n=i.get(this.data.exportValue);this.checkedAppearance=n instanceof BaseStream?n:null;const g=i.get("Off");this.uncheckedAppearance=g instanceof BaseStream?g:null;this.checkedAppearance?this._streams.push(this.checkedAppearance):this._getDefaultCheckedAppearance(e,"check");this.uncheckedAppearance&&this._streams.push(this.uncheckedAppearance);this._fallbackFontDict=this.fallbackFontDict;null===this.data.defaultFieldValue&&(this.data.defaultFieldValue="Off")}_processRadioButton(e){this.data.buttonValue=null;const t=e.dict.get("Parent");if(t instanceof Dict){this.parent=e.dict.getRaw("Parent");const i=t.get("V");i instanceof Name&&(this.data.fieldValue=this._decodeFormValue(i))}const i=e.dict.get("AP");if(!(i instanceof Dict))return;const a=i.get("N");if(!(a instanceof Dict))return;for(const e of a.getKeys())if("Off"!==e){this.data.buttonValue=this._decodeFormValue(e);break}const s=a.get(this.data.buttonValue);this.checkedAppearance=s instanceof BaseStream?s:null;const r=a.get("Off");this.uncheckedAppearance=r instanceof BaseStream?r:null;this.checkedAppearance?this._streams.push(this.checkedAppearance):this._getDefaultCheckedAppearance(e,"disc");this.uncheckedAppearance&&this._streams.push(this.uncheckedAppearance);this._fallbackFontDict=this.fallbackFontDict;null===this.data.defaultFieldValue&&(this.data.defaultFieldValue="Off")}_processPushButton(e){const{dict:t,annotationGlobals:i}=e;if(t.has("A")||t.has("AA")||this.data.alternativeText){this.data.isTooltipOnly=!t.has("A")&&!t.has("AA");Catalog.parseDestDictionary({destDict:t,resultObj:this.data,docBaseUrl:i.baseUrl,docAttachments:i.attachments})}else warn("Push buttons without action dictionaries are not supported")}getFieldObject(){let e,t="button";if(this.data.checkBox){t="checkbox";e=this.data.exportValue}else if(this.data.radioButton){t="radiobutton";e=this.data.buttonValue}return{id:this.data.id,value:this.data.fieldValue||"Off",defaultValue:this.data.defaultFieldValue,exportValues:e,editable:!this.data.readOnly,name:this.data.fieldName,rect:this.data.rect,hidden:this.data.hidden,actions:this.data.actions,page:this.data.pageIndex,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,rotation:this.rotation,type:t}}get fallbackFontDict(){const e=new Dict;e.set("BaseFont",Name.get("ZapfDingbats"));e.set("Type",Name.get("FallbackType"));e.set("Subtype",Name.get("FallbackType"));e.set("Encoding",Name.get("ZapfDingbatsEncoding"));return shadow(this,"fallbackFontDict",e)}}class ChoiceWidgetAnnotation extends WidgetAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.indices=t.getArray("I");this.hasIndices=Array.isArray(this.indices)&&this.indices.length>0;this.data.options=[];const a=getInheritableProperty({dict:t,key:"Opt"});if(Array.isArray(a))for(let e=0,t=a.length;e=0&&t0&&(this.data.options=this.data.fieldValue.map((e=>({exportValue:e,displayValue:e}))));this.data.combo=this.hasFieldFlag(oA);this.data.multiSelect=this.hasFieldFlag(cA);this._hasText=!0}getFieldObject(){const e=this.data.combo?"combobox":"listbox",t=this.data.fieldValue.length>0?this.data.fieldValue[0]:null;return{id:this.data.id,value:t,defaultValue:this.data.defaultFieldValue,editable:!this.data.readOnly,name:this.data.fieldName,rect:this.data.rect,numItems:this.data.fieldValue.length,multipleSelection:this.data.multiSelect,hidden:this.data.hidden,actions:this.data.actions,items:this.data.options,page:this.data.pageIndex,strokeColor:this.data.borderColor,fillColor:this.data.backgroundColor,rotation:this.rotation,type:e}}amendSavedDict(e,t){if(!this.hasIndices)return;let i=e?.get(this.data.id)?.value;Array.isArray(i)||(i=[i]);const a=[],{options:s}=this.data;for(let e=0,t=0,r=s.length;ei){i=a;t=e}}[Q,E]=this._computeFontSize(e,c-4,t,l,-1)}const u=E*s,d=(u-E)/2,f=Math.floor(o/u);let p=0;if(h.length>0){const e=Math.min(...h),t=Math.max(...h);p=Math.max(0,t-f+1);p>e&&(p=e)}const m=Math.min(p+f+1,C),y=["/Tx BMC q",`1 1 ${c} ${o} re W n`];if(h.length){y.push("0.600006 0.756866 0.854904 rg");for(const e of h)p<=e&&ee.trimEnd()));const{coords:e,bbox:t,matrix:i}=FakeUnicodeFont.getFirstPositionInfo(this.rectangle,this.rotation,a);this.data.textPosition=this._transformPoint(e,t,i)}if(this._isOffscreenCanvasSupported){const s=e.dict.get("CA"),r=new FakeUnicodeFont(i,"sans-serif");this.appearance=r.createAppearance(this._contents.str,this.rectangle,this.rotation,a,t,s);this._streams.push(this.appearance)}else warn("FreeTextAnnotation: OffscreenCanvas is not supported, annotation may not render correctly.")}}get hasTextContent(){return this._hasAppearance}static createNewDict(e,t,{apRef:i,ap:a}){const{color:s,fontSize:r,oldAnnotation:n,rect:g,rotation:o,user:c,value:C}=e,h=n||new Dict(t);h.set("Type",Name.get("Annot"));h.set("Subtype",Name.get("FreeText"));if(n){h.set("M",`D:${getModificationDate()}`);h.delete("RC")}else h.set("CreationDate",`D:${getModificationDate()}`);h.set("Rect",g);const l=`/Helv ${r} Tf ${getPdfColor(s,!0)}`;h.set("DA",l);h.set("Contents",stringToAsciiOrUTF16BE(C));h.set("F",4);h.set("Border",[0,0,0]);h.set("Rotate",o);c&&h.set("T",stringToAsciiOrUTF16BE(c));if(i||a){const e=new Dict(t);h.set("AP",e);i?e.set("N",i):e.set("N",a)}return h}static async createNewAppearanceStream(e,t,i){const{baseFontRef:a,evaluator:r,task:n}=i,{color:g,fontSize:o,rect:c,rotation:C,value:h}=e,l=new Dict(t),Q=new Dict(t);if(a)Q.set("Helv",a);else{const e=new Dict(t);e.set("BaseFont",Name.get("Helvetica"));e.set("Type",Name.get("Font"));e.set("Subtype",Name.get("Type1"));e.set("Encoding",Name.get("WinAnsiEncoding"));Q.set("Helv",e)}l.set("Font",Q);const E=await WidgetAnnotation._getFontData(r,n,{fontName:"Helv",fontSize:o},l),[u,d,f,p]=c;let m=f-u,y=p-d;C%180!=0&&([m,y]=[y,m]);const w=h.split("\n"),D=o/1e3;let b=-1/0;const F=[];for(let e of w){const t=E.encodeString(e);if(t.length>1)return null;e=t.join("");F.push(e);let i=0;const a=E.charsToGlyphs(e);for(const e of a)i+=e.width*D;b=Math.max(b,i)}let S=1;b>m&&(S=m/b);let k=1;const R=s*o,N=1*o,G=R*w.length;G>y&&(k=y/G);const M=o*Math.min(S,k);let U,x,L;switch(C){case 0:L=[1,0,0,1];x=[c[0],c[1],m,y];U=[c[0],c[3]-N];break;case 90:L=[0,1,-1,0];x=[c[1],-c[2],m,y];U=[c[1],-c[0]-N];break;case 180:L=[-1,0,0,-1];x=[-c[2],-c[3],m,y];U=[-c[2],-c[1]-N];break;case 270:L=[0,-1,1,0];x=[-c[3],c[0],m,y];U=[-c[3],c[2]-N]}const H=["q",`${L.join(" ")} 0 0 cm`,`${x.join(" ")} re W n`,"BT",`${getPdfColor(g,!0)}`,`0 Tc /Helv ${numberToString(M)} Tf`];H.push(`${U.join(" ")} Td (${escapeString(F[0])}) Tj`);const J=numberToString(R);for(let e=1,t=F.length;e{e.push(`${a[0]} ${a[1]} m`,`${a[2]} ${a[3]} l`,"S");return[t[0]-o,t[2]+o,t[7]-o,t[3]+o]}})}}}class SquareAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=M;this.data.hasOwnCanvas=this.data.noRotate;this.data.noHTML=!1;if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA"),s=getRgbColor(t.getArray("IC"),null),r=s?getPdfColorArray(s):null,n=r?a:null;if(0===this.borderStyle.width&&!r)return;this._setDefaultAppearance({xref:i,extra:`${this.borderStyle.width} w`,strokeColor:e,fillColor:r,strokeAlpha:a,fillAlpha:n,pointsCallback:(e,t)=>{const i=t[4]+this.borderStyle.width/2,a=t[5]+this.borderStyle.width/2,s=t[6]-t[4]-this.borderStyle.width,n=t[3]-t[7]-this.borderStyle.width;e.push(`${i} ${a} ${s} ${n} re`);r?e.push("B"):e.push("S");return[t[0],t[2],t[7],t[3]]}})}}}class CircleAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=U;if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA"),s=getRgbColor(t.getArray("IC"),null),r=s?getPdfColorArray(s):null,n=r?a:null;if(0===this.borderStyle.width&&!r)return;const g=4/3*Math.tan(Math.PI/8);this._setDefaultAppearance({xref:i,extra:`${this.borderStyle.width} w`,strokeColor:e,fillColor:r,strokeAlpha:a,fillAlpha:n,pointsCallback:(e,t)=>{const i=t[0]+this.borderStyle.width/2,a=t[1]-this.borderStyle.width/2,s=t[6]-this.borderStyle.width/2,n=t[7]+this.borderStyle.width/2,o=i+(s-i)/2,c=a+(n-a)/2,C=(s-i)/2*g,h=(n-a)/2*g;e.push(`${o} ${n} m`,`${o+C} ${n} ${s} ${c+h} ${s} ${c} c`,`${s} ${c-h} ${o+C} ${a} ${o} ${a} c`,`${o-C} ${a} ${i} ${c-h} ${i} ${c} c`,`${i} ${c+h} ${o-C} ${n} ${o} ${n} c`,"h");r?e.push("B"):e.push("S");return[t[0],t[2],t[7],t[3]]}})}}}class PolylineAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=L;this.data.hasOwnCanvas=this.data.noRotate;this.data.noHTML=!1;this.data.vertices=null;if(!(this instanceof PolygonAnnotation)){this.setLineEndings(t.getArray("LE"));this.data.lineEndings=this.lineEndings}const a=t.getArray("Vertices");if(!isNumberArray(a,null))return;const s=this.data.vertices=Float32Array.from(a);if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA"),r=this.borderStyle.width||1,n=2*r,g=[1/0,1/0,-1/0,-1/0];for(let e=0,t=s.length;e{for(let t=0,i=s.length;t{for(const t of this.data.inkLists){for(let i=0,a=t.length;ie/255)));Q.set("CA",n);const u=new Dict(t);Q.set("AP",u);i?u.set("N",i):u.set("N",a);return Q}static async createNewAppearanceStream(e,t,i){if(e.outlines)return this.createNewAppearanceStreamForHighlight(e,t,i);const{color:a,rect:s,paths:r,thickness:n,opacity:g}=e,o=[`${n} w 1 J 1 j`,`${getPdfColor(a,!1)}`];1!==g&&o.push("/R0 gs");for(const e of r.lines){o.push(`${numberToString(e[4])} ${numberToString(e[5])} m`);for(let t=6,i=e.length;t{e.push(`${t[0]} ${t[1]} m`,`${t[2]} ${t[3]} l`,`${t[6]} ${t[7]} l`,`${t[4]} ${t[5]} l`,"f");return[t[0],t[2],t[7],t[3]]}})}}else this.data.popupRef=null}static createNewDict(e,t,{apRef:i,ap:a}){const{color:s,oldAnnotation:r,opacity:n,rect:g,rotation:o,user:c,quadPoints:C}=e,h=r||new Dict(t);h.set("Type",Name.get("Annot"));h.set("Subtype",Name.get("Highlight"));h.set(r?"M":"CreationDate",`D:${getModificationDate()}`);h.set("CreationDate",`D:${getModificationDate()}`);h.set("Rect",g);h.set("F",4);h.set("Border",[0,0,0]);h.set("Rotate",o);h.set("QuadPoints",C);h.set("C",Array.from(s,(e=>e/255)));h.set("CA",n);c&&h.set("T",stringToAsciiOrUTF16BE(c));if(i||a){const e=new Dict(t);h.set("AP",e);e.set("N",i||a)}return h}static async createNewAppearanceStream(e,t,i){const{color:a,rect:s,outlines:r,opacity:n}=e,g=[`${getPdfColor(a,!0)}`,"/R0 gs"],o=[];for(const e of r){o.length=0;o.push(`${numberToString(e[0])} ${numberToString(e[1])} m`);for(let t=2,i=e.length;t{e.push(`${t[4]} ${t[5]+1.3} m`,`${t[6]} ${t[7]+1.3} l`,"S");return[t[0],t[2],t[7],t[3]]}})}}else this.data.popupRef=null}}class SquigglyAnnotation extends MarkupAnnotation{constructor(e){super(e);const{dict:t,xref:i}=e;this.data.annotationType=Y;if(this.data.quadPoints=getQuadPoints(t,null)){if(!this.appearance){const e=this.color?getPdfColorArray(this.color):[0,0,0],a=t.get("CA");this._setDefaultAppearance({xref:i,extra:"[] 0 d 1 w",strokeColor:e,strokeAlpha:a,pointsCallback:(e,t)=>{const i=(t[1]-t[5])/6;let a=i,s=t[4];const r=t[5],n=t[6];e.push(`${s} ${r+a} m`);do{s+=2;a=0===a?i:0;e.push(`${s} ${r+a} l`)}while(s{e.push((t[0]+t[4])/2+" "+(t[1]+t[5])/2+" m",(t[2]+t[6])/2+" "+(t[3]+t[7])/2+" l","S");return[t[0],t[2],t[7],t[3]]}})}}else this.data.popupRef=null}}class StampAnnotation extends MarkupAnnotation{#T;constructor(e){super(e);this.data.annotationType=K;this.#T=this.data.hasOwnCanvas=this.data.noRotate;this.data.isEditable=!this.data.noHTML;this.data.noHTML=!1}mustBeViewedWhenEditing(e,t=null){if(e){if(!this.data.isEditable)return!1;this.#T=this.data.hasOwnCanvas;this.data.hasOwnCanvas=!0;return!0}this.data.hasOwnCanvas=this.#T;return!t?.has(this.data.id)}static async createImage(e,t){const{width:i,height:a}=e,s=new OffscreenCanvas(i,a),r=s.getContext("2d",{alpha:!0});r.drawImage(e,0,0);const n=r.getImageData(0,0,i,a).data,g=new Uint32Array(n.buffer),o=g.some(FeatureTest.isLittleEndian?e=>e>>>24!=255:e=>!!(255&~e));if(o){r.fillStyle="white";r.fillRect(0,0,i,a);r.drawImage(e,0,0)}const c=s.convertToBlob({type:"image/jpeg",quality:1}).then((e=>e.arrayBuffer())),C=Name.get("XObject"),h=Name.get("Image"),l=new Dict(t);l.set("Type",C);l.set("Subtype",h);l.set("BitsPerComponent",8);l.set("ColorSpace",Name.get("DeviceRGB"));l.set("Filter",Name.get("DCTDecode"));l.set("BBox",[0,0,i,a]);l.set("Width",i);l.set("Height",a);let Q=null;if(o){const e=new Uint8Array(g.length);if(FeatureTest.isLittleEndian)for(let t=0,i=g.length;t>>24;else for(let t=0,i=g.length;t=0&&r<=1?r:null}}class DecryptStream extends DecodeStream{constructor(e,t,i){super(t);this.str=e;this.dict=e.dict;this.decrypt=i;this.nextChunk=null;this.initialized=!1}readBlock(){let e;if(this.initialized)e=this.nextChunk;else{e=this.str.getBytes(512);this.initialized=!0}if(!e?.length){this.eof=!0;return}this.nextChunk=this.str.getBytes(512);const t=this.nextChunk?.length>0;e=(0,this.decrypt)(e,!t);const i=this.bufferLength,a=i+e.length;this.ensureBuffer(a).set(e,i);this.bufferLength=a}}class ARCFourCipher{constructor(e){this.a=0;this.b=0;const t=new Uint8Array(256),i=e.length;for(let e=0;e<256;++e)t[e]=e;for(let a=0,s=0;a<256;++a){const r=t[a];s=s+r+e[a%i]&255;t[a]=t[s];t[s]=r}this.s=t}encryptBlock(e){let t=this.a,i=this.b;const a=this.s,s=e.length,r=new Uint8Array(s);for(let n=0;n>5&255;C[h++]=s>>13&255;C[h++]=s>>21&255;C[h++]=s>>>29&255;C[h++]=0;C[h++]=0;C[h++]=0;const E=new Int32Array(16);for(h=0;h>>32-g)|0;s=r}r=r+s|0;n=n+c|0;g=g+Q|0;o=o+u|0}return new Uint8Array([255&r,r>>8&255,r>>16&255,r>>>24&255,255&n,n>>8&255,n>>16&255,n>>>24&255,255&g,g>>8&255,g>>16&255,g>>>24&255,255&o,o>>8&255,o>>16&255,o>>>24&255])}}();class Word64{constructor(e,t){this.high=0|e;this.low=0|t}and(e){this.high&=e.high;this.low&=e.low}xor(e){this.high^=e.high;this.low^=e.low}or(e){this.high|=e.high;this.low|=e.low}shiftRight(e){if(e>=32){this.low=this.high>>>e-32|0;this.high=0}else{this.low=this.low>>>e|this.high<<32-e;this.high=this.high>>>e|0}}shiftLeft(e){if(e>=32){this.high=this.low<>>32-e;this.low<<=e}}rotateRight(e){let t,i;if(32&e){i=this.low;t=this.high}else{t=this.low;i=this.high}e&=31;this.low=t>>>e|i<<32-e;this.high=i>>>e|t<<32-e}not(){this.high=~this.high;this.low=~this.low}add(e){const t=(this.low>>>0)+(e.low>>>0);let i=(this.high>>>0)+(e.high>>>0);t>4294967295&&(i+=1);this.low=0|t;this.high=0|i}copyTo(e,t){e[t]=this.high>>>24&255;e[t+1]=this.high>>16&255;e[t+2]=this.high>>8&255;e[t+3]=255&this.high;e[t+4]=this.low>>>24&255;e[t+5]=this.low>>16&255;e[t+6]=this.low>>8&255;e[t+7]=255&this.low}assign(e){this.high=e.high;this.low=e.low}}const Ag=function calculateSHA256Closure(){function rotr(e,t){return e>>>t|e<<32-t}function ch(e,t,i){return e&t^~e&i}function maj(e,t,i){return e&t^e&i^t&i}function sigma(e){return rotr(e,2)^rotr(e,13)^rotr(e,22)}function sigmaPrime(e){return rotr(e,6)^rotr(e,11)^rotr(e,25)}function littleSigma(e){return rotr(e,7)^rotr(e,18)^e>>>3}const e=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298];return function hash(t,i,a){let s=1779033703,r=3144134277,n=1013904242,g=2773480762,o=1359893119,c=2600822924,C=528734635,h=1541459225;const l=64*Math.ceil((a+9)/64),Q=new Uint8Array(l);let E,u;for(E=0;E>>29&255;Q[E++]=a>>21&255;Q[E++]=a>>13&255;Q[E++]=a>>5&255;Q[E++]=a<<3&255;const f=new Uint32Array(64);for(E=0;E>>10)+f[u-7]+littleSigma(f[u-15])+f[u-16]|0;let t,i,a=s,l=r,d=n,m=g,y=o,w=c,D=C,b=h;for(u=0;u<64;++u){t=b+sigmaPrime(y)+ch(y,w,D)+e[u]+f[u];i=sigma(a)+maj(a,l,d);b=D;D=w;w=y;y=m+t|0;m=d;d=l;l=a;a=t+i|0}s=s+a|0;r=r+l|0;n=n+d|0;g=g+m|0;o=o+y|0;c=c+w|0;C=C+D|0;h=h+b|0}var p;return new Uint8Array([s>>24&255,s>>16&255,s>>8&255,255&s,r>>24&255,r>>16&255,r>>8&255,255&r,n>>24&255,n>>16&255,n>>8&255,255&n,g>>24&255,g>>16&255,g>>8&255,255&g,o>>24&255,o>>16&255,o>>8&255,255&o,c>>24&255,c>>16&255,c>>8&255,255&c,C>>24&255,C>>16&255,C>>8&255,255&C,h>>24&255,h>>16&255,h>>8&255,255&h])}}(),eg=function calculateSHA512Closure(){function ch(e,t,i,a,s){e.assign(t);e.and(i);s.assign(t);s.not();s.and(a);e.xor(s)}function maj(e,t,i,a,s){e.assign(t);e.and(i);s.assign(t);s.and(a);e.xor(s);s.assign(i);s.and(a);e.xor(s)}function sigma(e,t,i){e.assign(t);e.rotateRight(28);i.assign(t);i.rotateRight(34);e.xor(i);i.assign(t);i.rotateRight(39);e.xor(i)}function sigmaPrime(e,t,i){e.assign(t);e.rotateRight(14);i.assign(t);i.rotateRight(18);e.xor(i);i.assign(t);i.rotateRight(41);e.xor(i)}function littleSigma(e,t,i){e.assign(t);e.rotateRight(1);i.assign(t);i.rotateRight(8);e.xor(i);i.assign(t);i.shiftRight(7);e.xor(i)}function littleSigmaPrime(e,t,i){e.assign(t);e.rotateRight(19);i.assign(t);i.rotateRight(61);e.xor(i);i.assign(t);i.shiftRight(6);e.xor(i)}const e=[new Word64(1116352408,3609767458),new Word64(1899447441,602891725),new Word64(3049323471,3964484399),new Word64(3921009573,2173295548),new Word64(961987163,4081628472),new Word64(1508970993,3053834265),new Word64(2453635748,2937671579),new Word64(2870763221,3664609560),new Word64(3624381080,2734883394),new Word64(310598401,1164996542),new Word64(607225278,1323610764),new Word64(1426881987,3590304994),new Word64(1925078388,4068182383),new Word64(2162078206,991336113),new Word64(2614888103,633803317),new Word64(3248222580,3479774868),new Word64(3835390401,2666613458),new Word64(4022224774,944711139),new Word64(264347078,2341262773),new Word64(604807628,2007800933),new Word64(770255983,1495990901),new Word64(1249150122,1856431235),new Word64(1555081692,3175218132),new Word64(1996064986,2198950837),new Word64(2554220882,3999719339),new Word64(2821834349,766784016),new Word64(2952996808,2566594879),new Word64(3210313671,3203337956),new Word64(3336571891,1034457026),new Word64(3584528711,2466948901),new Word64(113926993,3758326383),new Word64(338241895,168717936),new Word64(666307205,1188179964),new Word64(773529912,1546045734),new Word64(1294757372,1522805485),new Word64(1396182291,2643833823),new Word64(1695183700,2343527390),new Word64(1986661051,1014477480),new Word64(2177026350,1206759142),new Word64(2456956037,344077627),new Word64(2730485921,1290863460),new Word64(2820302411,3158454273),new Word64(3259730800,3505952657),new Word64(3345764771,106217008),new Word64(3516065817,3606008344),new Word64(3600352804,1432725776),new Word64(4094571909,1467031594),new Word64(275423344,851169720),new Word64(430227734,3100823752),new Word64(506948616,1363258195),new Word64(659060556,3750685593),new Word64(883997877,3785050280),new Word64(958139571,3318307427),new Word64(1322822218,3812723403),new Word64(1537002063,2003034995),new Word64(1747873779,3602036899),new Word64(1955562222,1575990012),new Word64(2024104815,1125592928),new Word64(2227730452,2716904306),new Word64(2361852424,442776044),new Word64(2428436474,593698344),new Word64(2756734187,3733110249),new Word64(3204031479,2999351573),new Word64(3329325298,3815920427),new Word64(3391569614,3928383900),new Word64(3515267271,566280711),new Word64(3940187606,3454069534),new Word64(4118630271,4000239992),new Word64(116418474,1914138554),new Word64(174292421,2731055270),new Word64(289380356,3203993006),new Word64(460393269,320620315),new Word64(685471733,587496836),new Word64(852142971,1086792851),new Word64(1017036298,365543100),new Word64(1126000580,2618297676),new Word64(1288033470,3409855158),new Word64(1501505948,4234509866),new Word64(1607167915,987167468),new Word64(1816402316,1246189591)];return function hash(t,i,a,s=!1){let r,n,g,o,c,C,h,l;if(s){r=new Word64(3418070365,3238371032);n=new Word64(1654270250,914150663);g=new Word64(2438529370,812702999);o=new Word64(355462360,4144912697);c=new Word64(1731405415,4290775857);C=new Word64(2394180231,1750603025);h=new Word64(3675008525,1694076839);l=new Word64(1203062813,3204075428)}else{r=new Word64(1779033703,4089235720);n=new Word64(3144134277,2227873595);g=new Word64(1013904242,4271175723);o=new Word64(2773480762,1595750129);c=new Word64(1359893119,2917565137);C=new Word64(2600822924,725511199);h=new Word64(528734635,4215389547);l=new Word64(1541459225,327033209)}const Q=128*Math.ceil((a+17)/128),E=new Uint8Array(Q);let u,d;for(u=0;u>>29&255;E[u++]=a>>21&255;E[u++]=a>>13&255;E[u++]=a>>5&255;E[u++]=a<<3&255;const p=new Array(80);for(u=0;u<80;u++)p[u]=new Word64(0,0);let m=new Word64(0,0),y=new Word64(0,0),w=new Word64(0,0),D=new Word64(0,0),b=new Word64(0,0),F=new Word64(0,0),S=new Word64(0,0),k=new Word64(0,0);const R=new Word64(0,0),N=new Word64(0,0),G=new Word64(0,0),M=new Word64(0,0);let U,x;for(u=0;u=1;--e){i=r[13];r[13]=r[9];r[9]=r[5];r[5]=r[1];r[1]=i;i=r[14];a=r[10];r[14]=r[6];r[10]=r[2];r[6]=i;r[2]=a;i=r[15];a=r[11];s=r[7];r[15]=r[3];r[11]=i;r[7]=a;r[3]=s;for(let e=0;e<16;++e)r[e]=this._inv_s[r[e]];for(let i=0,a=16*e;i<16;++i,++a)r[i]^=t[a];for(let e=0;e<16;e+=4){const t=this._mix[r[e]],a=this._mix[r[e+1]],s=this._mix[r[e+2]],n=this._mix[r[e+3]];i=t^a>>>8^a<<24^s>>>16^s<<16^n>>>24^n<<8;r[e]=i>>>24&255;r[e+1]=i>>16&255;r[e+2]=i>>8&255;r[e+3]=255&i}}i=r[13];r[13]=r[9];r[9]=r[5];r[5]=r[1];r[1]=i;i=r[14];a=r[10];r[14]=r[6];r[10]=r[2];r[6]=i;r[2]=a;i=r[15];a=r[11];s=r[7];r[15]=r[3];r[11]=i;r[7]=a;r[3]=s;for(let e=0;e<16;++e){r[e]=this._inv_s[r[e]];r[e]^=t[e]}return r}_encrypt(e,t){const i=this._s;let a,s,r;const n=new Uint8Array(16);n.set(e);for(let e=0;e<16;++e)n[e]^=t[e];for(let e=1;e=a;--i)if(e[i]!==t){t=0;break}g-=t;r[r.length-1]=e.subarray(0,16-t)}}const o=new Uint8Array(g);for(let e=0,t=0,i=r.length;e=256&&(g=255&(27^g))}for(let t=0;t<4;++t){i[e]=a^=i[e-32];e++;i[e]=s^=i[e-32];e++;i[e]=r^=i[e-32];e++;i[e]=n^=i[e-32];e++}}return i}}class PDF17{checkOwnerPassword(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);return isArrayEqual(Ag(s,0,s.length),a)}checkUserPassword(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);return isArrayEqual(Ag(a,0,a.length),i)}getOwnerKey(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);const r=Ag(s,0,s.length);return new AES256Cipher(r).decryptBlock(a,!1,new Uint8Array(16))}getUserKey(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);const s=Ag(a,0,a.length);return new AES256Cipher(s).decryptBlock(i,!1,new Uint8Array(16))}}class PDF20{_hash(e,t,i){let a=Ag(t,0,t.length).subarray(0,32),s=[0],r=0;for(;r<64||s.at(-1)>r-32;){const t=e.length+a.length+i.length,c=new Uint8Array(t);let C=0;c.set(e,C);C+=e.length;c.set(a,C);C+=a.length;c.set(i,C);const h=new Uint8Array(64*t);for(let e=0,i=0;e<64;e++,i+=t)h.set(c,i);s=new AES128Cipher(a.subarray(0,16)).encrypt(h,a.subarray(16,32));const l=s.slice(0,16).reduce(((e,t)=>e+t),0)%3;0===l?a=Ag(s,0,s.length):1===l?a=(n=s,g=0,o=s.length,eg(n,g,o,!0)):2===l&&(a=eg(s,0,s.length));r++}var n,g,o;return a.subarray(0,32)}checkOwnerPassword(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);return isArrayEqual(this._hash(e,s,i),a)}checkUserPassword(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);return isArrayEqual(this._hash(e,a,[]),i)}getOwnerKey(e,t,i,a){const s=new Uint8Array(e.length+56);s.set(e,0);s.set(t,e.length);s.set(i,e.length+t.length);const r=this._hash(e,s,i);return new AES256Cipher(r).decryptBlock(a,!1,new Uint8Array(16))}getUserKey(e,t,i){const a=new Uint8Array(e.length+8);a.set(e,0);a.set(t,e.length);const s=this._hash(e,a,[]);return new AES256Cipher(s).decryptBlock(i,!1,new Uint8Array(16))}}class CipherTransform{constructor(e,t){this.StringCipherConstructor=e;this.StreamCipherConstructor=t}createStream(e,t){const i=new this.StreamCipherConstructor;return new DecryptStream(e,t,(function cipherTransformDecryptStream(e,t){return i.decryptBlock(e,t)}))}decryptString(e){const t=new this.StringCipherConstructor;let i=stringToBytes(e);i=t.decryptBlock(i,!0);return bytesToString(i)}encryptString(e){const t=new this.StringCipherConstructor;if(t instanceof AESBaseCipher){const i=16-e.length%16;e+=String.fromCharCode(i).repeat(i);const a=new Uint8Array(16);if("undefined"!=typeof crypto)crypto.getRandomValues(a);else for(let e=0;e<16;e++)a[e]=Math.floor(256*Math.random());let s=stringToBytes(e);s=t.encrypt(s,a);const r=new Uint8Array(16+s.length);r.set(a);r.set(s,16);return bytesToString(r)}let i=stringToBytes(e);i=t.encrypt(i);return bytesToString(i)}}class CipherTransformFactory{static#q=new Uint8Array([40,191,78,94,78,117,138,65,100,0,78,86,255,250,1,8,46,46,0,182,208,104,62,128,47,12,169,254,100,83,105,122]);#O(e,t,i,a,s,r,n,g,o,c,C,h){if(t){const e=Math.min(127,t.length);t=t.subarray(0,e)}else t=[];const l=6===e?new PDF20:new PDF17;return l.checkUserPassword(t,g,n)?l.getUserKey(t,o,C):t.length&&l.checkOwnerPassword(t,a,r,i)?l.getOwnerKey(t,s,r,c):null}#P(e,t,i,a,s,r,n,g){const o=40+i.length+e.length,c=new Uint8Array(o);let C,h,l=0;if(t){h=Math.min(32,t.length);for(;l>8&255;c[l++]=s>>16&255;c[l++]=s>>>24&255;for(C=0,h=e.length;C=4&&!g){c[l++]=255;c[l++]=255;c[l++]=255;c[l++]=255}let Q=$n(c,0,l);const E=n>>3;if(r>=3)for(C=0;C<50;++C)Q=$n(Q,0,E);const u=Q.subarray(0,E);let d,f;if(r>=3){for(l=0;l<32;++l)c[l]=CipherTransformFactory.#q[l];for(C=0,h=e.length;C>3;if(i>=3)for(g=0;g<50;++g)o=$n(o,0,o.length);let C,h;if(i>=3){h=t;const e=new Uint8Array(c);for(g=19;g>=0;g--){for(let t=0;t>8&255;s[n++]=e>>16&255;s[n++]=255&t;s[n++]=t>>8&255;if(a){s[n++]=115;s[n++]=65;s[n++]=108;s[n++]=84}return $n(s,0,n).subarray(0,Math.min(i.length+5,16))}#X(e,t,i,a,s){if(!(t instanceof Name))throw new FormatError("Invalid crypt filter name.");const r=this,n=e.get(t.name),g=n?.get("CFM");if(!g||"None"===g.name)return function(){return new NullCipher};if("V2"===g.name)return function(){return new ARCFourCipher(r.#j(i,a,s,!1))};if("AESV2"===g.name)return function(){return new AES128Cipher(r.#j(i,a,s,!0))};if("AESV3"===g.name)return function(){return new AES256Cipher(s)};throw new FormatError("Unknown crypto method")}constructor(e,t,i){const a=e.get("Filter");if(!isName(a,"Standard"))throw new FormatError("unknown encryption method");this.filterName=a.name;this.dict=e;const s=e.get("V");if(!Number.isInteger(s)||1!==s&&2!==s&&4!==s&&5!==s)throw new FormatError("unsupported encryption algorithm");this.algorithm=s;let r=e.get("Length");if(!r)if(s<=3)r=40;else{const t=e.get("CF"),i=e.get("StmF");if(t instanceof Dict&&i instanceof Name){t.suppressEncryption=!0;const e=t.get(i.name);r=e?.get("Length")||128;r<40&&(r<<=3)}}if(!Number.isInteger(r)||r<40||r%8!=0)throw new FormatError("invalid key length");const n=stringToBytes(e.get("O")),g=stringToBytes(e.get("U")),o=n.subarray(0,32),c=g.subarray(0,32),C=e.get("P"),h=e.get("R"),l=(4===s||5===s)&&!1!==e.get("EncryptMetadata");this.encryptMetadata=l;const Q=stringToBytes(t);let E,u;if(i){if(6===h)try{i=utf8StringToString(i)}catch{warn("CipherTransformFactory: Unable to convert UTF8 encoded password.")}E=stringToBytes(i)}if(5!==s)u=this.#P(Q,E,o,c,C,h,r,l);else{const t=n.subarray(32,40),i=n.subarray(40,48),a=g.subarray(0,48),s=g.subarray(32,40),r=g.subarray(40,48),C=stringToBytes(e.get("OE")),l=stringToBytes(e.get("UE")),Q=stringToBytes(e.get("Perms"));u=this.#O(h,E,o,t,i,a,c,s,r,C,l,Q)}if(!u&&!i)throw new PasswordException("No password given",rt);if(!u&&i){const e=this.#W(E,o,h,r);u=this.#P(Q,e,o,c,C,h,r,l)}if(!u)throw new PasswordException("Incorrect Password",nt);this.encryptionKey=u;if(s>=4){const t=e.get("CF");t instanceof Dict&&(t.suppressEncryption=!0);this.cf=t;this.stmf=e.get("StmF")||Name.get("Identity");this.strf=e.get("StrF")||Name.get("Identity");this.eff=e.get("EFF")||this.stmf}}createCipherTransform(e,t){if(4===this.algorithm||5===this.algorithm)return new CipherTransform(this.#X(this.cf,this.strf,e,t,this.encryptionKey),this.#X(this.cf,this.stmf,e,t,this.encryptionKey));const i=this.#j(e,t,this.encryptionKey,!1),cipherConstructor=function(){return new ARCFourCipher(i)};return new CipherTransform(cipherConstructor,cipherConstructor)}}function decodeString(e){try{return stringToUTF8String(e)}catch(t){warn(`UTF-8 decoding failed: "${t}".`);return e}}class DatasetXMLParser extends SimpleXMLParser{constructor(e){super(e);this.node=null}onEndElement(e){const t=super.onEndElement(e);if(t&&"xfa:datasets"===e){this.node=t;throw new Error("Aborting DatasetXMLParser.")}}}class DatasetReader{constructor(e){if(e.datasets)this.node=new SimpleXMLParser({hasAttributes:!0}).parseFromString(e.datasets).documentElement;else{const t=new DatasetXMLParser({hasAttributes:!0});try{t.parseFromString(e["xdp:xdp"])}catch{}this.node=t.node}}getValue(e){if(!this.node||!e)return"";const t=this.node.searchNode(parseXFAPath(e),0);if(!t)return"";const i=t.firstChild;return"value"===i?.nodeName?t.children.map((e=>decodeString(e.textContent))):decodeString(t.textContent)}}class XRef{#Z=null;constructor(e,t){this.stream=e;this.pdfManager=t;this.entries=[];this._xrefStms=new Set;this._cacheMap=new Map;this._pendingRefs=new RefSet;this._newPersistentRefNum=null;this._newTemporaryRefNum=null;this._persistentRefsCache=null}getNewPersistentRef(e){null===this._newPersistentRefNum&&(this._newPersistentRefNum=this.entries.length||1);const t=this._newPersistentRefNum++;this._cacheMap.set(t,e);return Ref.get(t,0)}getNewTemporaryRef(){if(null===this._newTemporaryRefNum){this._newTemporaryRefNum=this.entries.length||1;if(this._newPersistentRefNum){this._persistentRefsCache=new Map;for(let e=this._newTemporaryRefNum;e0;){const[n,g]=r;if(!Number.isInteger(n)||!Number.isInteger(g))throw new FormatError(`Invalid XRef range fields: ${n}, ${g}`);if(!Number.isInteger(i)||!Number.isInteger(a)||!Number.isInteger(s))throw new FormatError(`Invalid XRef entry fields length: ${n}, ${g}`);for(let r=t.entryNum;r=e.length);){i+=String.fromCharCode(a);a=e[t]}return i}function skipUntil(e,t,i){const a=i.length,s=e.length;let r=0;for(;t=a)break;t++;r++}return r}const e=/\b(endobj|\d+\s+\d+\s+obj|xref|trailer\s*<<)\b/g,t=/\b(startxref|\d+\s+\d+\s+obj)\b/g,i=/^(\d+)\s+(\d+)\s+obj\b/,a=new Uint8Array([116,114,97,105,108,101,114]),s=new Uint8Array([115,116,97,114,116,120,114,101,102]),r=new Uint8Array([47,88,82,101,102]);this.entries.length=0;this._cacheMap.clear();const n=this.stream;n.pos=0;const g=n.getBytes(),o=bytesToString(g),c=g.length;let C=n.start;const h=[],l=[];for(;C=c)break;Q=g[C]}while(10!==Q&&13!==Q);continue}const E=readToken(g,C);let u;if(E.startsWith("xref")&&(4===E.length||/\s/.test(E[4]))){C+=skipUntil(g,C,a);h.push(C);C+=skipUntil(g,C,s)}else if(u=i.exec(E)){const t=0|u[1],i=0|u[2],a=C+E.length;let s,h=!1;if(this.entries[t]){if(this.entries[t].gen===i)try{new Parser({lexer:new Lexer(n.makeSubStream(a))}).getObj();h=!0}catch(e){e instanceof ParserEOFException?warn(`indexObjects -- checking object (${E}): "${e}".`):h=!0}}else h=!0;h&&(this.entries[t]={offset:C-n.start,gen:i,uncompressed:!0});e.lastIndex=a;const Q=e.exec(o);if(Q){s=e.lastIndex+1-C;if("endobj"!==Q[1]){warn(`indexObjects: Found "${Q[1]}" inside of another "obj", caused by missing "endobj" -- trying to recover.`);s-=Q[1].length+1}}else s=c-C;const d=g.subarray(C,C+s),f=skipUntil(d,0,r);if(f0?Math.max(...this._xrefStms):null)}getEntry(e){const t=this.entries[e];return t&&!t.free&&t.offset?t:null}fetchIfRef(e,t=!1){return e instanceof Ref?this.fetch(e,t):e}fetch(e,t=!1){if(!(e instanceof Ref))throw new Error("ref object is not a reference");const i=e.num,a=this._cacheMap.get(i);if(void 0!==a){a instanceof Dict&&!a.objId&&(a.objId=e.toString());return a}let s=this.getEntry(i);if(null===s){this._cacheMap.set(i,s);return s}if(this._pendingRefs.has(e)){this._pendingRefs.remove(e);warn(`Ignoring circular reference: ${e}.`);return lt}this._pendingRefs.put(e);try{s=s.uncompressed?this.fetchUncompressed(e,s,t):this.fetchCompressed(e,s,t);this._pendingRefs.remove(e)}catch(t){this._pendingRefs.remove(e);throw t}s instanceof Dict?s.objId=e.toString():s instanceof BaseStream&&(s.dict.objId=e.toString());return s}fetchUncompressed(e,t,i=!1){const a=e.gen;let s=e.num;if(t.gen!==a){const r=`Inconsistent generation in XRef: ${e}`;if(this._generationFallback&&t.gen0&&t[3]-t[1]>0)return t;warn(`Empty, or invalid, /${e} entry.`)}return null}get mediaBox(){return shadow(this,"mediaBox",this._getBoundingBox("MediaBox")||tg)}get cropBox(){return shadow(this,"cropBox",this._getBoundingBox("CropBox")||this.mediaBox)}get userUnit(){const e=this.pageDict.get("UserUnit");return shadow(this,"userUnit","number"==typeof e&&e>0?e:1)}get view(){const{cropBox:e,mediaBox:t}=this;if(e!==t&&!isArrayEqual(e,t)){const i=Util.intersect(e,t);if(i&&i[2]-i[0]>0&&i[3]-i[1]>0)return shadow(this,"view",i);warn("Empty /CropBox and /MediaBox intersection.")}return shadow(this,"view",t)}get rotate(){let e=this._getInheritableProperty("Rotate")||0;e%90!=0?e=0:e>=360?e%=360:e<0&&(e=(e%360+360)%360);return shadow(this,"rotate",e)}_onSubStreamError(e,t){if(!this.evaluatorOptions.ignoreErrors)throw e;warn(`getContentStream - ignoring sub-stream (${t}): "${e}".`)}getContentStream(){return this.pdfManager.ensure(this,"content").then((e=>e instanceof BaseStream?e:Array.isArray(e)?new StreamsSequenceStream(e,this._onSubStreamError.bind(this)):new NullStream))}get xfaData(){return shadow(this,"xfaData",this.xfaFactory?{bbox:this.xfaFactory.getBoundingBox(this.pageIndex)}:null)}async#V(e,t,i){const a=[];for(const s of e)if(s.id){const e=Ref.fromString(s.id);if(!e){warn(`A non-linked annotation cannot be modified: ${s.id}`);continue}if(s.deleted){t.put(e,e);if(s.popupRef){const e=Ref.fromString(s.popupRef);e&&t.put(e,e)}continue}i?.put(e);s.ref=e;a.push(this.xref.fetchAsync(e).then((e=>{e instanceof Dict&&(s.oldAnnotation=e.clone())}),(()=>{warn(`Cannot fetch \`oldAnnotation\` for: ${e}.`)})));delete s.id}await Promise.all(a)}async saveNewAnnotations(e,t,i,a,s){if(this.xfaFactory)throw new Error("XFA: Cannot save new annotations.");const r=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions}),n=new RefSetCache,g=new RefSet;await this.#V(i,n,g);const o=this.pageDict,c=this.annotations.filter((e=>!(e instanceof Ref&&n.has(e)))),C=await AnnotationFactory.saveNewAnnotations(r,t,i,a,s);for(const{ref:e}of C.annotations)e instanceof Ref&&!g.has(e)&&c.push(e);const h=o.clone();h.set("Annots",c);s.put(this.ref,{data:h});for(const e of n)s.put(e,{data:null})}save(e,t,i,a){const s=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions});return this._parsedAnnotations.then((function(e){const r=[];for(const n of e)r.push(n.save(s,t,i,a).catch((function(e){warn(`save - ignoring annotation data during "${t.name}" task: "${e}".`);return null})));return Promise.all(r)}))}loadResources(e){this.resourcesPromise||=this.pdfManager.ensure(this,"resources");return this.resourcesPromise.then((()=>new ObjectLoader(this.resources,e,this.xref).load()))}getOperatorList({handler:e,sink:t,task:i,intent:a,cacheKey:s,annotationStorage:r=null,modifiedIds:n=null}){const C=this.getContentStream(),E=this.loadResources(["ColorSpace","ExtGState","Font","Pattern","Properties","Shading","XObject"]),d=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions}),f=this.xfaFactory?null:getNewAnnotationsMap(r),p=f?.get(this.pageIndex);let m=Promise.resolve(null),y=null;if(p){const e=this.pdfManager.ensureDoc("annotationGlobals");let t;const a=new Set;for(const{bitmapId:e,bitmap:t}of p)!e||t||a.has(e)||a.add(e);const{isOffscreenCanvasSupported:s}=this.evaluatorOptions;if(a.size>0){const e=p.slice();for(const[t,i]of r)t.startsWith(u)&&i.bitmap&&a.has(i.bitmapId)&&e.push(i);t=AnnotationFactory.generateImages(e,this.xref,s)}else t=AnnotationFactory.generateImages(p,this.xref,s);y=new RefSet;m=Promise.all([e,this.#V(p,y,null)]).then((([e])=>e?AnnotationFactory.printNewAnnotations(e,d,i,p,t):null))}const w=Promise.all([C,E]).then((([r])=>{const n=new OperatorList(a,t);e.send("StartRenderPage",{transparency:d.hasBlendModes(this.resources,this.nonBlendModesSet),pageIndex:this.pageIndex,cacheKey:s});return d.getOperatorList({stream:r,task:i,resources:this.resources,operatorList:n}).then((function(){return n}))}));return Promise.all([w,this._parsedAnnotations,m]).then((function([e,t,s]){if(s){t=t.filter((e=>!(e.ref&&y.has(e.ref))));for(let e=0,i=s.length;ee.ref&&isRefsEqual(e.ref,a.refToReplace)));if(r>=0){t.splice(r,1,a);s.splice(e--,1);i--}}}t=t.concat(s)}if(0===t.length||a&l){e.flush(!0);return{length:e.totalLength}}const C=!!(a&h),E=!!(a&Q),u=!!(a&g),f=!!(a&o),p=!!(a&c),m=[];for(const e of t)(u||f&&e.mustBeViewed(r,C)&&e.mustBeViewedWhenEditing(E,n)||p&&e.mustBePrinted(r))&&m.push(e.getOperatorList(d,i,a,r).catch((function(e){warn(`getOperatorList - ignoring annotation data during "${i.name}" task: "${e}".`);return{opList:null,separateForm:!1,separateCanvas:!1}})));return Promise.all(m).then((function(t){let i=!1,a=!1;for(const{opList:s,separateForm:r,separateCanvas:n}of t){e.addOpList(s);i||=r;a||=n}e.flush(!0,{form:i,canvas:a});return{length:e.totalLength}}))}))}async extractTextContent({handler:e,task:t,includeMarkedContent:i,disableNormalization:a,sink:s}){const r=this.getContentStream(),n=this.loadResources(["ExtGState","Font","Properties","XObject"]),g=this.pdfManager.ensureCatalog("lang"),[o,,c]=await Promise.all([r,n,g]);return new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions}).getTextContent({stream:o,task:t,resources:this.resources,includeMarkedContent:i,disableNormalization:a,sink:s,viewBox:this.view,lang:c})}async getStructTree(){const e=await this.pdfManager.ensureCatalog("structTreeRoot");if(!e)return null;await this._parsedAnnotations;const t=await this.pdfManager.ensure(this,"_parseStructTree",[e]);return this.pdfManager.ensure(t,"serializable")}_parseStructTree(e){const t=new StructTreePage(e,this.pageDict);t.parse(this.ref);return t}async getAnnotationsData(e,t,i){const a=await this._parsedAnnotations;if(0===a.length)return a;const s=[],r=[];let n;const C=!!(i&g),h=!!(i&o),l=!!(i&c);for(const i of a){const a=C||h&&i.viewable;(a||l&&i.printable)&&s.push(i.data);if(i.hasTextContent&&a){n||=new PartialEvaluator({xref:this.xref,handler:e,pageIndex:this.pageIndex,idFactory:this._localIdFactory,fontCache:this.fontCache,builtInCMapCache:this.builtInCMapCache,standardFontDataCache:this.standardFontDataCache,globalImageCache:this.globalImageCache,systemFontCache:this.systemFontCache,options:this.evaluatorOptions});r.push(i.extractTextContent(n,t,[-1/0,-1/0,1/0,1/0]).catch((function(e){warn(`getAnnotationsData - ignoring textContent during "${t.name}" task: "${e}".`)})))}}await Promise.all(r);return s}get annotations(){const e=this._getInheritableProperty("Annots");return shadow(this,"annotations",Array.isArray(e)?e:[])}get _parsedAnnotations(){return shadow(this,"_parsedAnnotations",this.pdfManager.ensure(this,"annotations").then((async e=>{if(0===e.length)return e;const[t,i]=await Promise.all([this.pdfManager.ensureDoc("annotationGlobals"),this.pdfManager.ensureDoc("fieldObjects")]);if(!t)return[];const a=i?.orphanFields,s=[];for(const i of e)s.push(AnnotationFactory.create(this.xref,i,t,this._localIdFactory,!1,a,this.ref).catch((function(e){warn(`_parsedAnnotations: "${e}".`);return null})));const r=[];let n,g;for(const e of await Promise.all(s))e&&(e instanceof WidgetAnnotation?(g||=[]).push(e):e instanceof PopupAnnotation?(n||=[]).push(e):r.push(e));g&&r.push(...g);n&&r.push(...n);return r})))}get jsActions(){return shadow(this,"jsActions",collectActions(this.xref,this.pageDict,pA))}}const ig=new Uint8Array([37,80,68,70,45]),ag=new Uint8Array([115,116,97,114,116,120,114,101,102]),sg=new Uint8Array([101,110,100,111,98,106]);function find(e,t,i=1024,a=!1){const s=t.length,r=e.peekBytes(i),n=r.length-s;if(n<=0)return!1;if(a){const i=s-1;let a=r.length-1;for(;a>=i;){let n=0;for(;n=s){e.pos+=a-i;return!0}a--}}else{let i=0;for(;i<=n;){let a=0;for(;a=s){e.pos+=i;return!0}i++}}return!1}class PDFDocument{constructor(e,t){if(t.length<=0)throw new InvalidPDFException("The PDF file is empty, i.e. its size is zero bytes.");this.pdfManager=e;this.stream=t;this.xref=new XRef(t,e);this._pagePromises=new Map;this._version=null;const i={font:0};this._globalIdFactory=class{static getDocId(){return`g_${e.docId}`}static createFontId(){return"f"+ ++i.font}static createObjId(){unreachable("Abstract method `createObjId` called.")}static getPageObjId(){unreachable("Abstract method `getPageObjId` called.")}}}parse(e){this.xref.parse(e);this.catalog=new Catalog(this.pdfManager,this.xref)}get linearization(){let e=null;try{e=Linearization.create(this.stream)}catch(e){if(e instanceof MissingDataException)throw e;info(e)}return shadow(this,"linearization",e)}get startXRef(){const e=this.stream;let t=0;if(this.linearization){e.reset();if(find(e,sg)){e.skip(6);let i=e.peekByte();for(;isWhiteSpace(i);){e.pos++;i=e.peekByte()}t=e.pos-e.start}}else{const i=1024,a=ag.length;let s=!1,r=e.end;for(;!s&&r>0;){r-=i-a;r<0&&(r=0);e.pos=r;s=find(e,ag,i,!0)}if(s){e.skip(9);let i;do{i=e.getByte()}while(isWhiteSpace(i));let a="";for(;i>=32&&i<=57;){a+=String.fromCharCode(i);i=e.getByte()}t=parseInt(a,10);isNaN(t)&&(t=0)}}return shadow(this,"startXRef",t)}checkHeader(){const e=this.stream;e.reset();if(!find(e,ig))return;e.moveStart();e.skip(ig.length);let t,i="";for(;(t=e.getByte())>32&&i.length<7;)i+=String.fromCharCode(t);ft.test(i)?this._version=i:warn(`Invalid PDF header version: ${i}`)}parseStartXRef(){this.xref.setStartXRef(this.startXRef)}get numPages(){let e=0;e=this.catalog.hasActualNumPages?this.catalog.numPages:this.xfaFactory?this.xfaFactory.getNumPages():this.linearization?this.linearization.numPages:this.catalog.numPages;return shadow(this,"numPages",e)}_hasOnlyDocumentSignatures(e,t=0){return!!Array.isArray(e)&&e.every((e=>{if(!((e=this.xref.fetchIfRef(e))instanceof Dict))return!1;if(e.has("Kids")){if(++t>10){warn("_hasOnlyDocumentSignatures: maximum recursion depth reached");return!1}return this._hasOnlyDocumentSignatures(e.get("Kids"),t)}const i=isName(e.get("FT"),"Sig"),a=e.get("Rect"),s=Array.isArray(a)&&a.every((e=>0===e));return i&&s}))}get _xfaStreams(){const e=this.catalog.acroForm;if(!e)return null;const t=e.get("XFA"),i={"xdp:xdp":"",template:"",datasets:"",config:"",connectionSet:"",localeSet:"",stylesheet:"","/xdp:xdp":""};if(t instanceof BaseStream&&!t.isEmpty){i["xdp:xdp"]=t;return i}if(!Array.isArray(t)||0===t.length)return null;for(let e=0,a=t.length;e0;e.hasFields=a;const s=t.get("XFA");e.hasXfa=Array.isArray(s)&&s.length>0||s instanceof BaseStream&&!s.isEmpty;const r=!!(1&t.get("SigFlags")),n=r&&this._hasOnlyDocumentSignatures(i);e.hasAcroForm=a&&!n;e.hasSignatures=r}catch(e){if(e instanceof MissingDataException)throw e;warn(`Cannot fetch form information: "${e}".`)}return shadow(this,"formInfo",e)}get documentInfo(){const e={PDFFormatVersion:this.version,Language:this.catalog.lang,EncryptFilterName:this.xref.encrypt?this.xref.encrypt.filterName:null,IsLinearized:!!this.linearization,IsAcroFormPresent:this.formInfo.hasAcroForm,IsXFAPresent:this.formInfo.hasXfa,IsCollectionPresent:!!this.catalog.collection,IsSignaturesPresent:this.formInfo.hasSignatures};let t;try{t=this.xref.trailer.get("Info")}catch(e){if(e instanceof MissingDataException)throw e;info("The document information dictionary is invalid.")}if(!(t instanceof Dict))return shadow(this,"documentInfo",e);for(const i of t.getKeys()){const a=t.get(i);switch(i){case"Title":case"Author":case"Subject":case"Keywords":case"Creator":case"Producer":case"CreationDate":case"ModDate":if("string"==typeof a){e[i]=stringToPDFString(a);continue}break;case"Trapped":if(a instanceof Name){e[i]=a;continue}break;default:let t;switch(typeof a){case"string":t=stringToPDFString(a);break;case"number":case"boolean":t=a;break;default:a instanceof Name&&(t=a)}if(void 0===t){warn(`Bad value, for custom key "${i}", in Info: ${a}.`);continue}e.Custom||(e.Custom=Object.create(null));e.Custom[i]=t;continue}warn(`Bad value, for key "${i}", in Info: ${a}.`)}return shadow(this,"documentInfo",e)}get fingerprints(){const e="\0".repeat(16);function validate(t){return"string"==typeof t&&16===t.length&&t!==e}const t=this.xref.trailer.get("ID");let i,a;if(Array.isArray(t)&&validate(t[0])){i=stringToBytes(t[0]);t[1]!==t[0]&&validate(t[1])&&(a=stringToBytes(t[1]))}else i=$n(this.stream.getByteRange(0,1024),0,1024);return shadow(this,"fingerprints",[toHexUtil(i),a?toHexUtil(a):null])}async _getLinearizationPage(e){const{catalog:t,linearization:i,xref:a}=this,s=Ref.get(i.objectNumberFirst,0);try{const e=await a.fetchAsync(s);if(e instanceof Dict){let i=e.getRaw("Type");i instanceof Ref&&(i=await a.fetchAsync(i));if(isName(i,"Page")||!e.has("Type")&&!e.has("Kids")&&e.has("Contents")){t.pageKidsCountCache.has(s)||t.pageKidsCountCache.put(s,1);t.pageIndexCache.has(s)||t.pageIndexCache.put(s,0);return[e,s]}}throw new FormatError("The Linearization dictionary doesn't point to a valid Page dictionary.")}catch(i){warn(`_getLinearizationPage: "${i.message}".`);return t.getPageDict(e)}}getPage(e){const t=this._pagePromises.get(e);if(t)return t;const{catalog:i,linearization:a,xfaFactory:s}=this;let r;r=s?Promise.resolve([Dict.empty,null]):a?.pageFirst===e?this._getLinearizationPage(e):i.getPageDict(e);r=r.then((([t,a])=>new Page({pdfManager:this.pdfManager,xref:this.xref,pageIndex:e,pageDict:t,ref:a,globalIdFactory:this._globalIdFactory,fontCache:i.fontCache,builtInCMapCache:i.builtInCMapCache,standardFontDataCache:i.standardFontDataCache,globalImageCache:i.globalImageCache,systemFontCache:i.systemFontCache,nonBlendModesSet:i.nonBlendModesSet,xfaFactory:s})));this._pagePromises.set(e,r);return r}async checkFirstPage(e=!1){if(!e)try{await this.getPage(0)}catch(e){if(e instanceof XRefEntryException){this._pagePromises.delete(0);await this.cleanup();throw new XRefParseException}}}async checkLastPage(e=!1){const{catalog:t,pdfManager:i}=this;t.setActualNumPages();let a;try{await Promise.all([i.ensureDoc("xfaFactory"),i.ensureDoc("linearization"),i.ensureCatalog("numPages")]);if(this.xfaFactory)return;a=this.linearization?this.linearization.numPages:t.numPages;if(!Number.isInteger(a))throw new FormatError("Page count is not an integer.");if(a<=1)return;await this.getPage(a-1)}catch(s){this._pagePromises.delete(a-1);await this.cleanup();if(s instanceof XRefEntryException&&!e)throw new XRefParseException;warn(`checkLastPage - invalid /Pages tree /Count: ${a}.`);let r;try{r=await t.getAllPageDicts(e)}catch(i){if(i instanceof XRefEntryException&&!e)throw new XRefParseException;t.setActualNumPages(1);return}for(const[e,[a,s]]of r){let r;if(a instanceof Error){r=Promise.reject(a);r.catch((()=>{}))}else r=Promise.resolve(new Page({pdfManager:i,xref:this.xref,pageIndex:e,pageDict:a,ref:s,globalIdFactory:this._globalIdFactory,fontCache:t.fontCache,builtInCMapCache:t.builtInCMapCache,standardFontDataCache:t.standardFontDataCache,globalImageCache:t.globalImageCache,systemFontCache:t.systemFontCache,nonBlendModesSet:t.nonBlendModesSet,xfaFactory:null}));this._pagePromises.set(e,r)}t.setActualNumPages(r.size)}}fontFallback(e,t){return this.catalog.fontFallback(e,t)}async cleanup(e=!1){return this.catalog?this.catalog.cleanup(e):clearGlobalCaches()}async#z(e,t,i,a,s,r,n){const{xref:g}=this;if(!(i instanceof Ref)||r.has(i))return;r.put(i);const o=await g.fetchAsync(i);if(!(o instanceof Dict))return;if(o.has("T")){const t=stringToPDFString(await o.getAsync("T"));e=""===e?t:`${e}.${t}`}else{let i=o;for(;;){i=i.getRaw("Parent")||t;if(i instanceof Ref){if(r.has(i))break;i=await g.fetchAsync(i)}if(!(i instanceof Dict))break;if(i.has("T")){const t=stringToPDFString(await i.getAsync("T"));e=""===e?t:`${e}.${t}`;break}}}t&&!o.has("Parent")&&isName(o.get("Subtype"),"Widget")&&n.put(i,t);a.has(e)||a.set(e,[]);a.get(e).push(AnnotationFactory.create(g,i,s,null,!0,n,null).then((e=>e?.getFieldObject())).catch((function(e){warn(`#collectFieldObjects: "${e}".`);return null})));if(!o.has("Kids"))return;const c=await o.getAsync("Kids");if(Array.isArray(c))for(const t of c)await this.#z(e,i,t,a,s,r,n)}get fieldObjects(){return shadow(this,"fieldObjects",this.pdfManager.ensureDoc("formInfo").then((async e=>{if(!e.hasFields)return null;const[t,i]=await Promise.all([this.pdfManager.ensureDoc("annotationGlobals"),this.pdfManager.ensureCatalog("acroForm")]);if(!t)return null;const a=new RefSet,s=Object.create(null),r=new Map,n=new RefSetCache;for(const e of await i.getAsync("Fields"))await this.#z("",null,e,r,t,a,n);const g=[];for(const[e,t]of r)g.push(Promise.all(t).then((t=>{(t=t.filter((e=>!!e))).length>0&&(s[e]=t)})));await Promise.all(g);return{allFields:s,orphanFields:n}})))}get hasJSActions(){return shadow(this,"hasJSActions",this.pdfManager.ensureDoc("_parseHasJSActions"))}async _parseHasJSActions(){const[e,t]=await Promise.all([this.pdfManager.ensureCatalog("jsActions"),this.pdfManager.ensureDoc("fieldObjects")]);return!!e||!!t&&Object.values(t.allFields).some((e=>e.some((e=>null!==e.actions))))}get calculationOrderIds(){const e=this.catalog.acroForm?.get("CO");if(!Array.isArray(e)||0===e.length)return shadow(this,"calculationOrderIds",null);const t=[];for(const i of e)i instanceof Ref&&t.push(i.toString());return shadow(this,"calculationOrderIds",t.length?t:null)}get annotationGlobals(){return shadow(this,"annotationGlobals",AnnotationFactory.createGlobals(this.pdfManager))}}class BasePdfManager{constructor(e){this._docBaseUrl=function parseDocBaseUrl(e){if(e){const t=createValidAbsoluteUrl(e);if(t)return t.href;warn(`Invalid absolute docBaseUrl: "${e}".`)}return null}(e.docBaseUrl);this._docId=e.docId;this._password=e.password;this.enableXfa=e.enableXfa;e.evaluatorOptions.isOffscreenCanvasSupported&&=FeatureTest.isOffscreenCanvasSupported;e.evaluatorOptions.isImageDecoderSupported&&=FeatureTest.isImageDecoderSupported;this.evaluatorOptions=Object.freeze(e.evaluatorOptions)}get docId(){return this._docId}get password(){return this._password}get docBaseUrl(){return this._docBaseUrl}get catalog(){return this.pdfDocument.catalog}ensureDoc(e,t){return this.ensure(this.pdfDocument,e,t)}ensureXRef(e,t){return this.ensure(this.pdfDocument.xref,e,t)}ensureCatalog(e,t){return this.ensure(this.pdfDocument.catalog,e,t)}getPage(e){return this.pdfDocument.getPage(e)}fontFallback(e,t){return this.pdfDocument.fontFallback(e,t)}loadXfaFonts(e,t){return this.pdfDocument.loadXfaFonts(e,t)}loadXfaImages(){return this.pdfDocument.loadXfaImages()}serializeXfaData(e){return this.pdfDocument.serializeXfaData(e)}cleanup(e=!1){return this.pdfDocument.cleanup(e)}async ensure(e,t,i){unreachable("Abstract method `ensure` called")}requestRange(e,t){unreachable("Abstract method `requestRange` called")}requestLoadedStream(e=!1){unreachable("Abstract method `requestLoadedStream` called")}sendProgressiveData(e){unreachable("Abstract method `sendProgressiveData` called")}updatePassword(e){this._password=e}terminate(e){unreachable("Abstract method `terminate` called")}}class LocalPdfManager extends BasePdfManager{constructor(e){super(e);const t=new Stream(e.source);this.pdfDocument=new PDFDocument(this,t);this._loadedStreamPromise=Promise.resolve(t)}async ensure(e,t,i){const a=e[t];return"function"==typeof a?a.apply(e,i):a}requestRange(e,t){return Promise.resolve()}requestLoadedStream(e=!1){return this._loadedStreamPromise}terminate(e){}}class NetworkPdfManager extends BasePdfManager{constructor(e){super(e);this.streamManager=new ChunkedStreamManager(e.source,{msgHandler:e.handler,length:e.length,disableAutoFetch:e.disableAutoFetch,rangeChunkSize:e.rangeChunkSize});this.pdfDocument=new PDFDocument(this,this.streamManager.getStream())}async ensure(e,t,i){try{const a=e[t];return"function"==typeof a?a.apply(e,i):a}catch(a){if(!(a instanceof MissingDataException))throw a;await this.requestRange(a.begin,a.end);return this.ensure(e,t,i)}}requestRange(e,t){return this.streamManager.requestRange(e,t)}requestLoadedStream(e=!1){return this.streamManager.requestAllChunks(e)}sendProgressiveData(e){this.streamManager.onReceiveData({chunk:e})}terminate(e){this.streamManager.abort(e)}}const rg=1,ng=2,gg=1,og=2,Ig=3,cg=4,Cg=5,hg=6,lg=7,Bg=8;function onFn(){}function wrapReason(e){if(e instanceof AbortException||e instanceof InvalidPDFException||e instanceof MissingPDFException||e instanceof PasswordException||e instanceof UnexpectedResponseException||e instanceof UnknownErrorException)return e;e instanceof Error||"object"==typeof e&&null!==e||unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');switch(e.name){case"AbortException":return new AbortException(e.message);case"InvalidPDFException":return new InvalidPDFException(e.message);case"MissingPDFException":return new MissingPDFException(e.message);case"PasswordException":return new PasswordException(e.message,e.code);case"UnexpectedResponseException":return new UnexpectedResponseException(e.message,e.status);case"UnknownErrorException":return new UnknownErrorException(e.message,e.details)}return new UnknownErrorException(e.message,e.toString())}class MessageHandler{#_=new AbortController;constructor(e,t,i){this.sourceName=e;this.targetName=t;this.comObj=i;this.callbackId=1;this.streamId=1;this.streamSinks=Object.create(null);this.streamControllers=Object.create(null);this.callbackCapabilities=Object.create(null);this.actionHandler=Object.create(null);i.addEventListener("message",this.#$.bind(this),{signal:this.#_.signal})}#$({data:e}){if(e.targetName!==this.sourceName)return;if(e.stream){this.#AA(e);return}if(e.callback){const t=e.callbackId,i=this.callbackCapabilities[t];if(!i)throw new Error(`Cannot resolve callback ${t}`);delete this.callbackCapabilities[t];if(e.callback===rg)i.resolve(e.data);else{if(e.callback!==ng)throw new Error("Unexpected callback case");i.reject(wrapReason(e.reason))}return}const t=this.actionHandler[e.action];if(!t)throw new Error(`Unknown action from worker: ${e.action}`);if(e.callbackId){const i=this.sourceName,a=e.sourceName,s=this.comObj;Promise.try(t,e.data).then((function(t){s.postMessage({sourceName:i,targetName:a,callback:rg,callbackId:e.callbackId,data:t})}),(function(t){s.postMessage({sourceName:i,targetName:a,callback:ng,callbackId:e.callbackId,reason:wrapReason(t)})}))}else e.streamId?this.#eA(e):t(e.data)}on(e,t){const i=this.actionHandler;if(i[e])throw new Error(`There is already an actionName called "${e}"`);i[e]=t}send(e,t,i){this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:e,data:t},i)}sendWithPromise(e,t,i){const a=this.callbackId++,s=Promise.withResolvers();this.callbackCapabilities[a]=s;try{this.comObj.postMessage({sourceName:this.sourceName,targetName:this.targetName,action:e,callbackId:a,data:t},i)}catch(e){s.reject(e)}return s.promise}sendWithStream(e,t,i,a){const s=this.streamId++,r=this.sourceName,n=this.targetName,g=this.comObj;return new ReadableStream({start:i=>{const o=Promise.withResolvers();this.streamControllers[s]={controller:i,startCall:o,pullCall:null,cancelCall:null,isClosed:!1};g.postMessage({sourceName:r,targetName:n,action:e,streamId:s,data:t,desiredSize:i.desiredSize},a);return o.promise},pull:e=>{const t=Promise.withResolvers();this.streamControllers[s].pullCall=t;g.postMessage({sourceName:r,targetName:n,stream:hg,streamId:s,desiredSize:e.desiredSize});return t.promise},cancel:e=>{assert(e instanceof Error,"cancel must have a valid reason");const t=Promise.withResolvers();this.streamControllers[s].cancelCall=t;this.streamControllers[s].isClosed=!0;g.postMessage({sourceName:r,targetName:n,stream:gg,streamId:s,reason:wrapReason(e)});return t.promise}},i)}#eA(e){const t=e.streamId,i=this.sourceName,a=e.sourceName,s=this.comObj,r=this,n=this.actionHandler[e.action],g={enqueue(e,r=1,n){if(this.isCancelled)return;const g=this.desiredSize;this.desiredSize-=r;if(g>0&&this.desiredSize<=0){this.sinkCapability=Promise.withResolvers();this.ready=this.sinkCapability.promise}s.postMessage({sourceName:i,targetName:a,stream:cg,streamId:t,chunk:e},n)},close(){if(!this.isCancelled){this.isCancelled=!0;s.postMessage({sourceName:i,targetName:a,stream:Ig,streamId:t});delete r.streamSinks[t]}},error(e){assert(e instanceof Error,"error must have a valid reason");if(!this.isCancelled){this.isCancelled=!0;s.postMessage({sourceName:i,targetName:a,stream:Cg,streamId:t,reason:wrapReason(e)})}},sinkCapability:Promise.withResolvers(),onPull:null,onCancel:null,isCancelled:!1,desiredSize:e.desiredSize,ready:null};g.sinkCapability.resolve();g.ready=g.sinkCapability.promise;this.streamSinks[t]=g;Promise.try(n,e.data,g).then((function(){s.postMessage({sourceName:i,targetName:a,stream:Bg,streamId:t,success:!0})}),(function(e){s.postMessage({sourceName:i,targetName:a,stream:Bg,streamId:t,reason:wrapReason(e)})}))}#AA(e){const t=e.streamId,i=this.sourceName,a=e.sourceName,s=this.comObj,r=this.streamControllers[t],n=this.streamSinks[t];switch(e.stream){case Bg:e.success?r.startCall.resolve():r.startCall.reject(wrapReason(e.reason));break;case lg:e.success?r.pullCall.resolve():r.pullCall.reject(wrapReason(e.reason));break;case hg:if(!n){s.postMessage({sourceName:i,targetName:a,stream:lg,streamId:t,success:!0});break}n.desiredSize<=0&&e.desiredSize>0&&n.sinkCapability.resolve();n.desiredSize=e.desiredSize;Promise.try(n.onPull||onFn).then((function(){s.postMessage({sourceName:i,targetName:a,stream:lg,streamId:t,success:!0})}),(function(e){s.postMessage({sourceName:i,targetName:a,stream:lg,streamId:t,reason:wrapReason(e)})}));break;case cg:assert(r,"enqueue should have stream controller");if(r.isClosed)break;r.controller.enqueue(e.chunk);break;case Ig:assert(r,"close should have stream controller");if(r.isClosed)break;r.isClosed=!0;r.controller.close();this.#tA(r,t);break;case Cg:assert(r,"error should have stream controller");r.controller.error(wrapReason(e.reason));this.#tA(r,t);break;case og:e.success?r.cancelCall.resolve():r.cancelCall.reject(wrapReason(e.reason));this.#tA(r,t);break;case gg:if(!n)break;const g=wrapReason(e.reason);Promise.try(n.onCancel||onFn,g).then((function(){s.postMessage({sourceName:i,targetName:a,stream:og,streamId:t,success:!0})}),(function(e){s.postMessage({sourceName:i,targetName:a,stream:og,streamId:t,reason:wrapReason(e)})}));n.sinkCapability.reject(g);n.isCancelled=!0;delete this.streamSinks[t];break;default:throw new Error("Unexpected stream case")}}async#tA(e,t){await Promise.allSettled([e.startCall?.promise,e.pullCall?.promise,e.cancelCall?.promise]);delete this.streamControllers[t]}destroy(){this.#_?.abort();this.#_=null}}async function writeObject(e,t,i,{encrypt:a=null}){const s=a?.createCipherTransform(e.num,e.gen);i.push(`${e.num} ${e.gen} obj\n`);t instanceof Dict?await writeDict(t,i,s):t instanceof BaseStream?await writeStream(t,i,s):(Array.isArray(t)||ArrayBuffer.isView(t))&&await writeArray(t,i,s);i.push("\nendobj\n")}async function writeDict(e,t,i){t.push("<<");for(const a of e.getKeys()){t.push(` /${escapePDFName(a)} `);await writeValue(e.getRaw(a),t,i)}t.push(">>")}async function writeStream(e,t,i){let a=e.getBytes();const{dict:s}=e,[r,n]=await Promise.all([s.getAsync("Filter"),s.getAsync("DecodeParms")]),g=isName(Array.isArray(r)?await s.xref.fetchIfRefAsync(r[0]):r,"FlateDecode");if(a.length>=256||g)try{const e=new CompressionStream("deflate"),t=e.writable.getWriter();await t.ready;t.write(a).then((async()=>{await t.ready;await t.close()})).catch((()=>{}));const i=await new Response(e.readable).arrayBuffer();a=new Uint8Array(i);let o,c;if(r){if(!g){o=Array.isArray(r)?[Name.get("FlateDecode"),...r]:[Name.get("FlateDecode"),r];n&&(c=Array.isArray(n)?[null,...n]:[null,n])}}else o=Name.get("FlateDecode");o&&s.set("Filter",o);c&&s.set("DecodeParms",c)}catch(e){info(`writeStream - cannot compress data: "${e}".`)}let o=bytesToString(a);i&&(o=i.encryptString(o));s.set("Length",o.length);await writeDict(s,t,i);t.push(" stream\n",o,"\nendstream")}async function writeArray(e,t,i){t.push("[");let a=!0;for(const s of e){a?a=!1:t.push(" ");await writeValue(s,t,i)}t.push("]")}async function writeValue(e,t,i){if(e instanceof Name)t.push(`/${escapePDFName(e.name)}`);else if(e instanceof Ref)t.push(`${e.num} ${e.gen} R`);else if(Array.isArray(e)||ArrayBuffer.isView(e))await writeArray(e,t,i);else if("string"==typeof e){i&&(e=i.encryptString(e));t.push(`(${escapeString(e)})`)}else"number"==typeof e?t.push(numberToString(e)):"boolean"==typeof e?t.push(e.toString()):e instanceof Dict?await writeDict(e,t,i):e instanceof BaseStream?await writeStream(e,t,i):null===e?t.push("null"):warn(`Unhandled value in writer: ${typeof e}, please file a bug.`)}function writeInt(e,t,i,a){for(let s=t+i-1;s>i-1;s--){a[s]=255&e;e>>=8}return i+t}function writeString(e,t,i){for(let a=0,s=e.length;a1&&(r=i.documentElement.searchNode([s.at(-1)],0));r?r.childNodes=Array.isArray(a)?a.map((e=>new SimpleDOMNode("value",e))):[new SimpleDOMNode("#text",a)]:warn(`Node not found for path: ${t}`)}const a=[];i.documentElement.dump(a);return a.join("")}(a.fetchIfRef(t).getString(),i)}const s=new StringStream(e);s.dict=new Dict(a);s.dict.set("Type",Name.get("EmbeddedFile"));i.put(t,{data:s})}function getIndexes(e){const t=[];for(const{ref:i}of e)i.num===t.at(-2)+t.at(-1)?t[t.length-1]+=1:t.push(i.num,1);return t}function computeIDs(e,t,i){if(Array.isArray(t.fileIds)&&t.fileIds.length>0){const a=function computeMD5(e,t){const i=Math.floor(Date.now()/1e3),a=t.filename||"",s=[i.toString(),a,e.toString()];let r=s.reduce(((e,t)=>e+t.length),0);for(const e of Object.values(t.info)){s.push(e);r+=e.length}const n=new Uint8Array(r);let g=0;for(const e of s){writeString(e,g,n);g+=e.length}return bytesToString($n(n))}(e,t);i.set("ID",[t.fileIds[0],a])}}async function incrementalUpdate({originalData:e,xrefInfo:t,changes:i,xref:a=null,hasXfa:s=!1,xfaDatasetsRef:r=null,hasXfaDatasetsEntry:n=!1,needAppearances:g,acroFormRef:o=null,acroForm:c=null,xfaData:C=null,useXrefStream:h=!1}){await async function updateAcroform({xref:e,acroForm:t,acroFormRef:i,hasXfa:a,hasXfaDatasetsEntry:s,xfaDatasetsRef:r,needAppearances:n,changes:g}){!a||s||r||warn("XFA - Cannot save it");if(!n&&(!a||!r||s))return;const o=t.clone();if(a&&!s){const e=t.get("XFA").slice();e.splice(2,0,"datasets");e.splice(3,0,r);o.set("XFA",e)}n&&o.set("NeedAppearances",!0);g.put(i,{data:o})}({xref:a,acroForm:c,acroFormRef:o,hasXfa:s,hasXfaDatasetsEntry:n,xfaDatasetsRef:r,needAppearances:g,changes:i});s&&updateXFA({xfaData:C,xfaDatasetsRef:r,changes:i,xref:a});const l=function getTrailerDict(e,t,i){const a=new Dict(null);a.set("Prev",e.startXRef);const s=e.newRef;if(i){t.put(s,{data:""});a.set("Size",s.num+1);a.set("Type",Name.get("XRef"))}else a.set("Size",s.num);null!==e.rootRef&&a.set("Root",e.rootRef);null!==e.infoRef&&a.set("Info",e.infoRef);null!==e.encryptRef&&a.set("Encrypt",e.encryptRef);return a}(t,i,h),Q=[],E=await async function writeChanges(e,t,i=[]){const a=[];for(const[s,{data:r}]of e.items())if(null!==r&&"string"!=typeof r){await writeObject(s,r,i,t);a.push({ref:s,data:i.join("")});i.length=0}else a.push({ref:s,data:r});return a.sort(((e,t)=>e.ref.num-t.ref.num))}(i,a,Q);let u=e.length;const d=e.at(-1);if(10!==d&&13!==d){Q.push("\n");u+=1}for(const{data:e}of E)null!==e&&Q.push(e);await(h?async function getXRefStreamTable(e,t,i,a,s){const r=[];let n=0,g=0;for(const{ref:e,data:a}of i){let i;n=Math.max(n,t);if(null!==a){i=Math.min(e.gen,65535);r.push([1,t,i]);t+=a.length}else{i=Math.min(e.gen+1,65535);r.push([0,0,i])}g=Math.max(g,i)}a.set("Index",getIndexes(i));const o=[1,getSizeInBytes(n),getSizeInBytes(g)];a.set("W",o);computeIDs(t,e,a);const c=o.reduce(((e,t)=>e+t),0),C=new Uint8Array(c*r.length),h=new Stream(C);h.dict=a;let l=0;for(const[e,t,i]of r){l=writeInt(e,o[0],l,C);l=writeInt(t,o[1],l,C);l=writeInt(i,o[2],l,C)}await writeObject(e.newRef,h,s,{});s.push("startxref\n",t.toString(),"\n%%EOF\n")}(t,u,E,l,Q):async function getXRefTable(e,t,i,a,s){s.push("xref\n");const r=getIndexes(i);let n=0;for(const{ref:e,data:a}of i){if(e.num===r[n]){s.push(`${r[n]} ${r[n+1]}\n`);n+=2}if(null!==a){s.push(`${t.toString().padStart(10,"0")} ${Math.min(e.gen,65535).toString().padStart(5,"0")} n\r\n`);t+=a.length}else s.push(`0000000000 ${Math.min(e.gen+1,65535).toString().padStart(5,"0")} f\r\n`)}computeIDs(t,e,a);s.push("trailer\n");await writeDict(a,s);s.push("\nstartxref\n",t.toString(),"\n%%EOF\n")}(t,u,E,l,Q));const f=Q.reduce(((e,t)=>e+t.length),e.length),p=new Uint8Array(f);p.set(e);let m=e.length;for(const e of Q){writeString(e,m,p);m+=e.length}return p}class PDFWorkerStream{constructor(e){this._msgHandler=e;this._contentLength=null;this._fullRequestReader=null;this._rangeRequestReaders=[]}getFullReader(){assert(!this._fullRequestReader,"PDFWorkerStream.getFullReader can only be called once.");this._fullRequestReader=new PDFWorkerStreamReader(this._msgHandler);return this._fullRequestReader}getRangeReader(e,t){const i=new PDFWorkerStreamRangeReader(e,t,this._msgHandler);this._rangeRequestReaders.push(i);return i}cancelAllRequests(e){this._fullRequestReader?.cancel(e);for(const t of this._rangeRequestReaders.slice(0))t.cancel(e)}}class PDFWorkerStreamReader{constructor(e){this._msgHandler=e;this.onProgress=null;this._contentLength=null;this._isRangeSupported=!1;this._isStreamingSupported=!1;const t=this._msgHandler.sendWithStream("GetReader");this._reader=t.getReader();this._headersReady=this._msgHandler.sendWithPromise("ReaderHeadersReady").then((e=>{this._isStreamingSupported=e.isStreamingSupported;this._isRangeSupported=e.isRangeSupported;this._contentLength=e.contentLength}))}get headersReady(){return this._headersReady}get contentLength(){return this._contentLength}get isStreamingSupported(){return this._isStreamingSupported}get isRangeSupported(){return this._isRangeSupported}async read(){const{value:e,done:t}=await this._reader.read();return t?{value:void 0,done:!0}:{value:e.buffer,done:!1}}cancel(e){this._reader.cancel(e)}}class PDFWorkerStreamRangeReader{constructor(e,t,i){this._msgHandler=i;this.onProgress=null;const a=this._msgHandler.sendWithStream("GetRangeReader",{begin:e,end:t});this._reader=a.getReader()}get isStreamingSupported(){return!1}async read(){const{value:e,done:t}=await this._reader.read();return t?{value:void 0,done:!0}:{value:e.buffer,done:!1}}cancel(e){this._reader.cancel(e)}}class WorkerTask{constructor(e){this.name=e;this.terminated=!1;this._capability=Promise.withResolvers()}get finished(){return this._capability.promise}finish(){this._capability.resolve()}terminate(){this.terminated=!0}ensureNotTerminated(){if(this.terminated)throw new Error("Worker task was terminated")}}class WorkerMessageHandler{static{"undefined"==typeof window&&!t&&"undefined"!=typeof self&&"function"==typeof self.postMessage&&"onmessage"in self&&this.initializeFromPort(self)}static setup(e,t){let i=!1;e.on("test",(t=>{if(!i){i=!0;e.send("test",t instanceof Uint8Array)}}));e.on("configure",(e=>{!function setVerbosityLevel(e){Number.isInteger(e)&&(gt=e)}(e.verbosity)}));e.on("GetDocRequest",(e=>this.createDocumentHandler(e,t)))}static createDocumentHandler(e,t){let i,a=!1,s=null;const r=new Set,n=getVerbosityLevel(),{docId:g,apiVersion:o}=e,c="4.10.38";if(o!==c)throw new Error(`The API version "${o}" does not match the Worker version "${c}".`);const C=[];for(const e in[])C.push(e);if(C.length)throw new Error("The `Array.prototype` contains unexpected enumerable properties: "+C.join(", ")+"; thus breaking e.g. `for...in` iteration of `Array`s.");const h=g+"_worker";let l=new MessageHandler(h,g,t);function ensureNotTerminated(){if(a)throw new Error("Worker was terminated")}function startWorkerTask(e){r.add(e)}function finishWorkerTask(e){e.finish();r.delete(e)}async function loadDocument(e){await i.ensureDoc("checkHeader");await i.ensureDoc("parseStartXRef");await i.ensureDoc("parse",[e]);await i.ensureDoc("checkFirstPage",[e]);await i.ensureDoc("checkLastPage",[e]);const t=await i.ensureDoc("isPureXfa");if(t){const e=new WorkerTask("loadXfaFonts");startWorkerTask(e);await Promise.all([i.loadXfaFonts(l,e).catch((e=>{})).then((()=>finishWorkerTask(e))),i.loadXfaImages()])}const[a,s]=await Promise.all([i.ensureDoc("numPages"),i.ensureDoc("fingerprints")]);return{numPages:a,fingerprints:s,htmlForXfa:t?await i.ensureDoc("htmlForXfa"):null}}function setupDoc(e){function onSuccess(e){ensureNotTerminated();l.send("GetDoc",{pdfInfo:e})}function onFailure(e){ensureNotTerminated();if(e instanceof PasswordException){const t=new WorkerTask(`PasswordException: response ${e.code}`);startWorkerTask(t);l.sendWithPromise("PasswordRequest",e).then((function({password:e}){finishWorkerTask(t);i.updatePassword(e);pdfManagerReady()})).catch((function(){finishWorkerTask(t);l.send("DocException",e)}))}else l.send("DocException",wrapReason(e))}function pdfManagerReady(){ensureNotTerminated();loadDocument(!1).then(onSuccess,(function(e){ensureNotTerminated();e instanceof XRefParseException?i.requestLoadedStream().then((function(){ensureNotTerminated();loadDocument(!0).then(onSuccess,onFailure)})):onFailure(e)}))}ensureNotTerminated();(async function getPdfManager({data:e,password:t,disableAutoFetch:i,rangeChunkSize:a,length:r,docBaseUrl:n,enableXfa:o,evaluatorOptions:c}){const C={source:null,disableAutoFetch:i,docBaseUrl:n,docId:g,enableXfa:o,evaluatorOptions:c,handler:l,length:r,password:t,rangeChunkSize:a};if(e){C.source=e;return new LocalPdfManager(C)}const h=new PDFWorkerStream(l),Q=h.getFullReader(),E=Promise.withResolvers();let u,d=[],f=0;Q.headersReady.then((function(){if(Q.isRangeSupported){C.source=h;C.length=Q.contentLength;C.disableAutoFetch||=Q.isStreamingSupported;u=new NetworkPdfManager(C);for(const e of d)u.sendProgressiveData(e);d=[];E.resolve(u);s=null}})).catch((function(e){E.reject(e);s=null}));new Promise((function(e,t){const readChunk=function({value:e,done:i}){try{ensureNotTerminated();if(i){if(!u){const e=arrayBuffersToBytes(d);d=[];r&&e.length!==r&&warn("reported HTTP length is different from actual");C.source=e;u=new LocalPdfManager(C);E.resolve(u)}s=null;return}f+=e.byteLength;Q.isStreamingSupported||l.send("DocProgress",{loaded:f,total:Math.max(f,Q.contentLength||0)});u?u.sendProgressiveData(e):d.push(e);Q.read().then(readChunk,t)}catch(e){t(e)}};Q.read().then(readChunk,t)})).catch((function(e){E.reject(e);s=null}));s=e=>{h.cancelAllRequests(e)};return E.promise})(e).then((function(e){if(a){e.terminate(new AbortException("Worker was terminated."));throw new Error("Worker was terminated")}i=e;i.requestLoadedStream(!0).then((e=>{l.send("DataLoaded",{length:e.bytes.byteLength})}))})).then(pdfManagerReady,onFailure)}l.on("GetPage",(function(e){return i.getPage(e.pageIndex).then((function(e){return Promise.all([i.ensure(e,"rotate"),i.ensure(e,"ref"),i.ensure(e,"userUnit"),i.ensure(e,"view")]).then((function([e,t,i,a]){return{rotate:e,ref:t,refStr:t?.toString()??null,userUnit:i,view:a}}))}))}));l.on("GetPageIndex",(function(e){const t=Ref.get(e.num,e.gen);return i.ensureCatalog("getPageIndex",[t])}));l.on("GetDestinations",(function(e){return i.ensureCatalog("destinations")}));l.on("GetDestination",(function(e){return i.ensureCatalog("getDestination",[e.id])}));l.on("GetPageLabels",(function(e){return i.ensureCatalog("pageLabels")}));l.on("GetPageLayout",(function(e){return i.ensureCatalog("pageLayout")}));l.on("GetPageMode",(function(e){return i.ensureCatalog("pageMode")}));l.on("GetViewerPreferences",(function(e){return i.ensureCatalog("viewerPreferences")}));l.on("GetOpenAction",(function(e){return i.ensureCatalog("openAction")}));l.on("GetAttachments",(function(e){return i.ensureCatalog("attachments")}));l.on("GetDocJSActions",(function(e){return i.ensureCatalog("jsActions")}));l.on("GetPageJSActions",(function({pageIndex:e}){return i.getPage(e).then((function(e){return i.ensure(e,"jsActions")}))}));l.on("GetOutline",(function(e){return i.ensureCatalog("documentOutline")}));l.on("GetOptionalContentConfig",(function(e){return i.ensureCatalog("optionalContentConfig")}));l.on("GetPermissions",(function(e){return i.ensureCatalog("permissions")}));l.on("GetMetadata",(function(e){return Promise.all([i.ensureDoc("documentInfo"),i.ensureCatalog("metadata")])}));l.on("GetMarkInfo",(function(e){return i.ensureCatalog("markInfo")}));l.on("GetData",(function(e){return i.requestLoadedStream().then((function(e){return e.bytes}))}));l.on("GetAnnotations",(function({pageIndex:e,intent:t}){return i.getPage(e).then((function(i){const a=new WorkerTask(`GetAnnotations: page ${e}`);startWorkerTask(a);return i.getAnnotationsData(l,a,t).then((e=>{finishWorkerTask(a);return e}),(e=>{finishWorkerTask(a);throw e}))}))}));l.on("GetFieldObjects",(function(e){return i.ensureDoc("fieldObjects").then((e=>e?.allFields||null))}));l.on("HasJSActions",(function(e){return i.ensureDoc("hasJSActions")}));l.on("GetCalculationOrderIds",(function(e){return i.ensureDoc("calculationOrderIds")}));l.on("SaveDocument",(async function({isPureXfa:e,numPages:t,annotationStorage:a,filename:s}){const r=[i.requestLoadedStream(),i.ensureCatalog("acroForm"),i.ensureCatalog("acroFormRef"),i.ensureDoc("startXRef"),i.ensureDoc("xref"),i.ensureDoc("linearization"),i.ensureCatalog("structTreeRoot")],n=new RefSetCache,g=[],o=e?null:getNewAnnotationsMap(a),[c,C,h,Q,E,u,d]=await Promise.all(r),f=E.trailer.getRaw("Root")||null;let p;if(o){d?await d.canUpdateStructTree({pdfManager:i,xref:E,newAnnotationsByPage:o})&&(p=d):await StructTreeRoot.canCreateStructureTree({catalogRef:f,pdfManager:i,newAnnotationsByPage:o})&&(p=null);const e=AnnotationFactory.generateImages(a.values(),E,i.evaluatorOptions.isOffscreenCanvasSupported),t=void 0===p?g:[];for(const[a,s]of o)t.push(i.getPage(a).then((t=>{const i=new WorkerTask(`Save (editor): page ${a}`);startWorkerTask(i);return t.saveNewAnnotations(l,i,s,e,n).finally((function(){finishWorkerTask(i)}))})));null===p?g.push(Promise.all(t).then((async()=>{await StructTreeRoot.createStructureTree({newAnnotationsByPage:o,xref:E,catalogRef:f,pdfManager:i,changes:n})}))):p&&g.push(Promise.all(t).then((async()=>{await p.updateStructureTree({newAnnotationsByPage:o,pdfManager:i,changes:n})})))}if(e)g.push(i.serializeXfaData(a));else for(let e=0;ee.needAppearances)),D=C instanceof Dict&&C.get("XFA")||null;let b=null,F=!1;if(Array.isArray(D)){for(let e=0,t=D.length;e{E.resetNewTemporaryRef()}))}));l.on("GetOperatorList",(function(e,t){const a=e.pageIndex;i.getPage(a).then((function(i){const s=new WorkerTask(`GetOperatorList: page ${a}`);startWorkerTask(s);const r=n>=yA?Date.now():0;i.getOperatorList({handler:l,sink:t,task:s,intent:e.intent,cacheKey:e.cacheKey,annotationStorage:e.annotationStorage,modifiedIds:e.modifiedIds}).then((function(e){finishWorkerTask(s);r&&info(`page=${a+1} - getOperatorList: time=${Date.now()-r}ms, len=${e.length}`);t.close()}),(function(e){finishWorkerTask(s);s.terminated||t.error(e)}))}))}));l.on("GetTextContent",(function(e,t){const{pageIndex:a,includeMarkedContent:s,disableNormalization:r}=e;i.getPage(a).then((function(e){const i=new WorkerTask("GetTextContent: page "+a);startWorkerTask(i);const g=n>=yA?Date.now():0;e.extractTextContent({handler:l,task:i,sink:t,includeMarkedContent:s,disableNormalization:r}).then((function(){finishWorkerTask(i);g&&info(`page=${a+1} - getTextContent: time=`+(Date.now()-g)+"ms");t.close()}),(function(e){finishWorkerTask(i);i.terminated||t.error(e)}))}))}));l.on("GetStructTree",(function(e){return i.getPage(e.pageIndex).then((function(e){return i.ensure(e,"getStructTree")}))}));l.on("FontFallback",(function(e){return i.fontFallback(e.id,l)}));l.on("Cleanup",(function(e){return i.cleanup(!0)}));l.on("Terminate",(function(e){a=!0;const t=[];if(i){i.terminate(new AbortException("Worker was terminated."));const e=i.cleanup();t.push(e);i=null}else clearGlobalCaches();s?.(new AbortException("Worker was terminated."));for(const e of r){t.push(e.finished);e.terminate()}return Promise.all(t).then((function(){l.destroy();l=null}))}));l.on("Ready",(function(t){setupDoc(e);e=null}));return h}static initializeFromPort(e){const t=new MessageHandler("worker","main",e);this.setup(t,e);t.send("ready",null)}}var Qg=__webpack_exports__.WorkerMessageHandler;export{Qg as WorkerMessageHandler}; \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif new file mode 100644 index 00000000000..1c72ebb554b Binary files /dev/null and b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/images/loading-icon.gif differ diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css new file mode 100644 index 00000000000..86a3716c501 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.css @@ -0,0 +1,3274 @@ +/* Copyright 2014 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.messageBar{ + --closing-button-icon:url(images/messageBar_closingButton.svg); + --message-bar-close-button-color:var(--text-primary-color); + --message-bar-close-button-color-hover:var(--text-primary-color); + --message-bar-close-button-border-radius:4px; + --message-bar-close-button-border:none; + --message-bar-close-button-hover-bg-color:rgb(21 20 26 / 0.14); + --message-bar-close-button-active-bg-color:rgb(21 20 26 / 0.21); + --message-bar-close-button-focus-bg-color:rgb(21 20 26 / 0.07); +} + +@media (prefers-color-scheme: dark){ + +.messageBar{ + --message-bar-close-button-hover-bg-color:rgb(251 251 254 / 0.14); + --message-bar-close-button-active-bg-color:rgb(251 251 254 / 0.21); + --message-bar-close-button-focus-bg-color:rgb(251 251 254 / 0.07); +} + } + +@media screen and (forced-colors: active){ + +.messageBar{ + --message-bar-close-button-color:ButtonText; + --message-bar-close-button-border:1px solid ButtonText; + --message-bar-close-button-hover-bg-color:ButtonText; + --message-bar-close-button-active-bg-color:ButtonText; + --message-bar-close-button-focus-bg-color:ButtonText; + --message-bar-close-button-color-hover:HighlightText; +} + } + +.messageBar{ + + display:flex; + position:relative; + padding:8px 8px 8px 16px; + flex-direction:column; + justify-content:center; + align-items:center; + gap:8px; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + + border-radius:4px; + + border:1px solid var(--message-bar-border-color); + background:var(--message-bar-bg-color); + color:var(--message-bar-fg-color); +} + +.messageBar > div{ + display:flex; + align-items:flex-start; + gap:8px; + align-self:stretch; + } + +:is(.messageBar > div)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--message-bar-icon); + mask-image:var(--message-bar-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--message-bar-icon-color); + flex-shrink:0; + } + +.messageBar button{ + cursor:pointer; + } + +:is(.messageBar button):focus-visible{ + outline:var(--focus-ring-outline); + outline-offset:2px; + } + +.messageBar .closeButton{ + width:32px; + height:32px; + background:none; + border-radius:var(--message-bar-close-button-border-radius); + border:var(--message-bar-close-button-border); + + display:flex; + align-items:center; + justify-content:center; + } + +:is(.messageBar .closeButton)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--closing-button-icon); + mask-image:var(--closing-button-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--message-bar-close-button-color); + } + +:is(.messageBar .closeButton):is(:hover,:active,:focus)::before{ + background-color:var(--message-bar-close-button-color-hover); + } + +:is(.messageBar .closeButton):hover{ + background-color:var(--message-bar-close-button-hover-bg-color); + } + +:is(.messageBar .closeButton):active{ + background-color:var(--message-bar-close-button-active-bg-color); + } + +:is(.messageBar .closeButton):focus{ + background-color:var(--message-bar-close-button-focus-bg-color); + } + +:is(.messageBar .closeButton) > span{ + display:inline-block; + width:0; + height:0; + overflow:hidden; + } + +#editorUndoBar{ + --text-primary-color:#15141a; + + --message-bar-icon:url(images/secondaryToolbarButton-documentProperties.svg); + --message-bar-icon-color:#0060df; + --message-bar-bg-color:#deeafc; + --message-bar-fg-color:var(--text-primary-color); + --message-bar-border-color:rgb(0 0 0 / 0.08); + + --undo-button-bg-color:rgb(21 20 26 / 0.07); + --undo-button-bg-color-hover:rgb(21 20 26 / 0.14); + --undo-button-bg-color-active:rgb(21 20 26 / 0.21); + + --undo-button-fg-color:var(--message-bar-fg-color); + --undo-button-fg-color-hover:var(--undo-button-fg-color); + --undo-button-fg-color-active:var(--undo-button-fg-color); + + --focus-ring-color:#0060df; + --focus-ring-outline:2px solid var(--focus-ring-color); +} + +@media (prefers-color-scheme: dark){ + +#editorUndoBar{ + --text-primary-color:#fbfbfe; + + --message-bar-icon-color:#73a7f3; + --message-bar-bg-color:#003070; + --message-bar-border-color:rgb(255 255 255 / 0.08); + + --undo-button-bg-color:rgb(255 255 255 / 0.08); + --undo-button-bg-color-hover:rgb(255 255 255 / 0.14); + --undo-button-bg-color-active:rgb(255 255 255 / 0.21); +} + } + +@media screen and (forced-colors: active){ + +#editorUndoBar{ + --text-primary-color:CanvasText; + + --message-bar-icon-color:CanvasText; + --message-bar-bg-color:Canvas; + --message-bar-border-color:CanvasText; + + --undo-button-bg-color:ButtonText; + --undo-button-bg-color-hover:SelectedItem; + --undo-button-bg-color-active:SelectedItem; + + --undo-button-fg-color:ButtonFace; + --undo-button-fg-color-hover:SelectedItemText; + --undo-button-fg-color-active:SelectedItemText; + + --focus-ring-color:CanvasText; +} + } + +#editorUndoBar{ + + position:fixed; + top:50px; + left:50%; + transform:translateX(-50%); + z-index:10; + + padding-block:8px; + padding-inline:16px 8px; + + font:menu; + font-size:15px; + + cursor:default; +} + +#editorUndoBar button{ + cursor:pointer; + } + +#editorUndoBar #editorUndoBarUndoButton{ + border-radius:4px; + font-weight:590; + line-height:19.5px; + color:var(--undo-button-fg-color); + border:none; + padding:4px 16px; + margin-inline-start:8px; + height:32px; + + background-color:var(--undo-button-bg-color); + } + +:is(#editorUndoBar #editorUndoBarUndoButton):hover{ + background-color:var(--undo-button-bg-color-hover); + color:var(--undo-button-fg-color-hover); + } + +:is(#editorUndoBar #editorUndoBarUndoButton):active{ + background-color:var(--undo-button-bg-color-active); + color:var(--undo-button-fg-color-active); + } + +#editorUndoBar > div{ + align-items:center; + } + +.dialog{ + --dialog-bg-color:white; + --dialog-border-color:white; + --dialog-shadow:0 2px 14px 0 rgb(58 57 68 / 0.2); + --text-primary-color:#15141a; + --text-secondary-color:#5b5b66; + --hover-filter:brightness(0.9); + --focus-ring-color:#0060df; + --focus-ring-outline:2px solid var(--focus-ring-color); + --link-fg-color:#0060df; + --link-hover-fg-color:#0250bb; + --separator-color:#f0f0f4; + + --textarea-border-color:#8f8f9d; + --textarea-bg-color:white; + --textarea-fg-color:var(--text-secondary-color); + + --radio-bg-color:#f0f0f4; + --radio-checked-bg-color:#fbfbfe; + --radio-border-color:#8f8f9d; + --radio-checked-border-color:#0060df; + + --button-secondary-bg-color:#f0f0f4; + --button-secondary-fg-color:var(--text-primary-color); + --button-secondary-border-color:var(--button-secondary-bg-color); + --button-secondary-hover-bg-color:var(--button-secondary-bg-color); + --button-secondary-hover-fg-color:var(--button-secondary-fg-color); + --button-secondary-hover-border-color:var(--button-secondary-hover-bg-color); + + --button-primary-bg-color:#0060df; + --button-primary-fg-color:#fbfbfe; + --button-primary-border-color:var(--button-primary-bg-color); + --button-primary-hover-bg-color:var(--button-primary-bg-color); + --button-primary-hover-fg-color:var(--button-primary-fg-color); + --button-primary-hover-border-color:var(--button-primary-hover-bg-color); +} + +@media (prefers-color-scheme: dark){ + +.dialog{ + --dialog-bg-color:#1c1b22; + --dialog-border-color:#1c1b22; + --dialog-shadow:0 2px 14px 0 #15141a; + --text-primary-color:#fbfbfe; + --text-secondary-color:#cfcfd8; + --focus-ring-color:#0df; + --hover-filter:brightness(1.4); + --link-fg-color:#0df; + --link-hover-fg-color:#80ebff; + --separator-color:#52525e; + + --textarea-bg-color:#42414d; + + --radio-bg-color:#2b2a33; + --radio-checked-bg-color:#15141a; + --radio-checked-border-color:#0df; + + --button-secondary-bg-color:#2b2a33; + --button-primary-bg-color:#0df; + --button-primary-fg-color:#15141a; +} + } + +@media screen and (forced-colors: active){ + +.dialog{ + --dialog-bg-color:Canvas; + --dialog-border-color:CanvasText; + --dialog-shadow:none; + --text-primary-color:CanvasText; + --text-secondary-color:CanvasText; + --hover-filter:none; + --focus-ring-color:ButtonBorder; + --link-fg-color:LinkText; + --link-hover-fg-color:LinkText; + --separator-color:CanvasText; + + --textarea-border-color:ButtonBorder; + --textarea-bg-color:Field; + --textarea-fg-color:ButtonText; + + --radio-bg-color:ButtonFace; + --radio-checked-bg-color:ButtonFace; + --radio-border-color:ButtonText; + --radio-checked-border-color:ButtonText; + + --button-secondary-bg-color:ButtonFace; + --button-secondary-fg-color:ButtonText; + --button-secondary-border-color:ButtonText; + --button-secondary-hover-bg-color:AccentColor; + --button-secondary-hover-fg-color:AccentColorText; + + --button-primary-bg-color:ButtonText; + --button-primary-fg-color:ButtonFace; + --button-primary-hover-bg-color:AccentColor; + --button-primary-hover-fg-color:AccentColorText; +} + } + +.dialog{ + + font:message-box; + font-size:13px; + font-weight:400; + line-height:150%; + border-radius:4px; + padding:12px 16px; + border:1px solid var(--dialog-border-color); + background:var(--dialog-bg-color); + color:var(--text-primary-color); + box-shadow:var(--dialog-shadow); +} + +:is(.dialog .mainContainer) *:focus-visible{ + outline:var(--focus-ring-outline); + outline-offset:2px; + } + +:is(.dialog .mainContainer) .title{ + display:flex; + width:auto; + flex-direction:column; + justify-content:flex-end; + align-items:flex-start; + gap:12px; + } + +:is(:is(.dialog .mainContainer) .title) > span{ + font-size:13px; + font-style:normal; + font-weight:590; + line-height:150%; + } + +:is(.dialog .mainContainer) .dialogSeparator{ + width:100%; + height:0; + margin-block:4px; + border-top:1px solid var(--separator-color); + border-bottom:none; + } + +:is(.dialog .mainContainer) .dialogButtonsGroup{ + display:flex; + gap:12px; + align-self:flex-end; + } + +:is(.dialog .mainContainer) .radio{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:4px; + } + +:is(:is(.dialog .mainContainer) .radio) > .radioButton{ + display:flex; + gap:8px; + align-self:stretch; + align-items:center; + } + +:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input{ + -webkit-appearance:none; + -moz-appearance:none; + appearance:none; + box-sizing:border-box; + width:16px; + height:16px; + border-radius:50%; + background-color:var(--radio-bg-color); + border:1px solid var(--radio-border-color); + } + +:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):hover{ + filter:var(--hover-filter); + } + +:is(:is(:is(:is(.dialog .mainContainer) .radio) > .radioButton) input):checked{ + background-color:var(--radio-checked-bg-color); + border:4px solid var(--radio-checked-border-color); + } + +:is(:is(.dialog .mainContainer) .radio) > .radioLabel{ + display:flex; + padding-inline-start:24px; + align-items:flex-start; + gap:10px; + align-self:stretch; + } + +:is(:is(:is(.dialog .mainContainer) .radio) > .radioLabel) > span{ + flex:1 0 0; + font-size:11px; + color:var(--text-secondary-color); + } + +:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton)){ + border-radius:4px; + border:1px solid; + font:menu; + font-weight:600; + padding:4px 16px; + width:auto; + height:32px; + } + +:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))):hover{ + cursor:pointer; + filter:var(--hover-filter); + } + +.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))){ + color:var(--button-secondary-fg-color); + background-color:var(--button-secondary-bg-color); + border-color:var(--button-secondary-border-color); + } + +.secondaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))):hover{ + color:var(--button-secondary-hover-fg-color); + background-color:var(--button-secondary-hover-bg-color); + border-color:var(--button-secondary-hover-border-color); + } + +.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))){ + color:var(--button-primary-fg-color); + background-color:var(--button-primary-bg-color); + border-color:var(--button-primary-border-color); + opacity:1; + } + +.primaryButton:is(:is(.dialog .mainContainer) button:not(:is(.toggle-button,.closeButton))):hover{ + color:var(--button-primary-hover-fg-color); + background-color:var(--button-primary-hover-bg-color); + border-color:var(--button-primary-hover-border-color); + } + +:is(.dialog .mainContainer) a{ + color:var(--link-fg-color); + } + +:is(:is(.dialog .mainContainer) a):hover{ + color:var(--link-hover-fg-color); + } + +:is(.dialog .mainContainer) textarea{ + font:inherit; + padding:8px; + resize:none; + margin:0; + box-sizing:border-box; + border-radius:4px; + border:1px solid var(--textarea-border-color); + background:var(--textarea-bg-color); + color:var(--textarea-fg-color); + } + +:is(:is(.dialog .mainContainer) textarea):focus{ + outline-offset:0; + border-color:transparent; + } + +:is(:is(.dialog .mainContainer) textarea):disabled{ + pointer-events:none; + opacity:0.4; + } + +:is(.dialog .mainContainer) .messageBar{ + --message-bar-bg-color:#ffebcd; + --message-bar-fg-color:#15141a; + --message-bar-border-color:rgb(0 0 0 / 0.08); + --message-bar-icon:url(images/messageBar_warning.svg); + --message-bar-icon-color:#cd411e; + } + +@media (prefers-color-scheme: dark){ + +:is(.dialog .mainContainer) .messageBar{ + --message-bar-bg-color:#5a3100; + --message-bar-fg-color:#fbfbfe; + --message-bar-border-color:rgb(255 255 255 / 0.08); + --message-bar-icon-color:#e49c49; + } + } + +@media screen and (forced-colors: active){ + +:is(.dialog .mainContainer) .messageBar{ + --message-bar-bg-color:HighlightText; + --message-bar-fg-color:CanvasText; + --message-bar-border-color:CanvasText; + --message-bar-icon-color:CanvasText; + } + } + +:is(.dialog .mainContainer) .messageBar{ + + align-self:stretch; + } + +:is(:is(:is(.dialog .mainContainer) .messageBar) > div)::before,:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div{ + margin-block:4px; + } + +:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:8px; + flex:1 0 0; + } + +:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .title{ + font-size:13px; + font-weight:590; + } + +:is(:is(:is(:is(.dialog .mainContainer) .messageBar) > div) > div) .description{ + font-size:13px; + } + +:is(.dialog .mainContainer) .toggler{ + display:flex; + align-items:center; + gap:8px; + align-self:stretch; + } + +:is(:is(.dialog .mainContainer) .toggler) > .togglerLabel{ + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + } + +.textLayer{ + position:absolute; + text-align:initial; + inset:0; + overflow:clip; + opacity:1; + line-height:1; + -webkit-text-size-adjust:none; + -moz-text-size-adjust:none; + text-size-adjust:none; + forced-color-adjust:none; + transform-origin:0 0; + caret-color:CanvasText; + z-index:0; +} + +.textLayer.highlighting{ + touch-action:none; + } + +.textLayer :is(span,br){ + color:transparent; + position:absolute; + white-space:pre; + cursor:text; + transform-origin:0% 0%; + } + +.textLayer > :not(.markedContent),.textLayer .markedContent span:not(.markedContent){ + z-index:1; + } + +.textLayer span.markedContent{ + top:0; + height:0; + } + +.textLayer span[role="img"]{ + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + cursor:default; + } + +.textLayer .highlight{ + --highlight-bg-color:rgb(180 0 170 / 0.25); + --highlight-selected-bg-color:rgb(0 100 0 / 0.25); + --highlight-backdrop-filter:none; + --highlight-selected-backdrop-filter:none; + } + +@media screen and (forced-colors: active){ + +.textLayer .highlight{ + --highlight-bg-color:transparent; + --highlight-selected-bg-color:transparent; + --highlight-backdrop-filter:var(--hcm-highlight-filter); + --highlight-selected-backdrop-filter:var( + --hcm-highlight-selected-filter + ); + } + } + +.textLayer .highlight{ + + margin:-1px; + padding:1px; + background-color:var(--highlight-bg-color); + -webkit-backdrop-filter:var(--highlight-backdrop-filter); + backdrop-filter:var(--highlight-backdrop-filter); + border-radius:4px; + } + +.appended:is(.textLayer .highlight){ + position:initial; + } + +.begin:is(.textLayer .highlight){ + border-radius:4px 0 0 4px; + } + +.end:is(.textLayer .highlight){ + border-radius:0 4px 4px 0; + } + +.middle:is(.textLayer .highlight){ + border-radius:0; + } + +.selected:is(.textLayer .highlight){ + background-color:var(--highlight-selected-bg-color); + -webkit-backdrop-filter:var(--highlight-selected-backdrop-filter); + backdrop-filter:var(--highlight-selected-backdrop-filter); + } + +.textLayer ::-moz-selection{ + background:rgba(0 0 255 / 0.25); + background:color-mix(in srgb, AccentColor, transparent 75%); + } + +.textLayer ::selection{ + background:rgba(0 0 255 / 0.25); + background:color-mix(in srgb, AccentColor, transparent 75%); + } + +.textLayer br::-moz-selection{ + background:transparent; + } + +.textLayer br::selection{ + background:transparent; + } + +.textLayer .endOfContent{ + display:block; + position:absolute; + inset:100% 0 0; + z-index:0; + cursor:default; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + } + +.textLayer.selecting .endOfContent{ + top:0; + } + +.annotationLayer{ + --annotation-unfocused-field-background:url("data:image/svg+xml;charset=UTF-8,"); + --input-focus-border-color:Highlight; + --input-focus-outline:1px solid Canvas; + --input-unfocused-border-color:transparent; + --input-disabled-border-color:transparent; + --input-hover-border-color:black; + --link-outline:none; +} + +@media screen and (forced-colors: active){ + +.annotationLayer{ + --input-focus-border-color:CanvasText; + --input-unfocused-border-color:ActiveText; + --input-disabled-border-color:GrayText; + --input-hover-border-color:Highlight; + --link-outline:1.5px solid LinkText; +} + + .annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{ + outline:1.5px solid selectedItem; + } + + .annotationLayer .linkAnnotation{ + outline:var(--link-outline); + } + + :is(.annotationLayer .linkAnnotation):hover{ + -webkit-backdrop-filter:var(--hcm-highlight-filter); + backdrop-filter:var(--hcm-highlight-filter); + } + + :is(.annotationLayer .linkAnnotation) > a:hover{ + opacity:0 !important; + background:none !important; + box-shadow:none; + } + + .annotationLayer .popupAnnotation .popup{ + outline:calc(1.5px * var(--scale-factor)) solid CanvasText !important; + background-color:ButtonFace !important; + color:ButtonText !important; + } + + .annotationLayer .highlightArea:hover::after{ + position:absolute; + top:0; + left:0; + width:100%; + height:100%; + -webkit-backdrop-filter:var(--hcm-highlight-filter); + backdrop-filter:var(--hcm-highlight-filter); + content:""; + pointer-events:none; + } + + .annotationLayer .popupAnnotation.focused .popup{ + outline:calc(3px * var(--scale-factor)) solid Highlight !important; + } + } + +.annotationLayer{ + + position:absolute; + top:0; + left:0; + pointer-events:none; + transform-origin:0 0; +} + +.annotationLayer[data-main-rotation="90"] .norotate{ + transform:rotate(270deg) translateX(-100%); + } + +.annotationLayer[data-main-rotation="180"] .norotate{ + transform:rotate(180deg) translate(-100%, -100%); + } + +.annotationLayer[data-main-rotation="270"] .norotate{ + transform:rotate(90deg) translateY(-100%); + } + +.annotationLayer.disabled section,.annotationLayer.disabled .popup{ + pointer-events:none; + } + +.annotationLayer .annotationContent{ + position:absolute; + width:100%; + height:100%; + pointer-events:none; + } + +.freetext:is(.annotationLayer .annotationContent){ + background:transparent; + border:none; + inset:0; + overflow:visible; + white-space:nowrap; + font:10px sans-serif; + line-height:1.35; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + } + +.annotationLayer section{ + position:absolute; + text-align:initial; + pointer-events:auto; + box-sizing:border-box; + transform-origin:0 0; + } + +:is(.annotationLayer section):has(div.annotationContent) canvas.annotationContent{ + display:none; + } + +.textLayer.selecting ~ .annotationLayer section{ + pointer-events:none; + } + +.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton) > a{ + position:absolute; + font-size:1em; + top:0; + left:0; + width:100%; + height:100%; + } + +.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton):not(.hasBorder) > a:hover{ + opacity:0.2; + background-color:rgb(255 255 0); + box-shadow:0 2px 10px rgb(255 255 0); + } + +.annotationLayer .linkAnnotation.hasBorder:hover{ + background-color:rgb(255 255 0 / 0.2); + } + +.annotationLayer .hasBorder{ + background-size:100% 100%; + } + +.annotationLayer .textAnnotation img{ + position:absolute; + cursor:pointer; + width:100%; + height:100%; + top:0; + left:0; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea),.annotationLayer .choiceWidgetAnnotation select,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{ + background-image:var(--annotation-unfocused-field-background); + border:2px solid var(--input-unfocused-border-color); + box-sizing:border-box; + font:calc(9px * var(--scale-factor)) sans-serif; + height:100%; + margin:0; + vertical-align:top; + width:100%; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{ + outline:1.5px solid red; + } + +.annotationLayer .choiceWidgetAnnotation select option{ + padding:0; + } + +.annotationLayer .buttonWidgetAnnotation.radioButton input{ + border-radius:50%; + } + +.annotationLayer .textWidgetAnnotation textarea{ + resize:none; + } + +.annotationLayer .textWidgetAnnotation [disabled]:is(input,textarea),.annotationLayer .choiceWidgetAnnotation select[disabled],.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input[disabled]{ + background:none; + border:2px solid var(--input-disabled-border-color); + cursor:not-allowed; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:hover{ + border:2px solid var(--input-hover-border-color); + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation.checkBox input:hover{ + border-radius:2px; + } + +.annotationLayer .textWidgetAnnotation :is(input,textarea):focus,.annotationLayer .choiceWidgetAnnotation select:focus{ + background:none; + border:2px solid var(--input-focus-border-color); + border-radius:2px; + outline:var(--input-focus-outline); + } + +.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) :focus{ + background-image:none; + background-color:transparent; + } + +.annotationLayer .buttonWidgetAnnotation.checkBox :focus{ + border:2px solid var(--input-focus-border-color); + border-radius:2px; + outline:var(--input-focus-outline); + } + +.annotationLayer .buttonWidgetAnnotation.radioButton :focus{ + border:2px solid var(--input-focus-border-color); + outline:var(--input-focus-outline); + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after,.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before{ + background-color:CanvasText; + content:""; + display:block; + position:absolute; + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after{ + height:80%; + left:45%; + width:1px; + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before{ + transform:rotate(45deg); + } + +.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after{ + transform:rotate(-45deg); + } + +.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before{ + border-radius:50%; + height:50%; + left:25%; + top:25%; + width:50%; + } + +.annotationLayer .textWidgetAnnotation input.comb{ + font-family:monospace; + padding-left:2px; + padding-right:0; + } + +.annotationLayer .textWidgetAnnotation input.comb:focus{ + width:103%; + } + +.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{ + -webkit-appearance:none; + -moz-appearance:none; + appearance:none; + } + +.annotationLayer .fileAttachmentAnnotation .popupTriggerArea{ + height:100%; + width:100%; + } + +.annotationLayer .popupAnnotation{ + position:absolute; + font-size:calc(9px * var(--scale-factor)); + pointer-events:none; + width:-moz-max-content; + width:max-content; + max-width:45%; + height:auto; + } + +.annotationLayer .popup{ + background-color:rgb(255 255 153); + box-shadow:0 calc(2px * var(--scale-factor)) calc(5px * var(--scale-factor)) rgb(136 136 136); + border-radius:calc(2px * var(--scale-factor)); + outline:1.5px solid rgb(255 255 74); + padding:calc(6px * var(--scale-factor)); + cursor:pointer; + font:message-box; + white-space:normal; + word-wrap:break-word; + pointer-events:auto; + } + +.annotationLayer .popupAnnotation.focused .popup{ + outline-width:3px; + } + +.annotationLayer .popup *{ + font-size:calc(9px * var(--scale-factor)); + } + +.annotationLayer .popup > .header{ + display:inline-block; + } + +.annotationLayer .popup > .header h1{ + display:inline; + } + +.annotationLayer .popup > .header .popupDate{ + display:inline-block; + margin-left:calc(5px * var(--scale-factor)); + width:-moz-fit-content; + width:fit-content; + } + +.annotationLayer .popupContent{ + border-top:1px solid rgb(51 51 51); + margin-top:calc(2px * var(--scale-factor)); + padding-top:calc(2px * var(--scale-factor)); + } + +.annotationLayer .richText > *{ + white-space:pre-wrap; + font-size:calc(9px * var(--scale-factor)); + } + +.annotationLayer .popupTriggerArea{ + cursor:pointer; + } + +.annotationLayer section svg{ + position:absolute; + width:100%; + height:100%; + top:0; + left:0; + } + +.annotationLayer .annotationTextContent{ + position:absolute; + width:100%; + height:100%; + opacity:0; + color:transparent; + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; + pointer-events:none; + } + +:is(.annotationLayer .annotationTextContent) span{ + width:100%; + display:inline-block; + } + +.annotationLayer svg.quadrilateralsContainer{ + contain:strict; + width:0; + height:0; + position:absolute; + top:0; + left:0; + z-index:-1; + } + +:root{ + --xfa-unfocused-field-background:url("data:image/svg+xml;charset=UTF-8,"); + --xfa-focus-outline:auto; +} + +@media screen and (forced-colors: active){ + :root{ + --xfa-focus-outline:2px solid CanvasText; + } + .xfaLayer *:required{ + outline:1.5px solid selectedItem; + } +} + +.xfaLayer{ + background-color:transparent; +} + +.xfaLayer .highlight{ + margin:-1px; + padding:1px; + background-color:rgb(239 203 237); + border-radius:4px; +} + +.xfaLayer .highlight.appended{ + position:initial; +} + +.xfaLayer .highlight.begin{ + border-radius:4px 0 0 4px; +} + +.xfaLayer .highlight.end{ + border-radius:0 4px 4px 0; +} + +.xfaLayer .highlight.middle{ + border-radius:0; +} + +.xfaLayer .highlight.selected{ + background-color:rgb(203 223 203); +} + +.xfaPage{ + overflow:hidden; + position:relative; +} + +.xfaContentarea{ + position:absolute; +} + +.xfaPrintOnly{ + display:none; +} + +.xfaLayer{ + position:absolute; + text-align:initial; + top:0; + left:0; + transform-origin:0 0; + line-height:1.2; +} + +.xfaLayer *{ + color:inherit; + font:inherit; + font-style:inherit; + font-weight:inherit; + font-kerning:inherit; + letter-spacing:-0.01px; + text-align:inherit; + text-decoration:inherit; + box-sizing:border-box; + background-color:transparent; + padding:0; + margin:0; + pointer-events:auto; + line-height:inherit; +} + +.xfaLayer *:required{ + outline:1.5px solid red; +} + +.xfaLayer div, +.xfaLayer svg, +.xfaLayer svg *{ + pointer-events:none; +} + +.xfaLayer a{ + color:blue; +} + +.xfaRich li{ + margin-left:3em; +} + +.xfaFont{ + color:black; + font-weight:normal; + font-kerning:none; + font-size:10px; + font-style:normal; + letter-spacing:0; + text-decoration:none; + vertical-align:0; +} + +.xfaCaption{ + overflow:hidden; + flex:0 0 auto; +} + +.xfaCaptionForCheckButton{ + overflow:hidden; + flex:1 1 auto; +} + +.xfaLabel{ + height:100%; + width:100%; +} + +.xfaLeft{ + display:flex; + flex-direction:row; + align-items:center; +} + +.xfaRight{ + display:flex; + flex-direction:row-reverse; + align-items:center; +} + +:is(.xfaLeft, .xfaRight) > :is(.xfaCaption, .xfaCaptionForCheckButton){ + max-height:100%; +} + +.xfaTop{ + display:flex; + flex-direction:column; + align-items:flex-start; +} + +.xfaBottom{ + display:flex; + flex-direction:column-reverse; + align-items:flex-start; +} + +:is(.xfaTop, .xfaBottom) > :is(.xfaCaption, .xfaCaptionForCheckButton){ + width:100%; +} + +.xfaBorder{ + background-color:transparent; + position:absolute; + pointer-events:none; +} + +.xfaWrapped{ + width:100%; + height:100%; +} + +:is(.xfaTextfield, .xfaSelect):focus{ + background-image:none; + background-color:transparent; + outline:var(--xfa-focus-outline); + outline-offset:-1px; +} + +:is(.xfaCheckbox, .xfaRadio):focus{ + outline:var(--xfa-focus-outline); +} + +.xfaTextfield, +.xfaSelect{ + height:100%; + width:100%; + flex:1 1 auto; + border:none; + resize:none; + background-image:var(--xfa-unfocused-field-background); +} + +.xfaSelect{ + padding-inline:2px; +} + +:is(.xfaTop, .xfaBottom) > :is(.xfaTextfield, .xfaSelect){ + flex:0 1 auto; +} + +.xfaButton{ + cursor:pointer; + width:100%; + height:100%; + border:none; + text-align:center; +} + +.xfaLink{ + width:100%; + height:100%; + position:absolute; + top:0; + left:0; +} + +.xfaCheckbox, +.xfaRadio{ + width:100%; + height:100%; + flex:0 0 auto; + border:none; +} + +.xfaRich{ + white-space:pre-wrap; + width:100%; + height:100%; +} + +.xfaImage{ + -o-object-position:left top; + object-position:left top; + -o-object-fit:contain; + object-fit:contain; + width:100%; + height:100%; +} + +.xfaLrTb, +.xfaRlTb, +.xfaTb{ + display:flex; + flex-direction:column; + align-items:stretch; +} + +.xfaLr{ + display:flex; + flex-direction:row; + align-items:stretch; +} + +.xfaRl{ + display:flex; + flex-direction:row-reverse; + align-items:stretch; +} + +.xfaTb > div{ + justify-content:left; +} + +.xfaPosition{ + position:relative; +} + +.xfaArea{ + position:relative; +} + +.xfaValignMiddle{ + display:flex; + align-items:center; +} + +.xfaTable{ + display:flex; + flex-direction:column; + align-items:stretch; +} + +.xfaTable .xfaRow{ + display:flex; + flex-direction:row; + align-items:stretch; +} + +.xfaTable .xfaRlRow{ + display:flex; + flex-direction:row-reverse; + align-items:stretch; + flex:1; +} + +.xfaTable .xfaRlRow > div{ + flex:1; +} + +:is(.xfaNonInteractive, .xfaDisabled, .xfaReadOnly) :is(input, textarea){ + background:initial; +} + +@media print{ + .xfaTextfield, + .xfaSelect{ + background:transparent; + } + + .xfaSelect{ + -webkit-appearance:none; + -moz-appearance:none; + appearance:none; + text-indent:1px; + text-overflow:""; + } +} + +.canvasWrapper svg{ + transform:none; + } + +.moving:is(.canvasWrapper svg){ + z-index:100000; + } + +[data-main-rotation="90"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="90"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ + transform:matrix(0, 1, -1, 0, 1, 0); + } + +[data-main-rotation="180"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="180"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ + transform:matrix(-1, 0, 0, -1, 1, 1); + } + +[data-main-rotation="270"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) mask,[data-main-rotation="270"]:is(.highlight:is(.canvasWrapper svg),.highlightOutline:is(.canvasWrapper svg)) use:not(.clip,.mask){ + transform:matrix(0, -1, 1, 0, 0, 1); + } + +.draw:is(.canvasWrapper svg){ + position:absolute; + mix-blend-mode:normal; + } + +.draw[data-draw-rotation="90"]:is(.canvasWrapper svg){ + transform:rotate(90deg); + } + +.draw[data-draw-rotation="180"]:is(.canvasWrapper svg){ + transform:rotate(180deg); + } + +.draw[data-draw-rotation="270"]:is(.canvasWrapper svg){ + transform:rotate(270deg); + } + +.highlight:is(.canvasWrapper svg){ + --blend-mode:multiply; + } + +@media screen and (forced-colors: active){ + +.highlight:is(.canvasWrapper svg){ + --blend-mode:difference; + } + } + +.highlight:is(.canvasWrapper svg){ + + position:absolute; + mix-blend-mode:var(--blend-mode); + } + +.highlight:is(.canvasWrapper svg):not(.free){ + fill-rule:evenodd; + } + +.highlightOutline:is(.canvasWrapper svg){ + position:absolute; + mix-blend-mode:normal; + fill-rule:evenodd; + fill:none; + } + +.highlightOutline.hovered:is(.canvasWrapper svg):not(.free):not(.selected){ + stroke:var(--hover-outline-color); + stroke-width:var(--outline-width); + } + +.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .mainOutline{ + stroke:var(--outline-around-color); + stroke-width:calc( + var(--outline-width) + 2 * var(--outline-around-width) + ); + } + +.highlightOutline.selected:is(.canvasWrapper svg):not(.free) .secondaryOutline{ + stroke:var(--outline-color); + stroke-width:var(--outline-width); + } + +.highlightOutline.free.hovered:is(.canvasWrapper svg):not(.selected){ + stroke:var(--hover-outline-color); + stroke-width:calc(2 * var(--outline-width)); + } + +.highlightOutline.free.selected:is(.canvasWrapper svg) .mainOutline{ + stroke:var(--outline-around-color); + stroke-width:calc( + 2 * (var(--outline-width) + var(--outline-around-width)) + ); + } + +.highlightOutline.free.selected:is(.canvasWrapper svg) .secondaryOutline{ + stroke:var(--outline-color); + stroke-width:calc(2 * var(--outline-width)); + } + +.toggle-button{ + --button-background-color:#f0f0f4; + --button-background-color-hover:#e0e0e6; + --button-background-color-active:#cfcfd8; + --color-accent-primary:#0060df; + --color-accent-primary-hover:#0250bb; + --color-accent-primary-active:#054096; + --border-interactive-color:#8f8f9d; + --border-radius-circle:9999px; + --border-width:1px; + --size-item-small:16px; + --size-item-large:32px; + --color-canvas:white; +} + +@media (prefers-color-scheme: dark){ + +.toggle-button{ + --button-background-color:color-mix(in srgb, currentColor 7%, transparent); + --button-background-color-hover:color-mix( + in srgb, + currentColor 14%, + transparent + ); + --button-background-color-active:color-mix( + in srgb, + currentColor 21%, + transparent + ); + --color-accent-primary:#0df; + --color-accent-primary-hover:#80ebff; + --color-accent-primary-active:#aaf2ff; + --border-interactive-color:#bfbfc9; + --color-canvas:#1c1b22; +} + } + +@media (forced-colors: active){ + +.toggle-button{ + --color-accent-primary:ButtonText; + --color-accent-primary-hover:SelectedItem; + --color-accent-primary-active:SelectedItem; + --border-interactive-color:ButtonText; + --button-background-color:ButtonFace; + --border-interactive-color-hover:SelectedItem; + --border-interactive-color-active:SelectedItem; + --border-interactive-color-disabled:GrayText; + --color-canvas:ButtonText; +} + } + +.toggle-button{ + + --toggle-background-color:var(--button-background-color); + --toggle-background-color-hover:var(--button-background-color-hover); + --toggle-background-color-active:var(--button-background-color-active); + --toggle-background-color-pressed:var(--color-accent-primary); + --toggle-background-color-pressed-hover:var(--color-accent-primary-hover); + --toggle-background-color-pressed-active:var(--color-accent-primary-active); + --toggle-border-color:var(--border-interactive-color); + --toggle-border-color-hover:var(--toggle-border-color); + --toggle-border-color-active:var(--toggle-border-color); + --toggle-border-radius:var(--border-radius-circle); + --toggle-border-width:var(--border-width); + --toggle-height:var(--size-item-small); + --toggle-width:var(--size-item-large); + --toggle-dot-background-color:var(--toggle-border-color); + --toggle-dot-background-color-hover:var(--toggle-dot-background-color); + --toggle-dot-background-color-active:var(--toggle-dot-background-color); + --toggle-dot-background-color-on-pressed:var(--color-canvas); + --toggle-dot-margin:1px; + --toggle-dot-height:calc( + var(--toggle-height) - 2 * var(--toggle-dot-margin) - 2 * + var(--toggle-border-width) + ); + --toggle-dot-width:var(--toggle-dot-height); + --toggle-dot-transform-x:calc( + var(--toggle-width) - 4 * var(--toggle-dot-margin) - var(--toggle-dot-width) + ); + + -webkit-appearance:none; + + -moz-appearance:none; + + appearance:none; + padding:0; + margin:0; + border:var(--toggle-border-width) solid var(--toggle-border-color); + height:var(--toggle-height); + width:var(--toggle-width); + border-radius:var(--toggle-border-radius); + background:var(--toggle-background-color); + box-sizing:border-box; + flex-shrink:0; +} + +.toggle-button:focus-visible{ + outline:var(--focus-outline); + outline-offset:var(--focus-outline-offset); + } + +.toggle-button:enabled:hover{ + background:var(--toggle-background-color-hover); + border-color:var(--toggle-border-color); + } + +.toggle-button:enabled:active{ + background:var(--toggle-background-color-active); + border-color:var(--toggle-border-color); + } + +.toggle-button[aria-pressed="true"]{ + background:var(--toggle-background-color-pressed); + border-color:transparent; + } + +.toggle-button[aria-pressed="true"]:enabled:hover{ + background:var(--toggle-background-color-pressed-hover); + border-color:transparent; + } + +.toggle-button[aria-pressed="true"]:enabled:active{ + background:var(--toggle-background-color-pressed-active); + border-color:transparent; + } + +.toggle-button::before{ + display:block; + content:""; + background-color:var(--toggle-dot-background-color); + height:var(--toggle-dot-height); + width:var(--toggle-dot-width); + margin:var(--toggle-dot-margin); + border-radius:var(--toggle-border-radius); + translate:0; + } + +.toggle-button[aria-pressed="true"]::before{ + translate:var(--toggle-dot-transform-x); + background-color:var(--toggle-dot-background-color-on-pressed); + } + +.toggle-button[aria-pressed="true"]:enabled:hover::before,.toggle-button[aria-pressed="true"]:enabled:active::before{ + background-color:var(--toggle-dot-background-color-on-pressed); + } + +[dir="rtl"] .toggle-button[aria-pressed="true"]::before{ + translate:calc(-1 * var(--toggle-dot-transform-x)); + } + +@media (prefers-reduced-motion: no-preference){ + .toggle-button::before{ + transition:translate 100ms; + } + } + +@media (prefers-contrast){ + .toggle-button:enabled:hover{ + border-color:var(--toggle-border-color-hover); + } + + .toggle-button:enabled:active{ + border-color:var(--toggle-border-color-active); + } + + .toggle-button[aria-pressed="true"]:enabled{ + border-color:var(--toggle-border-color); + position:relative; + } + + .toggle-button[aria-pressed="true"]:enabled:hover,.toggle-button[aria-pressed="true"]:enabled:hover:active{ + border-color:var(--toggle-border-color-hover); + } + + .toggle-button[aria-pressed="true"]:enabled:active{ + background-color:var(--toggle-dot-background-color-active); + border-color:var(--toggle-dot-background-color-hover); + } + + .toggle-button:hover::before,.toggle-button:active::before{ + background-color:var(--toggle-dot-background-color-hover); + } + } + +@media (forced-colors){ + +.toggle-button{ + --toggle-dot-background-color:var(--color-accent-primary); + --toggle-dot-background-color-hover:var(--color-accent-primary-hover); + --toggle-dot-background-color-active:var(--color-accent-primary-active); + --toggle-dot-background-color-on-pressed:var(--button-background-color); + --toggle-background-color-disabled:var(--button-background-color-disabled); + --toggle-border-color-hover:var(--border-interactive-color-hover); + --toggle-border-color-active:var(--border-interactive-color-active); + --toggle-border-color-disabled:var(--border-interactive-color-disabled); +} + + .toggle-button[aria-pressed="true"]:enabled::after{ + border:1px solid var(--button-background-color); + content:""; + position:absolute; + height:var(--toggle-height); + width:var(--toggle-width); + display:block; + border-radius:var(--toggle-border-radius); + inset:-2px; + } + + .toggle-button[aria-pressed="true"]:enabled:active::after{ + border-color:var(--toggle-border-color-active); + } + } + +:root{ + --outline-width:2px; + --outline-color:#0060df; + --outline-around-width:1px; + --outline-around-color:#f0f0f4; + --hover-outline-around-color:var(--outline-around-color); + --focus-outline:solid var(--outline-width) var(--outline-color); + --unfocus-outline:solid var(--outline-width) transparent; + --focus-outline-around:solid var(--outline-around-width) var(--outline-around-color); + --hover-outline-color:#8f8f9d; + --hover-outline:solid var(--outline-width) var(--hover-outline-color); + --hover-outline-around:solid var(--outline-around-width) var(--hover-outline-around-color); + --freetext-line-height:1.35; + --freetext-padding:2px; + --resizer-bg-color:var(--outline-color); + --resizer-size:6px; + --resizer-shift:calc( + 0px - (var(--outline-width) + var(--resizer-size)) / 2 - + var(--outline-around-width) + ); + --editorFreeText-editing-cursor:text; + --editorInk-editing-cursor:url(images/cursor-editorInk.svg) 0 16, pointer; + --editorHighlight-editing-cursor:url(images/cursor-editorTextHighlight.svg) 24 24, text; + --editorFreeHighlight-editing-cursor:url(images/cursor-editorFreeHighlight.svg) 1 18, pointer; + + --new-alt-text-warning-image:url(images/altText_warning.svg); +} +.visuallyHidden{ + position:absolute; + top:0; + left:0; + border:0; + margin:0; + padding:0; + width:0; + height:0; + overflow:hidden; + white-space:nowrap; + font-size:0; +} + +.textLayer.highlighting{ + cursor:var(--editorFreeHighlight-editing-cursor); + } + +.textLayer.highlighting:not(.free) span{ + cursor:var(--editorHighlight-editing-cursor); + } + +[role="img"]:is(.textLayer.highlighting:not(.free) span){ + cursor:var(--editorFreeHighlight-editing-cursor); + } + +.textLayer.highlighting.free span{ + cursor:var(--editorFreeHighlight-editing-cursor); + } + +:is(#viewerContainer.pdfPresentationMode:fullscreen,.annotationEditorLayer.disabled) .noAltTextBadge{ + display:none !important; + } + +@media (min-resolution: 1.1dppx){ + :root{ + --editorFreeText-editing-cursor:url(images/cursor-editorFreeText.svg) 0 16, text; + } +} + +@media screen and (forced-colors: active){ + :root{ + --outline-color:CanvasText; + --outline-around-color:ButtonFace; + --resizer-bg-color:ButtonText; + --hover-outline-color:Highlight; + --hover-outline-around-color:SelectedItemText; + } +} + +[data-editor-rotation="90"]{ + transform:rotate(90deg); +} + +[data-editor-rotation="180"]{ + transform:rotate(180deg); +} + +[data-editor-rotation="270"]{ + transform:rotate(270deg); +} + +.annotationEditorLayer{ + background:transparent; + position:absolute; + inset:0; + font-size:calc(100px * var(--scale-factor)); + transform-origin:0 0; + cursor:auto; +} + +.annotationEditorLayer .selectedEditor{ + z-index:100000 !important; + } + +.annotationEditorLayer.drawing *{ + pointer-events:none !important; + } + +.annotationEditorLayer.waiting{ + content:""; + cursor:wait; + position:absolute; + inset:0; + width:100%; + height:100%; +} + +.annotationEditorLayer.disabled{ + pointer-events:none; +} + +.annotationEditorLayer.freetextEditing{ + cursor:var(--editorFreeText-editing-cursor); +} + +.annotationEditorLayer.inkEditing{ + cursor:var(--editorInk-editing-cursor); +} + +.annotationEditorLayer .draw{ + box-sizing:border-box; +} + +.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor){ + position:absolute; + background:transparent; + z-index:1; + transform-origin:0 0; + cursor:auto; + max-width:100%; + max-height:100%; + border:var(--unfocus-outline); +} + +.draggable.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)){ + cursor:move; + } + +.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)){ + border:var(--focus-outline); + outline:var(--focus-outline-around); + } + +.selectedEditor:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor))::before{ + content:""; + position:absolute; + inset:0; + border:var(--focus-outline-around); + pointer-events:none; + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)):hover:not(.selectedEditor){ + border:var(--hover-outline); + outline:var(--hover-outline-around); + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)):hover:not(.selectedEditor)::before{ + content:""; + position:absolute; + inset:0; + border:var(--focus-outline-around); + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + --editor-toolbar-delete-image:url(images/editor-toolbar-delete.svg); + --editor-toolbar-bg-color:#f0f0f4; + --editor-toolbar-highlight-image:url(images/toolbarButton-editorHighlight.svg); + --editor-toolbar-fg-color:#2e2e56; + --editor-toolbar-border-color:#8f8f9d; + --editor-toolbar-hover-border-color:var(--editor-toolbar-border-color); + --editor-toolbar-hover-bg-color:#e0e0e6; + --editor-toolbar-hover-fg-color:var(--editor-toolbar-fg-color); + --editor-toolbar-hover-outline:none; + --editor-toolbar-focus-outline-color:#0060df; + --editor-toolbar-shadow:0 2px 6px 0 rgb(58 57 68 / 0.2); + --editor-toolbar-vert-offset:6px; + --editor-toolbar-height:28px; + --editor-toolbar-padding:2px; + --alt-text-done-color:#2ac3a2; + --alt-text-warning-color:#0090ed; + --alt-text-hover-done-color:var(--alt-text-done-color); + --alt-text-hover-warning-color:var(--alt-text-warning-color); + } + +@media (prefers-color-scheme: dark){ + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + --editor-toolbar-bg-color:#2b2a33; + --editor-toolbar-fg-color:#fbfbfe; + --editor-toolbar-hover-bg-color:#52525e; + --editor-toolbar-focus-outline-color:#0df; + --alt-text-done-color:#54ffbd; + --alt-text-warning-color:#80ebff; + } + } + +@media screen and (forced-colors: active){ + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + --editor-toolbar-bg-color:ButtonFace; + --editor-toolbar-fg-color:ButtonText; + --editor-toolbar-border-color:ButtonText; + --editor-toolbar-hover-border-color:AccentColor; + --editor-toolbar-hover-bg-color:ButtonFace; + --editor-toolbar-hover-fg-color:AccentColor; + --editor-toolbar-hover-outline:2px solid var(--editor-toolbar-hover-border-color); + --editor-toolbar-focus-outline-color:ButtonBorder; + --editor-toolbar-shadow:none; + --alt-text-done-color:var(--editor-toolbar-fg-color); + --alt-text-warning-color:var(--editor-toolbar-fg-color); + --alt-text-hover-done-color:var(--editor-toolbar-hover-fg-color); + --alt-text-hover-warning-color:var(--editor-toolbar-hover-fg-color); + } + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar{ + + display:flex; + width:-moz-fit-content; + width:fit-content; + height:var(--editor-toolbar-height); + flex-direction:column; + justify-content:center; + align-items:center; + cursor:default; + pointer-events:auto; + box-sizing:content-box; + padding:var(--editor-toolbar-padding); + + position:absolute; + inset-inline-end:0; + inset-block-start:calc(100% + var(--editor-toolbar-vert-offset)); + + border-radius:6px; + background-color:var(--editor-toolbar-bg-color); + border:1px solid var(--editor-toolbar-border-color); + box-shadow:var(--editor-toolbar-shadow); + } + +.hidden:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar){ + display:none; + } + +:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar):has(:focus-visible){ + border-color:transparent; + } + +[dir="ltr"] :is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar){ + transform-origin:100% 0; + } + +[dir="rtl"] :is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar){ + transform-origin:0 0; + } + +:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons{ + display:flex; + justify-content:center; + align-items:center; + gap:0; + height:100%; + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) button{ + padding:0; + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .divider{ + width:0; + height:calc( + 2 * var(--editor-toolbar-padding) + var(--editor-toolbar-height) + ); + border-left:1px solid var(--editor-toolbar-border-color); + border-right:none; + display:inline-block; + margin-inline:2px; + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .highlightButton{ + width:var(--editor-toolbar-height); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .highlightButton)::before{ + content:""; + -webkit-mask-image:var(--editor-toolbar-highlight-image); + mask-image:var(--editor-toolbar-highlight-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + background-color:var(--editor-toolbar-fg-color); + width:100%; + height:100%; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .highlightButton):hover::before{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .delete{ + width:var(--editor-toolbar-height); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .delete)::before{ + content:""; + -webkit-mask-image:var(--editor-toolbar-delete-image); + mask-image:var(--editor-toolbar-delete-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + background-color:var(--editor-toolbar-fg-color); + width:100%; + height:100%; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .delete):hover::before{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > *{ + height:var(--editor-toolbar-height); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider){ + border:none; + background-color:transparent; + cursor:pointer; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):hover{ + border-radius:2px; + background-color:var(--editor-toolbar-hover-bg-color); + color:var(--editor-toolbar-hover-fg-color); + outline:var(--editor-toolbar-hover-outline); + outline-offset:1px; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):hover:active{ + outline:none; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) > :not(.divider)):focus-visible{ + border-radius:2px; + outline:2px solid var(--editor-toolbar-focus-outline-color); + } + +:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText{ + --alt-text-add-image:url(images/altText_add.svg); + --alt-text-done-image:url(images/altText_done.svg); + + display:flex; + align-items:center; + justify-content:center; + width:-moz-max-content; + width:max-content; + padding-inline:8px; + pointer-events:all; + font:menu; + font-weight:590; + font-size:12px; + color:var(--editor-toolbar-fg-color); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):disabled{ + pointer-events:none; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + content:""; + -webkit-mask-image:var(--alt-text-add-image); + mask-image:var(--alt-text-add-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + width:12px; + height:13px; + background-color:var(--editor-toolbar-fg-color); + margin-inline-end:4px; + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + -webkit-mask-image:var(--alt-text-done-image); + mask-image:var(--alt-text-done-image); + } + +.new:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + width:16px; + height:16px; + -webkit-mask-image:var(--new-alt-text-warning-image); + mask-image:var(--new-alt-text-warning-image); + background-color:var(--alt-text-warning-color); + -webkit-mask-size:cover; + mask-size:cover; + } + +.new:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ + background-color:var(--alt-text-hover-warning-color); + } + +.new.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText)::before{ + -webkit-mask-image:var(--alt-text-done-image); + mask-image:var(--alt-text-done-image); + background-color:var(--alt-text-done-color); + } + +.new.done:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText):hover::before{ + background-color:var(--alt-text-hover-done-color); + } + +:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip{ + display:none; + word-wrap:anywhere; + } + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + --alt-text-tooltip-bg:#f0f0f4; + --alt-text-tooltip-fg:#15141a; + --alt-text-tooltip-border:#8f8f9d; + --alt-text-tooltip-shadow:0px 2px 6px 0px rgb(58 57 68 / 0.2); + } + +@media (prefers-color-scheme: dark){ + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + --alt-text-tooltip-bg:#1c1b22; + --alt-text-tooltip-fg:#fbfbfe; + --alt-text-tooltip-shadow:0px 2px 6px 0px #15141a; + } + } + +@media screen and (forced-colors: active){ + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + --alt-text-tooltip-bg:Canvas; + --alt-text-tooltip-fg:CanvasText; + --alt-text-tooltip-border:CanvasText; + --alt-text-tooltip-shadow:none; + } + } + +.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){ + + display:inline-flex; + flex-direction:column; + align-items:center; + justify-content:center; + position:absolute; + top:calc(100% + 2px); + inset-inline-start:0; + padding-block:2px 3px; + padding-inline:3px; + max-width:300px; + width:-moz-max-content; + width:max-content; + height:auto; + font-size:12px; + + border:0.5px solid var(--alt-text-tooltip-border); + background:var(--alt-text-tooltip-bg); + box-shadow:var(--alt-text-tooltip-shadow); + color:var(--alt-text-tooltip-fg); + + pointer-events:none; + } + +.annotationEditorLayer .freeTextEditor{ + padding:calc(var(--freetext-padding) * var(--scale-factor)); + width:auto; + height:auto; + touch-action:none; +} + +.annotationEditorLayer .freeTextEditor .internal{ + background:transparent; + border:none; + inset:0; + overflow:visible; + white-space:nowrap; + font:10px sans-serif; + line-height:var(--freetext-line-height); + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; +} + +.annotationEditorLayer .freeTextEditor .overlay{ + position:absolute; + display:none; + background:transparent; + inset:0; + width:100%; + height:100%; +} + +.annotationEditorLayer freeTextEditor .overlay.enabled{ + display:block; +} + +.annotationEditorLayer .freeTextEditor .internal:empty::before{ + content:attr(default-content); + color:gray; +} + +.annotationEditorLayer .freeTextEditor .internal:focus{ + outline:none; + -webkit-user-select:auto; + -moz-user-select:auto; + user-select:auto; +} + +.annotationEditorLayer .inkEditor{ + width:100%; + height:100%; +} + +.annotationEditorLayer .inkEditor.editing{ + cursor:inherit; +} + +.annotationEditorLayer .inkEditor .inkEditorCanvas{ + position:absolute; + inset:0; + width:100%; + height:100%; + touch-action:none; +} + +.annotationEditorLayer .stampEditor{ + width:auto; + height:auto; +} + +:is(.annotationEditorLayer .stampEditor) canvas{ + position:absolute; + width:100%; + height:100%; + margin:0; + top:0; + left:0; + } + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + --no-alt-text-badge-border-color:#f0f0f4; + --no-alt-text-badge-bg-color:#cfcfd8; + --no-alt-text-badge-fg-color:#5b5b66; + } + +@media (prefers-color-scheme: dark){ + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + --no-alt-text-badge-border-color:#52525e; + --no-alt-text-badge-bg-color:#fbfbfe; + --no-alt-text-badge-fg-color:#15141a; + } + } + +@media screen and (forced-colors: active){ + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + --no-alt-text-badge-border-color:ButtonText; + --no-alt-text-badge-bg-color:ButtonFace; + --no-alt-text-badge-fg-color:ButtonText; + } + } + +:is(.annotationEditorLayer .stampEditor) .noAltTextBadge{ + + position:absolute; + inset-inline-end:5px; + inset-block-end:5px; + display:inline-flex; + width:32px; + height:32px; + padding:3px; + justify-content:center; + align-items:center; + pointer-events:none; + z-index:1; + + border-radius:2px; + border:1px solid var(--no-alt-text-badge-border-color); + background:var(--no-alt-text-badge-bg-color); + } + +:is(:is(.annotationEditorLayer .stampEditor) .noAltTextBadge)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--new-alt-text-warning-image); + mask-image:var(--new-alt-text-warning-image); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--no-alt-text-badge-fg-color); + } + +:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers{ + position:absolute; + inset:0; + } + +.hidden:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers){ + display:none; + } + +:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer{ + width:var(--resizer-size); + height:var(--resizer-size); + background:content-box var(--resizer-bg-color); + border:var(--focus-outline-around); + border-radius:2px; + position:absolute; + } + +.topLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:var(--resizer-shift); + left:var(--resizer-shift); + } + +.topMiddle:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:var(--resizer-shift); + left:calc(50% + var(--resizer-shift)); + } + +.topRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:var(--resizer-shift); + right:var(--resizer-shift); + } + +.middleRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:calc(50% + var(--resizer-shift)); + right:var(--resizer-shift); + } + +.bottomRight:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + bottom:var(--resizer-shift); + right:var(--resizer-shift); + } + +.bottomMiddle:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + bottom:var(--resizer-shift); + left:calc(50% + var(--resizer-shift)); + } + +.bottomLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + bottom:var(--resizer-shift); + left:var(--resizer-shift); + } + +.middleLeft:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor)) > .resizers) > .resizer){ + top:calc(50% + var(--resizer-shift)); + left:var(--resizer-shift); + } + +.topLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:nwse-resize; + } + +.topMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:ns-resize; + } + +.topRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.bottomLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:nesw-resize; + } + +.middleRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer),.middleLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="180"],[data-editor-rotation="0"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="90"],[data-editor-rotation="270"])) > .resizers > .resizer){ + cursor:ew-resize; + } + +.topLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:nesw-resize; + } + +.topMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomMiddle:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:ew-resize; + } + +.topRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.bottomLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:nwse-resize; + } + +.middleRight:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer),.middleLeft:is(:is(.annotationEditorLayer[data-main-rotation="0"] :is([data-editor-rotation="90"],[data-editor-rotation="270"]),.annotationEditorLayer[data-main-rotation="90"] :is([data-editor-rotation="0"],[data-editor-rotation="180"]),.annotationEditorLayer[data-main-rotation="180"] :is([data-editor-rotation="270"],[data-editor-rotation="90"]),.annotationEditorLayer[data-main-rotation="270"] :is([data-editor-rotation="180"],[data-editor-rotation="0"])) > .resizers > .resizer){ + cursor:ns-resize; + } + +:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar{ + rotate:270deg; + } + +[dir="ltr"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar){ + inset-inline-end:calc(0px - var(--editor-toolbar-vert-offset)); + inset-block-start:0; + } + +[dir="rtl"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="90"],[data-main-rotation="90"] [data-editor-rotation="0"],[data-main-rotation="180"] [data-editor-rotation="270"],[data-main-rotation="270"] [data-editor-rotation="180"])) .editToolbar){ + inset-inline-end:calc(100% + var(--editor-toolbar-vert-offset)); + inset-block-start:0; + } + +:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="180"],[data-main-rotation="90"] [data-editor-rotation="90"],[data-main-rotation="180"] [data-editor-rotation="0"],[data-main-rotation="270"] [data-editor-rotation="270"])) .editToolbar{ + rotate:180deg; + inset-inline-end:100%; + inset-block-start:calc(0pc - var(--editor-toolbar-vert-offset)); + } + +:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar{ + rotate:90deg; + } + +[dir="ltr"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar){ + inset-inline-end:calc(100% + var(--editor-toolbar-vert-offset)); + inset-block-start:100%; + } + +[dir="rtl"] :is(:is(.annotationEditorLayer :is([data-main-rotation="0"] [data-editor-rotation="270"],[data-main-rotation="90"] [data-editor-rotation="180"],[data-main-rotation="180"] [data-editor-rotation="90"],[data-main-rotation="270"] [data-editor-rotation="0"])) .editToolbar){ + inset-inline-start:calc(0px - var(--editor-toolbar-vert-offset)); + inset-block-start:0; + } + +.dialog.altText::backdrop{ + -webkit-mask:url(#alttext-manager-mask); + mask:url(#alttext-manager-mask); + } + +.dialog.altText.positioned{ + margin:0; + } + +.dialog.altText #altTextContainer{ + width:300px; + height:-moz-fit-content; + height:fit-content; + display:inline-flex; + flex-direction:column; + align-items:flex-start; + gap:16px; + } + +:is(.dialog.altText #altTextContainer) #overallDescription{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:4px; + align-self:stretch; + } + +:is(:is(.dialog.altText #altTextContainer) #overallDescription) span{ + align-self:stretch; + } + +:is(:is(.dialog.altText #altTextContainer) #overallDescription) .title{ + font-size:13px; + font-style:normal; + font-weight:590; + } + +:is(.dialog.altText #altTextContainer) #addDescription{ + display:flex; + flex-direction:column; + align-items:stretch; + gap:8px; + } + +:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea{ + flex:1; + padding-inline:24px 10px; + } + +:is(:is(:is(.dialog.altText #altTextContainer) #addDescription) .descriptionArea) textarea{ + width:100%; + min-height:75px; + } + +:is(.dialog.altText #altTextContainer) #buttons{ + display:flex; + justify-content:flex-end; + align-items:flex-start; + gap:8px; + align-self:stretch; + } + +.dialog.newAltText{ + --new-alt-text-ai-disclaimer-icon:url(images/altText_disclaimer.svg); + --new-alt-text-spinner-icon:url(images/altText_spinner.svg); + --preview-image-bg-color:#f0f0f4; + --preview-image-border:none; +} + +@media (prefers-color-scheme: dark){ + +.dialog.newAltText{ + --preview-image-bg-color:#2b2a33; +} + } + +@media screen and (forced-colors: active){ + +.dialog.newAltText{ + --preview-image-bg-color:ButtonFace; + --preview-image-border:1px solid ButtonText; +} + } + +.dialog.newAltText{ + + width:80%; + max-width:570px; + min-width:300px; + padding:0; +} + +.dialog.newAltText.noAi #newAltTextDisclaimer,.dialog.newAltText.noAi #newAltTextCreateAutomatically{ + display:none !important; + } + +.dialog.newAltText.aiInstalling #newAltTextCreateAutomatically{ + display:none !important; + } + +.dialog.newAltText.aiInstalling #newAltTextDownloadModel{ + display:flex !important; + } + +.dialog.newAltText.error #newAltTextNotNow{ + display:none !important; + } + +.dialog.newAltText.error #newAltTextCancel{ + display:inline-block !important; + } + +.dialog.newAltText:not(.error) #newAltTextError{ + display:none !important; + } + +.dialog.newAltText #newAltTextContainer{ + display:flex; + width:auto; + padding:16px; + flex-direction:column; + justify-content:flex-end; + align-items:flex-start; + gap:12px; + flex:0 1 auto; + line-height:normal; + } + +:is(.dialog.newAltText #newAltTextContainer) #mainContent{ + display:flex; + justify-content:flex-end; + align-items:flex-start; + gap:12px; + align-self:stretch; + flex:1 1 auto; + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionAndSettings{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:16px; + flex:1 0 0; + align-self:stretch; + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:8px; + align-self:stretch; + flex:1 1 auto; + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer{ + width:100%; + height:70px; + position:relative; + } + +:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea{ + width:100%; + height:100%; + padding:8px; + } + +:is(:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea)::-moz-placeholder{ + color:var(--text-secondary-color); + } + +:is(:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea)::placeholder{ + color:var(--text-secondary-color); + } + +:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) .altTextSpinner{ + display:none; + position:absolute; + width:16px; + height:16px; + inset-inline-start:8px; + inset-block-start:8px; + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--text-secondary-color); + pointer-events:none; + } + +.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea::-moz-placeholder{ + color:transparent; + } + +.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) textarea::placeholder{ + color:transparent; + } + +.loading:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescriptionContainer) .altTextSpinner{ + display:inline-block; + -webkit-mask-image:var(--new-alt-text-spinner-icon); + mask-image:var(--new-alt-text-spinner-icon); + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDescription{ + font-size:11px; + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDisclaimer{ + display:flex; + flex-direction:row; + align-items:flex-start; + gap:4px; + font-size:11px; + } + +:is(:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #descriptionInstruction) #newAltTextDisclaimer)::before{ + content:""; + display:inline-block; + width:17px; + height:16px; + -webkit-mask-image:var(--new-alt-text-ai-disclaimer-icon); + mask-image:var(--new-alt-text-ai-disclaimer-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--text-secondary-color); + flex:1 0 auto; + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextDownloadModel{ + display:flex; + align-items:center; + gap:4px; + align-self:stretch; + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextDownloadModel)::before{ + content:""; + display:inline-block; + width:16px; + height:16px; + -webkit-mask-image:var(--new-alt-text-spinner-icon); + mask-image:var(--new-alt-text-spinner-icon); + -webkit-mask-size:cover; + mask-size:cover; + background-color:var(--text-secondary-color); + } + +:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextImagePreview{ + width:180px; + aspect-ratio:1; + display:flex; + justify-content:center; + align-items:center; + flex:0 0 auto; + background-color:var(--preview-image-bg-color); + border:var(--preview-image-border); + } + +:is(:is(:is(.dialog.newAltText #newAltTextContainer) #mainContent) #newAltTextImagePreview) > canvas{ + max-width:100%; + max-height:100%; + } + +.colorPicker{ + --hover-outline-color:#0250bb; + --selected-outline-color:#0060df; + --swatch-border-color:#cfcfd8; +} + +@media (prefers-color-scheme: dark){ + +.colorPicker{ + --hover-outline-color:#80ebff; + --selected-outline-color:#aaf2ff; + --swatch-border-color:#52525e; +} + } + +@media screen and (forced-colors: active){ + +.colorPicker{ + --hover-outline-color:Highlight; + --selected-outline-color:var(--hover-outline-color); + --swatch-border-color:ButtonText; +} + } + +.colorPicker .swatch{ + width:16px; + height:16px; + border:1px solid var(--swatch-border-color); + border-radius:100%; + outline-offset:2px; + box-sizing:border-box; + forced-color-adjust:none; + } + +.colorPicker button:is(:hover,.selected) > .swatch{ + border:none; + } + +.annotationEditorLayer[data-main-rotation="0"] .highlightEditor:not(.free) > .editToolbar{ + rotate:0deg; + } + +.annotationEditorLayer[data-main-rotation="90"] .highlightEditor:not(.free) > .editToolbar{ + rotate:270deg; + } + +.annotationEditorLayer[data-main-rotation="180"] .highlightEditor:not(.free) > .editToolbar{ + rotate:180deg; + } + +.annotationEditorLayer[data-main-rotation="270"] .highlightEditor:not(.free) > .editToolbar{ + rotate:90deg; + } + +.annotationEditorLayer .highlightEditor{ + position:absolute; + background:transparent; + z-index:1; + cursor:auto; + max-width:100%; + max-height:100%; + border:none; + outline:none; + pointer-events:none; + transform-origin:0 0; + } + +:is(.annotationEditorLayer .highlightEditor):not(.free){ + transform:none; + } + +:is(.annotationEditorLayer .highlightEditor) .internal{ + position:absolute; + top:0; + left:0; + width:100%; + height:100%; + pointer-events:auto; + } + +.disabled:is(.annotationEditorLayer .highlightEditor) .internal{ + pointer-events:none; + } + +.selectedEditor:is(.annotationEditorLayer .highlightEditor) .internal{ + cursor:pointer; + } + +:is(.annotationEditorLayer .highlightEditor) .editToolbar{ + --editor-toolbar-colorpicker-arrow-image:url(images/toolbarButton-menuArrow.svg); + + transform-origin:center !important; + } + +:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker{ + position:relative; + width:auto; + display:flex; + justify-content:center; + align-items:center; + gap:4px; + padding:4px; + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker)::after{ + content:""; + -webkit-mask-image:var(--editor-toolbar-colorpicker-arrow-image); + mask-image:var(--editor-toolbar-colorpicker-arrow-image); + -webkit-mask-repeat:no-repeat; + mask-repeat:no-repeat; + -webkit-mask-position:center; + mask-position:center; + display:inline-block; + background-color:var(--editor-toolbar-fg-color); + width:12px; + height:12px; + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):hover::after{ + background-color:var(--editor-toolbar-hover-fg-color); + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):has(.dropdown:not(.hidden)){ + background-color:var(--editor-toolbar-hover-bg-color); + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker):has(.dropdown:not(.hidden))::after{ + scale:-1; + } + +:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown{ + position:absolute; + display:flex; + justify-content:center; + align-items:center; + flex-direction:column; + gap:11px; + padding-block:8px; + border-radius:6px; + background-color:var(--editor-toolbar-bg-color); + border:1px solid var(--editor-toolbar-border-color); + box-shadow:var(--editor-toolbar-shadow); + inset-block-start:calc(100% + 4px); + width:calc(100% + 2 * var(--editor-toolbar-padding)); + } + +:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button{ + width:100%; + height:auto; + border:none; + cursor:pointer; + display:flex; + justify-content:center; + align-items:center; + background:none; + } + +:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button):is(:active,:focus-visible){ + outline:none; + } + +:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button) > .swatch{ + outline-offset:2px; + } + +[aria-selected="true"]:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button) > .swatch{ + outline:2px solid var(--selected-outline-color); + } + +:is(:is(:is(:is(:is(:is(.annotationEditorLayer .highlightEditor) .editToolbar) .buttons) .colorPicker) .dropdown) button):is(:hover,:active,:focus-visible) > .swatch{ + outline:2px solid var(--hover-outline-color); + } + +.editorParamsToolbar:has(#highlightParamsToolbarContainer){ + padding:unset; +} + +#highlightParamsToolbarContainer{ + gap:16px; + padding-inline:10px; + padding-block-end:12px; +} + +#highlightParamsToolbarContainer .colorPicker{ + display:flex; + flex-direction:column; + gap:8px; + } + +:is(#highlightParamsToolbarContainer .colorPicker) .dropdown{ + display:flex; + justify-content:space-between; + align-items:center; + flex-direction:row; + height:auto; + } + +:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button{ + width:auto; + height:auto; + border:none; + cursor:pointer; + display:flex; + justify-content:center; + align-items:center; + background:none; + flex:0 0 auto; + padding:0; + } + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) .swatch{ + width:24px; + height:24px; + } + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button):is(:active,:focus-visible){ + outline:none; + } + +[aria-selected="true"]:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button) > .swatch{ + outline:2px solid var(--selected-outline-color); + } + +:is(:is(:is(#highlightParamsToolbarContainer .colorPicker) .dropdown) button):is(:hover,:active,:focus-visible) > .swatch{ + outline:2px solid var(--hover-outline-color); + } + +#highlightParamsToolbarContainer #editorHighlightThickness{ + display:flex; + flex-direction:column; + align-items:center; + gap:4px; + align-self:stretch; + } + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .editorParamsLabel{ + height:auto; + align-self:stretch; + } + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ + display:flex; + justify-content:space-between; + align-items:center; + align-self:stretch; + + --example-color:#bfbfc9; + } + +@media (prefers-color-scheme: dark){ + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ + --example-color:#80808e; + } + } + +@media screen and (forced-colors: active){ + +:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker{ + --example-color:CanvasText; + } + } + +:is(:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) > .editorParamsSlider[disabled]){ + opacity:0.4; + } + +:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::before,:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::after{ + content:""; + width:8px; + aspect-ratio:1; + display:block; + border-radius:100%; + background-color:var(--example-color); + } + +:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker)::after{ + width:24px; + } + +:is(:is(#highlightParamsToolbarContainer #editorHighlightThickness) .thicknessPicker) .editorParamsSlider{ + width:unset; + height:14px; + } + +#highlightParamsToolbarContainer #editorHighlightVisibility{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:8px; + align-self:stretch; + } + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + --divider-color:#d7d7db; + } + +@media (prefers-color-scheme: dark){ + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + --divider-color:#8f8f9d; + } + } + +@media screen and (forced-colors: active){ + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + --divider-color:CanvasText; + } + } + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .divider{ + + margin-block:4px; + width:100%; + height:1px; + background-color:var(--divider-color); + } + +:is(#highlightParamsToolbarContainer #editorHighlightVisibility) .toggler{ + display:flex; + justify-content:space-between; + align-items:center; + align-self:stretch; + } + +#altTextSettingsDialog{ + padding:16px; +} + +#altTextSettingsDialog #altTextSettingsContainer{ + display:flex; + width:573px; + flex-direction:column; + gap:16px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) .mainContainer{ + gap:16px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) .description{ + color:var(--text-secondary-color); + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings{ + display:flex; + flex-direction:column; + gap:12px; + } + +:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) button{ + width:-moz-fit-content; + width:fit-content; + } + +.download:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings) #deleteModelButton{ + display:none; + } + +:is(:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings):not(.download) #downloadModelButton{ + display:none; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticAltText,:is(#altTextSettingsDialog #altTextSettingsContainer) #altTextEditor{ + display:flex; + flex-direction:column; + gap:8px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #createModelDescription,:is(#altTextSettingsDialog #altTextSettingsContainer) #aiModelSettings,:is(#altTextSettingsDialog #altTextSettingsContainer) #showAltTextDialogDescription{ + padding-inline-start:40px; + } + +:is(#altTextSettingsDialog #altTextSettingsContainer) #automaticSettings{ + display:flex; + flex-direction:column; + gap:16px; + } + +:root{ + --viewer-container-height:0; + --pdfViewer-padding-bottom:0; + --page-margin:1px auto -8px; + --page-border:9px solid transparent; + --spreadHorizontalWrapped-margin-LR:-3.5px; + --loading-icon-delay:400ms; +} + +@media screen and (forced-colors: active){ + :root{ + --pdfViewer-padding-bottom:9px; + --page-margin:8px auto -1px; + --page-border:1px solid CanvasText; + --spreadHorizontalWrapped-margin-LR:3.5px; + } +} + +[data-main-rotation="90"]{ + transform:rotate(90deg) translateY(-100%); +} +[data-main-rotation="180"]{ + transform:rotate(180deg) translate(-100%, -100%); +} +[data-main-rotation="270"]{ + transform:rotate(270deg) translateX(-100%); +} + +#hiddenCopyElement, +.hiddenCanvasElement{ + position:absolute; + top:0; + left:0; + width:0; + height:0; + display:none; +} + +.pdfViewer{ + --scale-factor:1; + --page-bg-color:unset; + + padding-bottom:var(--pdfViewer-padding-bottom); + + --hcm-highlight-filter:none; + --hcm-highlight-selected-filter:none; +} + +@media screen and (forced-colors: active){ + +.pdfViewer{ + --hcm-highlight-filter:invert(100%); +} + } + +.pdfViewer.copyAll{ + cursor:wait; + } + +.pdfViewer .canvasWrapper{ + overflow:hidden; + width:100%; + height:100%; + } + +:is(.pdfViewer .canvasWrapper) canvas{ + position:absolute; + top:0; + left:0; + margin:0; + display:block; + width:100%; + height:100%; + contain:content; + } + +:is(:is(.pdfViewer .canvasWrapper) canvas) .structTree{ + contain:strict; + } + +.pdfViewer .page{ + --scale-round-x:1px; + --scale-round-y:1px; + + direction:ltr; + width:816px; + height:1056px; + margin:var(--page-margin); + position:relative; + overflow:visible; + border:var(--page-border); + background-clip:content-box; + background-color:var(--page-bg-color, rgb(255 255 255)); +} + +.pdfViewer .dummyPage{ + position:relative; + width:0; + height:var(--viewer-container-height); +} + +.pdfViewer.noUserSelect{ + -webkit-user-select:none; + -moz-user-select:none; + user-select:none; +} + +.pdfViewer.removePageBorders .page{ + margin:0 auto 10px; + border:none; +} + +.pdfViewer.singlePageView{ + display:inline-block; +} + +.pdfViewer.singlePageView .page{ + margin:0; + border:none; +} + +.pdfViewer:is(.scrollHorizontal, .scrollWrapped), +.spread{ + margin-inline:3.5px; + text-align:center; +} + +.pdfViewer.scrollHorizontal, +.spread{ + white-space:nowrap; +} + +.pdfViewer.removePageBorders, +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .spread{ + margin-inline:0; +} + +.spread :is(.page, .dummyPage), +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) :is(.page, .spread){ + display:inline-block; + vertical-align:middle; +} + +.spread .page, +.pdfViewer:is(.scrollHorizontal, .scrollWrapped) .page{ + margin-inline:var(--spreadHorizontalWrapped-margin-LR); +} + +.pdfViewer.removePageBorders .spread .page, +.pdfViewer.removePageBorders:is(.scrollHorizontal, .scrollWrapped) .page{ + margin-inline:5px; +} + +.pdfViewer .page.loadingIcon::after{ + position:absolute; + top:0; + left:0; + content:""; + width:100%; + height:100%; + background:url("images/loading-icon.gif") center no-repeat; + display:none; + transition-property:display; + transition-delay:var(--loading-icon-delay); + z-index:5; + contain:strict; +} + +.pdfViewer .page.loading::after{ + display:block; +} + +.pdfViewer .page:not(.loading)::after{ + transition-property:none; + display:none; +} + +.pdfPresentationMode .pdfViewer{ + padding-bottom:0; +} + +.pdfPresentationMode .spread{ + margin:0; +} + +.pdfPresentationMode .pdfViewer .page{ + margin:0 auto; + border:2px solid transparent; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs new file mode 100644 index 00000000000..9b2c200c99e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/pdfjs-dist/dist/web/pdf_viewer.mjs @@ -0,0 +1,8435 @@ +/** + * @licstart The following is the entire license notice for the + * JavaScript code in this page + * + * Copyright 2024 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @licend The above is the entire license notice for the + * JavaScript code in this page + */ + +/******/ // The require scope +/******/ var __webpack_require__ = {}; +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/************************************************************************/ +var __webpack_exports__ = globalThis.pdfjsViewer = {}; + +// EXPORTS +__webpack_require__.d(__webpack_exports__, { + AnnotationLayerBuilder: () => (/* reexport */ AnnotationLayerBuilder), + DownloadManager: () => (/* reexport */ DownloadManager), + EventBus: () => (/* reexport */ EventBus), + FindState: () => (/* reexport */ FindState), + GenericL10n: () => (/* reexport */ genericl10n_GenericL10n), + LinkTarget: () => (/* reexport */ LinkTarget), + PDFFindController: () => (/* reexport */ PDFFindController), + PDFHistory: () => (/* reexport */ PDFHistory), + PDFLinkService: () => (/* reexport */ PDFLinkService), + PDFPageView: () => (/* reexport */ PDFPageView), + PDFScriptingManager: () => (/* reexport */ PDFScriptingManagerComponents), + PDFSinglePageViewer: () => (/* reexport */ PDFSinglePageViewer), + PDFViewer: () => (/* reexport */ PDFViewer), + ProgressBar: () => (/* reexport */ ProgressBar), + RenderingStates: () => (/* reexport */ RenderingStates), + ScrollMode: () => (/* reexport */ ScrollMode), + SimpleLinkService: () => (/* reexport */ SimpleLinkService), + SpreadMode: () => (/* reexport */ SpreadMode), + StructTreeLayerBuilder: () => (/* reexport */ StructTreeLayerBuilder), + TextLayerBuilder: () => (/* reexport */ TextLayerBuilder), + XfaLayerBuilder: () => (/* reexport */ XfaLayerBuilder), + parseQueryString: () => (/* reexport */ parseQueryString) +}); + +;// ./web/ui_utils.js +const DEFAULT_SCALE_VALUE = "auto"; +const DEFAULT_SCALE = 1.0; +const DEFAULT_SCALE_DELTA = 1.1; +const MIN_SCALE = 0.1; +const MAX_SCALE = 10.0; +const UNKNOWN_SCALE = 0; +const MAX_AUTO_SCALE = 1.25; +const SCROLLBAR_PADDING = 40; +const VERTICAL_PADDING = 5; +const RenderingStates = { + INITIAL: 0, + RUNNING: 1, + PAUSED: 2, + FINISHED: 3 +}; +const PresentationModeState = { + UNKNOWN: 0, + NORMAL: 1, + CHANGING: 2, + FULLSCREEN: 3 +}; +const SidebarView = { + UNKNOWN: -1, + NONE: 0, + THUMBS: 1, + OUTLINE: 2, + ATTACHMENTS: 3, + LAYERS: 4 +}; +const TextLayerMode = { + DISABLE: 0, + ENABLE: 1, + ENABLE_PERMISSIONS: 2 +}; +const ScrollMode = { + UNKNOWN: -1, + VERTICAL: 0, + HORIZONTAL: 1, + WRAPPED: 2, + PAGE: 3 +}; +const SpreadMode = { + UNKNOWN: -1, + NONE: 0, + ODD: 1, + EVEN: 2 +}; +const CursorTool = { + SELECT: 0, + HAND: 1, + ZOOM: 2 +}; +const AutoPrintRegExp = /\bprint\s*\(/; +function scrollIntoView(element, spot, scrollMatches = false) { + let parent = element.offsetParent; + if (!parent) { + console.error("offsetParent is not set -- cannot scroll"); + return; + } + let offsetY = element.offsetTop + element.clientTop; + let offsetX = element.offsetLeft + element.clientLeft; + while (parent.clientHeight === parent.scrollHeight && parent.clientWidth === parent.scrollWidth || scrollMatches && (parent.classList.contains("markedContent") || getComputedStyle(parent).overflow === "hidden")) { + offsetY += parent.offsetTop; + offsetX += parent.offsetLeft; + parent = parent.offsetParent; + if (!parent) { + return; + } + } + if (spot) { + if (spot.top !== undefined) { + offsetY += spot.top; + } + if (spot.left !== undefined) { + offsetX += spot.left; + parent.scrollLeft = offsetX; + } + } + parent.scrollTop = offsetY; +} +function watchScroll(viewAreaElement, callback, abortSignal = undefined) { + const debounceScroll = function (evt) { + if (rAF) { + return; + } + rAF = window.requestAnimationFrame(function viewAreaElementScrolled() { + rAF = null; + const currentX = viewAreaElement.scrollLeft; + const lastX = state.lastX; + if (currentX !== lastX) { + state.right = currentX > lastX; + } + state.lastX = currentX; + const currentY = viewAreaElement.scrollTop; + const lastY = state.lastY; + if (currentY !== lastY) { + state.down = currentY > lastY; + } + state.lastY = currentY; + callback(state); + }); + }; + const state = { + right: true, + down: true, + lastX: viewAreaElement.scrollLeft, + lastY: viewAreaElement.scrollTop, + _eventHandler: debounceScroll + }; + let rAF = null; + viewAreaElement.addEventListener("scroll", debounceScroll, { + useCapture: true, + signal: abortSignal + }); + abortSignal?.addEventListener("abort", () => window.cancelAnimationFrame(rAF), { + once: true + }); + return state; +} +function parseQueryString(query) { + const params = new Map(); + for (const [key, value] of new URLSearchParams(query)) { + params.set(key.toLowerCase(), value); + } + return params; +} +const InvisibleCharsRegExp = /[\x00-\x1F]/g; +function removeNullCharacters(str, replaceInvisible = false) { + if (!InvisibleCharsRegExp.test(str)) { + return str; + } + if (replaceInvisible) { + return str.replaceAll(InvisibleCharsRegExp, m => m === "\x00" ? "" : " "); + } + return str.replaceAll("\x00", ""); +} +function binarySearchFirstItem(items, condition, start = 0) { + let minIndex = start; + let maxIndex = items.length - 1; + if (maxIndex < 0 || !condition(items[maxIndex])) { + return items.length; + } + if (condition(items[minIndex])) { + return minIndex; + } + while (minIndex < maxIndex) { + const currentIndex = minIndex + maxIndex >> 1; + const currentItem = items[currentIndex]; + if (condition(currentItem)) { + maxIndex = currentIndex; + } else { + minIndex = currentIndex + 1; + } + } + return minIndex; +} +function approximateFraction(x) { + if (Math.floor(x) === x) { + return [x, 1]; + } + const xinv = 1 / x; + const limit = 8; + if (xinv > limit) { + return [1, limit]; + } else if (Math.floor(xinv) === xinv) { + return [1, xinv]; + } + const x_ = x > 1 ? xinv : x; + let a = 0, + b = 1, + c = 1, + d = 1; + while (true) { + const p = a + c, + q = b + d; + if (q > limit) { + break; + } + if (x_ <= p / q) { + c = p; + d = q; + } else { + a = p; + b = q; + } + } + let result; + if (x_ - a / b < c / d - x_) { + result = x_ === x ? [a, b] : [b, a]; + } else { + result = x_ === x ? [c, d] : [d, c]; + } + return result; +} +function floorToDivide(x, div) { + return x - x % div; +} +function getPageSizeInches({ + view, + userUnit, + rotate +}) { + const [x1, y1, x2, y2] = view; + const changeOrientation = rotate % 180 !== 0; + const width = (x2 - x1) / 72 * userUnit; + const height = (y2 - y1) / 72 * userUnit; + return { + width: changeOrientation ? height : width, + height: changeOrientation ? width : height + }; +} +function backtrackBeforeAllVisibleElements(index, views, top) { + if (index < 2) { + return index; + } + let elt = views[index].div; + let pageTop = elt.offsetTop + elt.clientTop; + if (pageTop >= top) { + elt = views[index - 1].div; + pageTop = elt.offsetTop + elt.clientTop; + } + for (let i = index - 2; i >= 0; --i) { + elt = views[i].div; + if (elt.offsetTop + elt.clientTop + elt.clientHeight <= pageTop) { + break; + } + index = i; + } + return index; +} +function getVisibleElements({ + scrollEl, + views, + sortByVisibility = false, + horizontal = false, + rtl = false +}) { + const top = scrollEl.scrollTop, + bottom = top + scrollEl.clientHeight; + const left = scrollEl.scrollLeft, + right = left + scrollEl.clientWidth; + function isElementBottomAfterViewTop(view) { + const element = view.div; + const elementBottom = element.offsetTop + element.clientTop + element.clientHeight; + return elementBottom > top; + } + function isElementNextAfterViewHorizontally(view) { + const element = view.div; + const elementLeft = element.offsetLeft + element.clientLeft; + const elementRight = elementLeft + element.clientWidth; + return rtl ? elementLeft < right : elementRight > left; + } + const visible = [], + ids = new Set(), + numViews = views.length; + let firstVisibleElementInd = binarySearchFirstItem(views, horizontal ? isElementNextAfterViewHorizontally : isElementBottomAfterViewTop); + if (firstVisibleElementInd > 0 && firstVisibleElementInd < numViews && !horizontal) { + firstVisibleElementInd = backtrackBeforeAllVisibleElements(firstVisibleElementInd, views, top); + } + let lastEdge = horizontal ? right : -1; + for (let i = firstVisibleElementInd; i < numViews; i++) { + const view = views[i], + element = view.div; + const currentWidth = element.offsetLeft + element.clientLeft; + const currentHeight = element.offsetTop + element.clientTop; + const viewWidth = element.clientWidth, + viewHeight = element.clientHeight; + const viewRight = currentWidth + viewWidth; + const viewBottom = currentHeight + viewHeight; + if (lastEdge === -1) { + if (viewBottom >= bottom) { + lastEdge = viewBottom; + } + } else if ((horizontal ? currentWidth : currentHeight) > lastEdge) { + break; + } + if (viewBottom <= top || currentHeight >= bottom || viewRight <= left || currentWidth >= right) { + continue; + } + const hiddenHeight = Math.max(0, top - currentHeight) + Math.max(0, viewBottom - bottom); + const hiddenWidth = Math.max(0, left - currentWidth) + Math.max(0, viewRight - right); + const fractionHeight = (viewHeight - hiddenHeight) / viewHeight, + fractionWidth = (viewWidth - hiddenWidth) / viewWidth; + const percent = fractionHeight * fractionWidth * 100 | 0; + visible.push({ + id: view.id, + x: currentWidth, + y: currentHeight, + view, + percent, + widthPercent: fractionWidth * 100 | 0 + }); + ids.add(view.id); + } + const first = visible[0], + last = visible.at(-1); + if (sortByVisibility) { + visible.sort(function (a, b) { + const pc = a.percent - b.percent; + if (Math.abs(pc) > 0.001) { + return -pc; + } + return a.id - b.id; + }); + } + return { + first, + last, + views: visible, + ids + }; +} +function normalizeWheelEventDirection(evt) { + let delta = Math.hypot(evt.deltaX, evt.deltaY); + const angle = Math.atan2(evt.deltaY, evt.deltaX); + if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) { + delta = -delta; + } + return delta; +} +function normalizeWheelEventDelta(evt) { + const deltaMode = evt.deltaMode; + let delta = normalizeWheelEventDirection(evt); + const MOUSE_PIXELS_PER_LINE = 30; + const MOUSE_LINES_PER_PAGE = 30; + if (deltaMode === WheelEvent.DOM_DELTA_PIXEL) { + delta /= MOUSE_PIXELS_PER_LINE * MOUSE_LINES_PER_PAGE; + } else if (deltaMode === WheelEvent.DOM_DELTA_LINE) { + delta /= MOUSE_LINES_PER_PAGE; + } + return delta; +} +function isValidRotation(angle) { + return Number.isInteger(angle) && angle % 90 === 0; +} +function isValidScrollMode(mode) { + return Number.isInteger(mode) && Object.values(ScrollMode).includes(mode) && mode !== ScrollMode.UNKNOWN; +} +function isValidSpreadMode(mode) { + return Number.isInteger(mode) && Object.values(SpreadMode).includes(mode) && mode !== SpreadMode.UNKNOWN; +} +function isPortraitOrientation(size) { + return size.width <= size.height; +} +const animationStarted = new Promise(function (resolve) { + window.requestAnimationFrame(resolve); +}); +const docStyle = document.documentElement.style; +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} +class ProgressBar { + #classList = null; + #disableAutoFetchTimeout = null; + #percent = 0; + #style = null; + #visible = true; + constructor(bar) { + this.#classList = bar.classList; + this.#style = bar.style; + } + get percent() { + return this.#percent; + } + set percent(val) { + this.#percent = clamp(val, 0, 100); + if (isNaN(val)) { + this.#classList.add("indeterminate"); + return; + } + this.#classList.remove("indeterminate"); + this.#style.setProperty("--progressBar-percent", `${this.#percent}%`); + } + setWidth(viewer) { + if (!viewer) { + return; + } + const container = viewer.parentNode; + const scrollbarWidth = container.offsetWidth - viewer.offsetWidth; + if (scrollbarWidth > 0) { + this.#style.setProperty("--progressBar-end-offset", `${scrollbarWidth}px`); + } + } + setDisableAutoFetch(delay = 5000) { + if (this.#percent === 100 || isNaN(this.#percent)) { + return; + } + if (this.#disableAutoFetchTimeout) { + clearTimeout(this.#disableAutoFetchTimeout); + } + this.show(); + this.#disableAutoFetchTimeout = setTimeout(() => { + this.#disableAutoFetchTimeout = null; + this.hide(); + }, delay); + } + hide() { + if (!this.#visible) { + return; + } + this.#visible = false; + this.#classList.add("hidden"); + } + show() { + if (this.#visible) { + return; + } + this.#visible = true; + this.#classList.remove("hidden"); + } +} +function getActiveOrFocusedElement() { + let curRoot = document; + let curActiveOrFocused = curRoot.activeElement || curRoot.querySelector(":focus"); + while (curActiveOrFocused?.shadowRoot) { + curRoot = curActiveOrFocused.shadowRoot; + curActiveOrFocused = curRoot.activeElement || curRoot.querySelector(":focus"); + } + return curActiveOrFocused; +} +function apiPageLayoutToViewerModes(layout) { + let scrollMode = ScrollMode.VERTICAL, + spreadMode = SpreadMode.NONE; + switch (layout) { + case "SinglePage": + scrollMode = ScrollMode.PAGE; + break; + case "OneColumn": + break; + case "TwoPageLeft": + scrollMode = ScrollMode.PAGE; + case "TwoColumnLeft": + spreadMode = SpreadMode.ODD; + break; + case "TwoPageRight": + scrollMode = ScrollMode.PAGE; + case "TwoColumnRight": + spreadMode = SpreadMode.EVEN; + break; + } + return { + scrollMode, + spreadMode + }; +} +function apiPageModeToSidebarView(mode) { + switch (mode) { + case "UseNone": + return SidebarView.NONE; + case "UseThumbs": + return SidebarView.THUMBS; + case "UseOutlines": + return SidebarView.OUTLINE; + case "UseAttachments": + return SidebarView.ATTACHMENTS; + case "UseOC": + return SidebarView.LAYERS; + } + return SidebarView.NONE; +} +function toggleCheckedBtn(button, toggle, view = null) { + button.classList.toggle("toggled", toggle); + button.setAttribute("aria-checked", toggle); + view?.classList.toggle("hidden", !toggle); +} +function toggleExpandedBtn(button, toggle, view = null) { + button.classList.toggle("toggled", toggle); + button.setAttribute("aria-expanded", toggle); + view?.classList.toggle("hidden", !toggle); +} +const calcRound = function () { + const e = document.createElement("div"); + e.style.width = "round(down, calc(1.6666666666666665 * 792px), 1px)"; + return e.style.width === "calc(1320px)" ? Math.fround : x => x; +}(); + +;// ./web/pdf_find_utils.js +const CharacterType = { + SPACE: 0, + ALPHA_LETTER: 1, + PUNCT: 2, + HAN_LETTER: 3, + KATAKANA_LETTER: 4, + HIRAGANA_LETTER: 5, + HALFWIDTH_KATAKANA_LETTER: 6, + THAI_LETTER: 7 +}; +function isAlphabeticalScript(charCode) { + return charCode < 0x2e80; +} +function isAscii(charCode) { + return (charCode & 0xff80) === 0; +} +function isAsciiAlpha(charCode) { + return charCode >= 0x61 && charCode <= 0x7a || charCode >= 0x41 && charCode <= 0x5a; +} +function isAsciiDigit(charCode) { + return charCode >= 0x30 && charCode <= 0x39; +} +function isAsciiSpace(charCode) { + return charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a; +} +function isHan(charCode) { + return charCode >= 0x3400 && charCode <= 0x9fff || charCode >= 0xf900 && charCode <= 0xfaff; +} +function isKatakana(charCode) { + return charCode >= 0x30a0 && charCode <= 0x30ff; +} +function isHiragana(charCode) { + return charCode >= 0x3040 && charCode <= 0x309f; +} +function isHalfwidthKatakana(charCode) { + return charCode >= 0xff60 && charCode <= 0xff9f; +} +function isThai(charCode) { + return (charCode & 0xff80) === 0x0e00; +} +function getCharacterType(charCode) { + if (isAlphabeticalScript(charCode)) { + if (isAscii(charCode)) { + if (isAsciiSpace(charCode)) { + return CharacterType.SPACE; + } else if (isAsciiAlpha(charCode) || isAsciiDigit(charCode) || charCode === 0x5f) { + return CharacterType.ALPHA_LETTER; + } + return CharacterType.PUNCT; + } else if (isThai(charCode)) { + return CharacterType.THAI_LETTER; + } else if (charCode === 0xa0) { + return CharacterType.SPACE; + } + return CharacterType.ALPHA_LETTER; + } + if (isHan(charCode)) { + return CharacterType.HAN_LETTER; + } else if (isKatakana(charCode)) { + return CharacterType.KATAKANA_LETTER; + } else if (isHiragana(charCode)) { + return CharacterType.HIRAGANA_LETTER; + } else if (isHalfwidthKatakana(charCode)) { + return CharacterType.HALFWIDTH_KATAKANA_LETTER; + } + return CharacterType.ALPHA_LETTER; +} +let NormalizeWithNFKC; +function getNormalizeWithNFKC() { + NormalizeWithNFKC ||= ` ¨ª¯²-µ¸-º¼-¾IJ-ijĿ-ŀʼnſDŽ-njDZ-dzʰ-ʸ˘-˝ˠ-ˤʹͺ;΄-΅·ϐ-ϖϰ-ϲϴ-ϵϹևٵ-ٸक़-य़ড়-ঢ়য়ਲ਼ਸ਼ਖ਼-ਜ਼ਫ਼ଡ଼-ଢ଼ำຳໜ-ໝ༌གྷཌྷདྷབྷཛྷཀྵჼᴬ-ᴮᴰ-ᴺᴼ-ᵍᵏ-ᵪᵸᶛ-ᶿẚ-ẛάέήίόύώΆ᾽-῁ΈΉ῍-῏ΐΊ῝-῟ΰΎ῭-`ΌΏ´-῾ - ‑‗․-… ″-‴‶-‷‼‾⁇-⁉⁗ ⁰-ⁱ⁴-₎ₐ-ₜ₨℀-℃℅-ℇ℉-ℓℕ-№ℙ-ℝ℠-™ℤΩℨK-ℭℯ-ℱℳ-ℹ℻-⅀ⅅ-ⅉ⅐-ⅿ↉∬-∭∯-∰〈-〉①-⓪⨌⩴-⩶⫝̸ⱼ-ⱽⵯ⺟⻳⼀-⿕ 〶〸-〺゛-゜ゟヿㄱ-ㆎ㆒-㆟㈀-㈞㈠-㉇㉐-㉾㊀-㏿ꚜ-ꚝꝰꟲ-ꟴꟸ-ꟹꭜ-ꭟꭩ豈-嗀塚晴凞-羽蘒諸逸-都飯-舘並-龎ff-stﬓ-ﬗיִײַ-זּטּ-לּמּנּ-סּףּ-פּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-﷼︐-︙︰-﹄﹇-﹒﹔-﹦﹨-﹫ﹰ-ﹲﹴﹶ-ﻼ!-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ¢-₩`; + return NormalizeWithNFKC; +} + +;// ./web/pdf_find_controller.js + + +const FindState = { + FOUND: 0, + NOT_FOUND: 1, + WRAPPED: 2, + PENDING: 3 +}; +const FIND_TIMEOUT = 250; +const MATCH_SCROLL_OFFSET_TOP = -50; +const MATCH_SCROLL_OFFSET_LEFT = -400; +const CHARACTERS_TO_NORMALIZE = { + "\u2010": "-", + "\u2018": "'", + "\u2019": "'", + "\u201A": "'", + "\u201B": "'", + "\u201C": '"', + "\u201D": '"', + "\u201E": '"', + "\u201F": '"', + "\u00BC": "1/4", + "\u00BD": "1/2", + "\u00BE": "3/4" +}; +const DIACRITICS_EXCEPTION = new Set([0x3099, 0x309a, 0x094d, 0x09cd, 0x0a4d, 0x0acd, 0x0b4d, 0x0bcd, 0x0c4d, 0x0ccd, 0x0d3b, 0x0d3c, 0x0d4d, 0x0dca, 0x0e3a, 0x0eba, 0x0f84, 0x1039, 0x103a, 0x1714, 0x1734, 0x17d2, 0x1a60, 0x1b44, 0x1baa, 0x1bab, 0x1bf2, 0x1bf3, 0x2d7f, 0xa806, 0xa82c, 0xa8c4, 0xa953, 0xa9c0, 0xaaf6, 0xabed, 0x0c56, 0x0f71, 0x0f72, 0x0f7a, 0x0f7b, 0x0f7c, 0x0f7d, 0x0f80, 0x0f74]); +let DIACRITICS_EXCEPTION_STR; +const DIACRITICS_REG_EXP = /\p{M}+/gu; +const SPECIAL_CHARS_REG_EXP = /([.*+?^${}()|[\]\\])|(\p{P})|(\s+)|(\p{M})|(\p{L})/gu; +const NOT_DIACRITIC_FROM_END_REG_EXP = /([^\p{M}])\p{M}*$/u; +const NOT_DIACRITIC_FROM_START_REG_EXP = /^\p{M}*([^\p{M}])/u; +const SYLLABLES_REG_EXP = /[\uAC00-\uD7AF\uFA6C\uFACF-\uFAD1\uFAD5-\uFAD7]+/g; +const SYLLABLES_LENGTHS = new Map(); +const FIRST_CHAR_SYLLABLES_REG_EXP = "[\\u1100-\\u1112\\ud7a4-\\ud7af\\ud84a\\ud84c\\ud850\\ud854\\ud857\\ud85f]"; +const NFKC_CHARS_TO_NORMALIZE = new Map(); +let noSyllablesRegExp = null; +let withSyllablesRegExp = null; +function normalize(text) { + const syllablePositions = []; + let m; + while ((m = SYLLABLES_REG_EXP.exec(text)) !== null) { + let { + index + } = m; + for (const char of m[0]) { + let len = SYLLABLES_LENGTHS.get(char); + if (!len) { + len = char.normalize("NFD").length; + SYLLABLES_LENGTHS.set(char, len); + } + syllablePositions.push([len, index++]); + } + } + let normalizationRegex; + if (syllablePositions.length === 0 && noSyllablesRegExp) { + normalizationRegex = noSyllablesRegExp; + } else if (syllablePositions.length > 0 && withSyllablesRegExp) { + normalizationRegex = withSyllablesRegExp; + } else { + const replace = Object.keys(CHARACTERS_TO_NORMALIZE).join(""); + const toNormalizeWithNFKC = getNormalizeWithNFKC(); + const CJK = "(?:\\p{Ideographic}|[\u3040-\u30FF])"; + const HKDiacritics = "(?:\u3099|\u309A)"; + const CompoundWord = "\\p{Ll}-\\n\\p{Lu}"; + const regexp = `([${replace}])|([${toNormalizeWithNFKC}])|(${HKDiacritics}\\n)|(\\p{M}+(?:-\\n)?)|(${CompoundWord})|(\\S-\\n)|(${CJK}\\n)|(\\n)`; + if (syllablePositions.length === 0) { + normalizationRegex = noSyllablesRegExp = new RegExp(regexp + "|(\\u0000)", "gum"); + } else { + normalizationRegex = withSyllablesRegExp = new RegExp(regexp + `|(${FIRST_CHAR_SYLLABLES_REG_EXP})`, "gum"); + } + } + const rawDiacriticsPositions = []; + while ((m = DIACRITICS_REG_EXP.exec(text)) !== null) { + rawDiacriticsPositions.push([m[0].length, m.index]); + } + let normalized = text.normalize("NFD"); + const positions = [0, 0]; + let rawDiacriticsIndex = 0; + let syllableIndex = 0; + let shift = 0; + let shiftOrigin = 0; + let eol = 0; + let hasDiacritics = false; + normalized = normalized.replace(normalizationRegex, (match, p1, p2, p3, p4, p5, p6, p7, p8, p9, i) => { + i -= shiftOrigin; + if (p1) { + const replacement = CHARACTERS_TO_NORMALIZE[p1]; + const jj = replacement.length; + for (let j = 1; j < jj; j++) { + positions.push(i - shift + j, shift - j); + } + shift -= jj - 1; + return replacement; + } + if (p2) { + let replacement = NFKC_CHARS_TO_NORMALIZE.get(p2); + if (!replacement) { + replacement = p2.normalize("NFKC"); + NFKC_CHARS_TO_NORMALIZE.set(p2, replacement); + } + const jj = replacement.length; + for (let j = 1; j < jj; j++) { + positions.push(i - shift + j, shift - j); + } + shift -= jj - 1; + return replacement; + } + if (p3) { + hasDiacritics = true; + if (i + eol === rawDiacriticsPositions[rawDiacriticsIndex]?.[1]) { + ++rawDiacriticsIndex; + } else { + positions.push(i - 1 - shift + 1, shift - 1); + shift -= 1; + shiftOrigin += 1; + } + positions.push(i - shift + 1, shift); + shiftOrigin += 1; + eol += 1; + return p3.charAt(0); + } + if (p4) { + const hasTrailingDashEOL = p4.endsWith("\n"); + const len = hasTrailingDashEOL ? p4.length - 2 : p4.length; + hasDiacritics = true; + let jj = len; + if (i + eol === rawDiacriticsPositions[rawDiacriticsIndex]?.[1]) { + jj -= rawDiacriticsPositions[rawDiacriticsIndex][0]; + ++rawDiacriticsIndex; + } + for (let j = 1; j <= jj; j++) { + positions.push(i - 1 - shift + j, shift - j); + } + shift -= jj; + shiftOrigin += jj; + if (hasTrailingDashEOL) { + i += len - 1; + positions.push(i - shift + 1, 1 + shift); + shift += 1; + shiftOrigin += 1; + eol += 1; + return p4.slice(0, len); + } + return p4; + } + if (p5) { + shiftOrigin += 1; + eol += 1; + return p5.replace("\n", ""); + } + if (p6) { + const len = p6.length - 2; + positions.push(i - shift + len, 1 + shift); + shift += 1; + shiftOrigin += 1; + eol += 1; + return p6.slice(0, -2); + } + if (p7) { + const len = p7.length - 1; + positions.push(i - shift + len, shift); + shiftOrigin += 1; + eol += 1; + return p7.slice(0, -1); + } + if (p8) { + positions.push(i - shift + 1, shift - 1); + shift -= 1; + shiftOrigin += 1; + eol += 1; + return " "; + } + if (i + eol === syllablePositions[syllableIndex]?.[1]) { + const newCharLen = syllablePositions[syllableIndex][0] - 1; + ++syllableIndex; + for (let j = 1; j <= newCharLen; j++) { + positions.push(i - (shift - j), shift - j); + } + shift -= newCharLen; + shiftOrigin += newCharLen; + } + return p9; + }); + positions.push(normalized.length, shift); + const starts = new Uint32Array(positions.length >> 1); + const shifts = new Int32Array(positions.length >> 1); + for (let i = 0, ii = positions.length; i < ii; i += 2) { + starts[i >> 1] = positions[i]; + shifts[i >> 1] = positions[i + 1]; + } + return [normalized, [starts, shifts], hasDiacritics]; +} +function getOriginalIndex(diffs, pos, len) { + if (!diffs) { + return [pos, len]; + } + const [starts, shifts] = diffs; + const start = pos; + const end = pos + len - 1; + let i = binarySearchFirstItem(starts, x => x >= start); + if (starts[i] > start) { + --i; + } + let j = binarySearchFirstItem(starts, x => x >= end, i); + if (starts[j] > end) { + --j; + } + const oldStart = start + shifts[i]; + const oldEnd = end + shifts[j]; + const oldLen = oldEnd + 1 - oldStart; + return [oldStart, oldLen]; +} +class PDFFindController { + #state = null; + #updateMatchesCountOnProgress = true; + #visitedPagesCount = 0; + constructor({ + linkService, + eventBus, + updateMatchesCountOnProgress = true + }) { + this._linkService = linkService; + this._eventBus = eventBus; + this.#updateMatchesCountOnProgress = updateMatchesCountOnProgress; + this.onIsPageVisible = null; + this.#reset(); + eventBus._on("find", this.#onFind.bind(this)); + eventBus._on("findbarclose", this.#onFindBarClose.bind(this)); + } + get highlightMatches() { + return this._highlightMatches; + } + get pageMatches() { + return this._pageMatches; + } + get pageMatchesLength() { + return this._pageMatchesLength; + } + get selected() { + return this._selected; + } + get state() { + return this.#state; + } + setDocument(pdfDocument) { + if (this._pdfDocument) { + this.#reset(); + } + if (!pdfDocument) { + return; + } + this._pdfDocument = pdfDocument; + this._firstPageCapability.resolve(); + } + #onFind(state) { + if (!state) { + return; + } + const pdfDocument = this._pdfDocument; + const { + type + } = state; + if (this.#state === null || this.#shouldDirtyMatch(state)) { + this._dirtyMatch = true; + } + this.#state = state; + if (type !== "highlightallchange") { + this.#updateUIState(FindState.PENDING); + } + this._firstPageCapability.promise.then(() => { + if (!this._pdfDocument || pdfDocument && this._pdfDocument !== pdfDocument) { + return; + } + this.#extractText(); + const findbarClosed = !this._highlightMatches; + const pendingTimeout = !!this._findTimeout; + if (this._findTimeout) { + clearTimeout(this._findTimeout); + this._findTimeout = null; + } + if (!type) { + this._findTimeout = setTimeout(() => { + this.#nextMatch(); + this._findTimeout = null; + }, FIND_TIMEOUT); + } else if (this._dirtyMatch) { + this.#nextMatch(); + } else if (type === "again") { + this.#nextMatch(); + if (findbarClosed && this.#state.highlightAll) { + this.#updateAllPages(); + } + } else if (type === "highlightallchange") { + if (pendingTimeout) { + this.#nextMatch(); + } else { + this._highlightMatches = true; + } + this.#updateAllPages(); + } else { + this.#nextMatch(); + } + }); + } + scrollMatchIntoView({ + element = null, + selectedLeft = 0, + pageIndex = -1, + matchIndex = -1 + }) { + if (!this._scrollMatches || !element) { + return; + } else if (matchIndex === -1 || matchIndex !== this._selected.matchIdx) { + return; + } else if (pageIndex === -1 || pageIndex !== this._selected.pageIdx) { + return; + } + this._scrollMatches = false; + const spot = { + top: MATCH_SCROLL_OFFSET_TOP, + left: selectedLeft + MATCH_SCROLL_OFFSET_LEFT + }; + scrollIntoView(element, spot, true); + } + #reset() { + this._highlightMatches = false; + this._scrollMatches = false; + this._pdfDocument = null; + this._pageMatches = []; + this._pageMatchesLength = []; + this.#visitedPagesCount = 0; + this.#state = null; + this._selected = { + pageIdx: -1, + matchIdx: -1 + }; + this._offset = { + pageIdx: null, + matchIdx: null, + wrapped: false + }; + this._extractTextPromises = []; + this._pageContents = []; + this._pageDiffs = []; + this._hasDiacritics = []; + this._matchesCountTotal = 0; + this._pagesToSearch = null; + this._pendingFindMatches = new Set(); + this._resumePageIdx = null; + this._dirtyMatch = false; + clearTimeout(this._findTimeout); + this._findTimeout = null; + this._firstPageCapability = Promise.withResolvers(); + } + get #query() { + const { + query + } = this.#state; + if (typeof query === "string") { + if (query !== this._rawQuery) { + this._rawQuery = query; + [this._normalizedQuery] = normalize(query); + } + return this._normalizedQuery; + } + return (query || []).filter(q => !!q).map(q => normalize(q)[0]); + } + #shouldDirtyMatch(state) { + const newQuery = state.query, + prevQuery = this.#state.query; + const newType = typeof newQuery, + prevType = typeof prevQuery; + if (newType !== prevType) { + return true; + } + if (newType === "string") { + if (newQuery !== prevQuery) { + return true; + } + } else if (JSON.stringify(newQuery) !== JSON.stringify(prevQuery)) { + return true; + } + switch (state.type) { + case "again": + const pageNumber = this._selected.pageIdx + 1; + const linkService = this._linkService; + return pageNumber >= 1 && pageNumber <= linkService.pagesCount && pageNumber !== linkService.page && !(this.onIsPageVisible?.(pageNumber) ?? true); + case "highlightallchange": + return false; + } + return true; + } + #isEntireWord(content, startIdx, length) { + let match = content.slice(0, startIdx).match(NOT_DIACRITIC_FROM_END_REG_EXP); + if (match) { + const first = content.charCodeAt(startIdx); + const limit = match[1].charCodeAt(0); + if (getCharacterType(first) === getCharacterType(limit)) { + return false; + } + } + match = content.slice(startIdx + length).match(NOT_DIACRITIC_FROM_START_REG_EXP); + if (match) { + const last = content.charCodeAt(startIdx + length - 1); + const limit = match[1].charCodeAt(0); + if (getCharacterType(last) === getCharacterType(limit)) { + return false; + } + } + return true; + } + #convertToRegExpString(query, hasDiacritics) { + const { + matchDiacritics + } = this.#state; + let isUnicode = false; + query = query.replaceAll(SPECIAL_CHARS_REG_EXP, (match, p1, p2, p3, p4, p5) => { + if (p1) { + return `[ ]*\\${p1}[ ]*`; + } + if (p2) { + return `[ ]*${p2}[ ]*`; + } + if (p3) { + return "[ ]+"; + } + if (matchDiacritics) { + return p4 || p5; + } + if (p4) { + return DIACRITICS_EXCEPTION.has(p4.charCodeAt(0)) ? p4 : ""; + } + if (hasDiacritics) { + isUnicode = true; + return `${p5}\\p{M}*`; + } + return p5; + }); + const trailingSpaces = "[ ]*"; + if (query.endsWith(trailingSpaces)) { + query = query.slice(0, query.length - trailingSpaces.length); + } + if (matchDiacritics) { + if (hasDiacritics) { + DIACRITICS_EXCEPTION_STR ||= String.fromCharCode(...DIACRITICS_EXCEPTION); + isUnicode = true; + query = `${query}(?=[${DIACRITICS_EXCEPTION_STR}]|[^\\p{M}]|$)`; + } + } + return [isUnicode, query]; + } + #calculateMatch(pageIndex) { + const query = this.#query; + if (query.length === 0) { + return; + } + const pageContent = this._pageContents[pageIndex]; + const matcherResult = this.match(query, pageContent, pageIndex); + const matches = this._pageMatches[pageIndex] = []; + const matchesLength = this._pageMatchesLength[pageIndex] = []; + const diffs = this._pageDiffs[pageIndex]; + matcherResult?.forEach(({ + index, + length + }) => { + const [matchPos, matchLen] = getOriginalIndex(diffs, index, length); + if (matchLen) { + matches.push(matchPos); + matchesLength.push(matchLen); + } + }); + if (this.#state.highlightAll) { + this.#updatePage(pageIndex); + } + if (this._resumePageIdx === pageIndex) { + this._resumePageIdx = null; + this.#nextPageMatch(); + } + const pageMatchesCount = matches.length; + this._matchesCountTotal += pageMatchesCount; + if (this.#updateMatchesCountOnProgress) { + if (pageMatchesCount > 0) { + this.#updateUIResultsCount(); + } + } else if (++this.#visitedPagesCount === this._linkService.pagesCount) { + this.#updateUIResultsCount(); + } + } + match(query, pageContent, pageIndex) { + const hasDiacritics = this._hasDiacritics[pageIndex]; + let isUnicode = false; + if (typeof query === "string") { + [isUnicode, query] = this.#convertToRegExpString(query, hasDiacritics); + } else { + query = query.sort().reverse().map(q => { + const [isUnicodePart, queryPart] = this.#convertToRegExpString(q, hasDiacritics); + isUnicode ||= isUnicodePart; + return `(${queryPart})`; + }).join("|"); + } + if (!query) { + return undefined; + } + const { + caseSensitive, + entireWord + } = this.#state; + const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`; + query = new RegExp(query, flags); + const matches = []; + let match; + while ((match = query.exec(pageContent)) !== null) { + if (entireWord && !this.#isEntireWord(pageContent, match.index, match[0].length)) { + continue; + } + matches.push({ + index: match.index, + length: match[0].length + }); + } + return matches; + } + #extractText() { + if (this._extractTextPromises.length > 0) { + return; + } + let deferred = Promise.resolve(); + const textOptions = { + disableNormalization: true + }; + for (let i = 0, ii = this._linkService.pagesCount; i < ii; i++) { + const { + promise, + resolve + } = Promise.withResolvers(); + this._extractTextPromises[i] = promise; + deferred = deferred.then(() => { + return this._pdfDocument.getPage(i + 1).then(pdfPage => pdfPage.getTextContent(textOptions)).then(textContent => { + const strBuf = []; + for (const textItem of textContent.items) { + strBuf.push(textItem.str); + if (textItem.hasEOL) { + strBuf.push("\n"); + } + } + [this._pageContents[i], this._pageDiffs[i], this._hasDiacritics[i]] = normalize(strBuf.join("")); + resolve(); + }, reason => { + console.error(`Unable to get text content for page ${i + 1}`, reason); + this._pageContents[i] = ""; + this._pageDiffs[i] = null; + this._hasDiacritics[i] = false; + resolve(); + }); + }); + } + } + #updatePage(index) { + if (this._scrollMatches && this._selected.pageIdx === index) { + this._linkService.page = index + 1; + } + this._eventBus.dispatch("updatetextlayermatches", { + source: this, + pageIndex: index + }); + } + #updateAllPages() { + this._eventBus.dispatch("updatetextlayermatches", { + source: this, + pageIndex: -1 + }); + } + #nextMatch() { + const previous = this.#state.findPrevious; + const currentPageIndex = this._linkService.page - 1; + const numPages = this._linkService.pagesCount; + this._highlightMatches = true; + if (this._dirtyMatch) { + this._dirtyMatch = false; + this._selected.pageIdx = this._selected.matchIdx = -1; + this._offset.pageIdx = currentPageIndex; + this._offset.matchIdx = null; + this._offset.wrapped = false; + this._resumePageIdx = null; + this._pageMatches.length = 0; + this._pageMatchesLength.length = 0; + this.#visitedPagesCount = 0; + this._matchesCountTotal = 0; + this.#updateAllPages(); + for (let i = 0; i < numPages; i++) { + if (this._pendingFindMatches.has(i)) { + continue; + } + this._pendingFindMatches.add(i); + this._extractTextPromises[i].then(() => { + this._pendingFindMatches.delete(i); + this.#calculateMatch(i); + }); + } + } + const query = this.#query; + if (query.length === 0) { + this.#updateUIState(FindState.FOUND); + return; + } + if (this._resumePageIdx) { + return; + } + const offset = this._offset; + this._pagesToSearch = numPages; + if (offset.matchIdx !== null) { + const numPageMatches = this._pageMatches[offset.pageIdx].length; + if (!previous && offset.matchIdx + 1 < numPageMatches || previous && offset.matchIdx > 0) { + offset.matchIdx = previous ? offset.matchIdx - 1 : offset.matchIdx + 1; + this.#updateMatch(true); + return; + } + this.#advanceOffsetPage(previous); + } + this.#nextPageMatch(); + } + #matchesReady(matches) { + const offset = this._offset; + const numMatches = matches.length; + const previous = this.#state.findPrevious; + if (numMatches) { + offset.matchIdx = previous ? numMatches - 1 : 0; + this.#updateMatch(true); + return true; + } + this.#advanceOffsetPage(previous); + if (offset.wrapped) { + offset.matchIdx = null; + if (this._pagesToSearch < 0) { + this.#updateMatch(false); + return true; + } + } + return false; + } + #nextPageMatch() { + if (this._resumePageIdx !== null) { + console.error("There can only be one pending page."); + } + let matches = null; + do { + const pageIdx = this._offset.pageIdx; + matches = this._pageMatches[pageIdx]; + if (!matches) { + this._resumePageIdx = pageIdx; + break; + } + } while (!this.#matchesReady(matches)); + } + #advanceOffsetPage(previous) { + const offset = this._offset; + const numPages = this._linkService.pagesCount; + offset.pageIdx = previous ? offset.pageIdx - 1 : offset.pageIdx + 1; + offset.matchIdx = null; + this._pagesToSearch--; + if (offset.pageIdx >= numPages || offset.pageIdx < 0) { + offset.pageIdx = previous ? numPages - 1 : 0; + offset.wrapped = true; + } + } + #updateMatch(found = false) { + let state = FindState.NOT_FOUND; + const wrapped = this._offset.wrapped; + this._offset.wrapped = false; + if (found) { + const previousPage = this._selected.pageIdx; + this._selected.pageIdx = this._offset.pageIdx; + this._selected.matchIdx = this._offset.matchIdx; + state = wrapped ? FindState.WRAPPED : FindState.FOUND; + if (previousPage !== -1 && previousPage !== this._selected.pageIdx) { + this.#updatePage(previousPage); + } + } + this.#updateUIState(state, this.#state.findPrevious); + if (this._selected.pageIdx !== -1) { + this._scrollMatches = true; + this.#updatePage(this._selected.pageIdx); + } + } + #onFindBarClose(evt) { + const pdfDocument = this._pdfDocument; + this._firstPageCapability.promise.then(() => { + if (!this._pdfDocument || pdfDocument && this._pdfDocument !== pdfDocument) { + return; + } + if (this._findTimeout) { + clearTimeout(this._findTimeout); + this._findTimeout = null; + } + if (this._resumePageIdx) { + this._resumePageIdx = null; + this._dirtyMatch = true; + } + this.#updateUIState(FindState.FOUND); + this._highlightMatches = false; + this.#updateAllPages(); + }); + } + #requestMatchesCount() { + const { + pageIdx, + matchIdx + } = this._selected; + let current = 0, + total = this._matchesCountTotal; + if (matchIdx !== -1) { + for (let i = 0; i < pageIdx; i++) { + current += this._pageMatches[i]?.length || 0; + } + current += matchIdx + 1; + } + if (current < 1 || current > total) { + current = total = 0; + } + return { + current, + total + }; + } + #updateUIResultsCount() { + this._eventBus.dispatch("updatefindmatchescount", { + source: this, + matchesCount: this.#requestMatchesCount() + }); + } + #updateUIState(state, previous = false) { + if (!this.#updateMatchesCountOnProgress && (this.#visitedPagesCount !== this._linkService.pagesCount || state === FindState.PENDING)) { + return; + } + this._eventBus.dispatch("updatefindcontrolstate", { + source: this, + state, + previous, + entireWord: this.#state?.entireWord ?? null, + matchesCount: this.#requestMatchesCount(), + rawQuery: this.#state?.query ?? null + }); + } +} + +;// ./web/pdf_link_service.js + +const DEFAULT_LINK_REL = "noopener noreferrer nofollow"; +const LinkTarget = { + NONE: 0, + SELF: 1, + BLANK: 2, + PARENT: 3, + TOP: 4 +}; +class PDFLinkService { + externalLinkEnabled = true; + constructor({ + eventBus, + externalLinkTarget = null, + externalLinkRel = null, + ignoreDestinationZoom = false + } = {}) { + this.eventBus = eventBus; + this.externalLinkTarget = externalLinkTarget; + this.externalLinkRel = externalLinkRel; + this._ignoreDestinationZoom = ignoreDestinationZoom; + this.baseUrl = null; + this.pdfDocument = null; + this.pdfViewer = null; + this.pdfHistory = null; + } + setDocument(pdfDocument, baseUrl = null) { + this.baseUrl = baseUrl; + this.pdfDocument = pdfDocument; + } + setViewer(pdfViewer) { + this.pdfViewer = pdfViewer; + } + setHistory(pdfHistory) { + this.pdfHistory = pdfHistory; + } + get pagesCount() { + return this.pdfDocument ? this.pdfDocument.numPages : 0; + } + get page() { + return this.pdfDocument ? this.pdfViewer.currentPageNumber : 1; + } + set page(value) { + if (this.pdfDocument) { + this.pdfViewer.currentPageNumber = value; + } + } + get rotation() { + return this.pdfDocument ? this.pdfViewer.pagesRotation : 0; + } + set rotation(value) { + if (this.pdfDocument) { + this.pdfViewer.pagesRotation = value; + } + } + get isInPresentationMode() { + return this.pdfDocument ? this.pdfViewer.isInPresentationMode : false; + } + async goToDestination(dest) { + if (!this.pdfDocument) { + return; + } + let namedDest, explicitDest, pageNumber; + if (typeof dest === "string") { + namedDest = dest; + explicitDest = await this.pdfDocument.getDestination(dest); + } else { + namedDest = null; + explicitDest = await dest; + } + if (!Array.isArray(explicitDest)) { + console.error(`goToDestination: "${explicitDest}" is not a valid destination array, for dest="${dest}".`); + return; + } + const [destRef] = explicitDest; + if (destRef && typeof destRef === "object") { + pageNumber = this.pdfDocument.cachedPageNumber(destRef); + if (!pageNumber) { + try { + pageNumber = (await this.pdfDocument.getPageIndex(destRef)) + 1; + } catch { + console.error(`goToDestination: "${destRef}" is not a valid page reference, for dest="${dest}".`); + return; + } + } + } else if (Number.isInteger(destRef)) { + pageNumber = destRef + 1; + } + if (!pageNumber || pageNumber < 1 || pageNumber > this.pagesCount) { + console.error(`goToDestination: "${pageNumber}" is not a valid page number, for dest="${dest}".`); + return; + } + if (this.pdfHistory) { + this.pdfHistory.pushCurrentPosition(); + this.pdfHistory.push({ + namedDest, + explicitDest, + pageNumber + }); + } + this.pdfViewer.scrollPageIntoView({ + pageNumber, + destArray: explicitDest, + ignoreDestinationZoom: this._ignoreDestinationZoom + }); + } + goToPage(val) { + if (!this.pdfDocument) { + return; + } + const pageNumber = typeof val === "string" && this.pdfViewer.pageLabelToPageNumber(val) || val | 0; + if (!(Number.isInteger(pageNumber) && pageNumber > 0 && pageNumber <= this.pagesCount)) { + console.error(`PDFLinkService.goToPage: "${val}" is not a valid page.`); + return; + } + if (this.pdfHistory) { + this.pdfHistory.pushCurrentPosition(); + this.pdfHistory.pushPage(pageNumber); + } + this.pdfViewer.scrollPageIntoView({ + pageNumber + }); + } + addLinkAttributes(link, url, newWindow = false) { + if (!url || typeof url !== "string") { + throw new Error('A valid "url" parameter must provided.'); + } + const target = newWindow ? LinkTarget.BLANK : this.externalLinkTarget, + rel = this.externalLinkRel; + if (this.externalLinkEnabled) { + link.href = link.title = url; + } else { + link.href = ""; + link.title = `Disabled: ${url}`; + link.onclick = () => false; + } + let targetStr = ""; + switch (target) { + case LinkTarget.NONE: + break; + case LinkTarget.SELF: + targetStr = "_self"; + break; + case LinkTarget.BLANK: + targetStr = "_blank"; + break; + case LinkTarget.PARENT: + targetStr = "_parent"; + break; + case LinkTarget.TOP: + targetStr = "_top"; + break; + } + link.target = targetStr; + link.rel = typeof rel === "string" ? rel : DEFAULT_LINK_REL; + } + getDestinationHash(dest) { + if (typeof dest === "string") { + if (dest.length > 0) { + return this.getAnchorUrl("#" + escape(dest)); + } + } else if (Array.isArray(dest)) { + const str = JSON.stringify(dest); + if (str.length > 0) { + return this.getAnchorUrl("#" + escape(str)); + } + } + return this.getAnchorUrl(""); + } + getAnchorUrl(anchor) { + return this.baseUrl ? this.baseUrl + anchor : anchor; + } + setHash(hash) { + if (!this.pdfDocument) { + return; + } + let pageNumber, dest; + if (hash.includes("=")) { + const params = parseQueryString(hash); + if (params.has("search")) { + const query = params.get("search").replaceAll('"', ""), + phrase = params.get("phrase") === "true"; + this.eventBus.dispatch("findfromurlhash", { + source: this, + query: phrase ? query : query.match(/\S+/g) + }); + } + if (params.has("page")) { + pageNumber = params.get("page") | 0 || 1; + } + if (params.has("zoom")) { + const zoomArgs = params.get("zoom").split(","); + const zoomArg = zoomArgs[0]; + const zoomArgNumber = parseFloat(zoomArg); + if (!zoomArg.includes("Fit")) { + dest = [null, { + name: "XYZ" + }, zoomArgs.length > 1 ? zoomArgs[1] | 0 : null, zoomArgs.length > 2 ? zoomArgs[2] | 0 : null, zoomArgNumber ? zoomArgNumber / 100 : zoomArg]; + } else if (zoomArg === "Fit" || zoomArg === "FitB") { + dest = [null, { + name: zoomArg + }]; + } else if (zoomArg === "FitH" || zoomArg === "FitBH" || zoomArg === "FitV" || zoomArg === "FitBV") { + dest = [null, { + name: zoomArg + }, zoomArgs.length > 1 ? zoomArgs[1] | 0 : null]; + } else if (zoomArg === "FitR") { + if (zoomArgs.length !== 5) { + console.error('PDFLinkService.setHash: Not enough parameters for "FitR".'); + } else { + dest = [null, { + name: zoomArg + }, zoomArgs[1] | 0, zoomArgs[2] | 0, zoomArgs[3] | 0, zoomArgs[4] | 0]; + } + } else { + console.error(`PDFLinkService.setHash: "${zoomArg}" is not a valid zoom value.`); + } + } + if (dest) { + this.pdfViewer.scrollPageIntoView({ + pageNumber: pageNumber || this.page, + destArray: dest, + allowNegativeOffset: true + }); + } else if (pageNumber) { + this.page = pageNumber; + } + if (params.has("pagemode")) { + this.eventBus.dispatch("pagemode", { + source: this, + mode: params.get("pagemode") + }); + } + if (params.has("nameddest")) { + this.goToDestination(params.get("nameddest")); + } + return; + } + dest = unescape(hash); + try { + dest = JSON.parse(dest); + if (!Array.isArray(dest)) { + dest = dest.toString(); + } + } catch {} + if (typeof dest === "string" || PDFLinkService.#isValidExplicitDest(dest)) { + this.goToDestination(dest); + return; + } + console.error(`PDFLinkService.setHash: "${unescape(hash)}" is not a valid destination.`); + } + executeNamedAction(action) { + if (!this.pdfDocument) { + return; + } + switch (action) { + case "GoBack": + this.pdfHistory?.back(); + break; + case "GoForward": + this.pdfHistory?.forward(); + break; + case "NextPage": + this.pdfViewer.nextPage(); + break; + case "PrevPage": + this.pdfViewer.previousPage(); + break; + case "LastPage": + this.page = this.pagesCount; + break; + case "FirstPage": + this.page = 1; + break; + default: + break; + } + this.eventBus.dispatch("namedaction", { + source: this, + action + }); + } + async executeSetOCGState(action) { + if (!this.pdfDocument) { + return; + } + const pdfDocument = this.pdfDocument, + optionalContentConfig = await this.pdfViewer.optionalContentConfigPromise; + if (pdfDocument !== this.pdfDocument) { + return; + } + optionalContentConfig.setOCGState(action); + this.pdfViewer.optionalContentConfigPromise = Promise.resolve(optionalContentConfig); + } + static #isValidExplicitDest(dest) { + if (!Array.isArray(dest) || dest.length < 2) { + return false; + } + const [page, zoom, ...args] = dest; + if (!(typeof page === "object" && Number.isInteger(page?.num) && Number.isInteger(page?.gen)) && !Number.isInteger(page)) { + return false; + } + if (!(typeof zoom === "object" && typeof zoom?.name === "string")) { + return false; + } + const argsLen = args.length; + let allowNull = true; + switch (zoom.name) { + case "XYZ": + if (argsLen < 2 || argsLen > 3) { + return false; + } + break; + case "Fit": + case "FitB": + return argsLen === 0; + case "FitH": + case "FitBH": + case "FitV": + case "FitBV": + if (argsLen > 1) { + return false; + } + break; + case "FitR": + if (argsLen !== 4) { + return false; + } + allowNull = false; + break; + default: + return false; + } + for (const arg of args) { + if (!(typeof arg === "number" || allowNull && arg === null)) { + return false; + } + } + return true; + } +} +class SimpleLinkService extends PDFLinkService { + setDocument(pdfDocument, baseUrl = null) {} +} + +;// ./web/pdfjs.js +const { + AbortException, + AnnotationEditorLayer, + AnnotationEditorParamsType, + AnnotationEditorType, + AnnotationEditorUIManager, + AnnotationLayer, + AnnotationMode, + build, + ColorPicker, + createValidAbsoluteUrl, + DOMSVGFactory, + DrawLayer, + FeatureTest, + fetchData, + getDocument, + getFilenameFromUrl, + getPdfFilenameFromUrl, + getXfaPageViewport, + GlobalWorkerOptions, + ImageKind, + InvalidPDFException, + isDataScheme, + isPdfFile, + MissingPDFException, + noContextMenu, + normalizeUnicode, + OPS, + OutputScale, + PasswordResponses, + PDFDataRangeTransport, + PDFDateString, + PDFWorker, + PermissionFlag, + PixelsPerInch, + RenderingCancelledException, + setLayerDimensions, + shadow, + stopEvent, + TextLayer, + TouchManager, + UnexpectedResponseException, + Util, + VerbosityLevel, + version, + XfaLayer +} = globalThis.pdfjsLib; + +;// ./web/annotation_layer_builder.js + + +class AnnotationLayerBuilder { + #onAppend = null; + #eventAbortController = null; + constructor({ + pdfPage, + linkService, + downloadManager, + annotationStorage = null, + imageResourcesPath = "", + renderForms = true, + enableScripting = false, + hasJSActionsPromise = null, + fieldObjectsPromise = null, + annotationCanvasMap = null, + accessibilityManager = null, + annotationEditorUIManager = null, + onAppend = null + }) { + this.pdfPage = pdfPage; + this.linkService = linkService; + this.downloadManager = downloadManager; + this.imageResourcesPath = imageResourcesPath; + this.renderForms = renderForms; + this.annotationStorage = annotationStorage; + this.enableScripting = enableScripting; + this._hasJSActionsPromise = hasJSActionsPromise || Promise.resolve(false); + this._fieldObjectsPromise = fieldObjectsPromise || Promise.resolve(null); + this._annotationCanvasMap = annotationCanvasMap; + this._accessibilityManager = accessibilityManager; + this._annotationEditorUIManager = annotationEditorUIManager; + this.#onAppend = onAppend; + this.annotationLayer = null; + this.div = null; + this._cancelled = false; + this._eventBus = linkService.eventBus; + } + async render(viewport, options, intent = "display") { + if (this.div) { + if (this._cancelled || !this.annotationLayer) { + return; + } + this.annotationLayer.update({ + viewport: viewport.clone({ + dontFlip: true + }) + }); + return; + } + const [annotations, hasJSActions, fieldObjects] = await Promise.all([this.pdfPage.getAnnotations({ + intent + }), this._hasJSActionsPromise, this._fieldObjectsPromise]); + if (this._cancelled) { + return; + } + const div = this.div = document.createElement("div"); + div.className = "annotationLayer"; + this.#onAppend?.(div); + if (annotations.length === 0) { + this.hide(); + return; + } + this.annotationLayer = new AnnotationLayer({ + div, + accessibilityManager: this._accessibilityManager, + annotationCanvasMap: this._annotationCanvasMap, + annotationEditorUIManager: this._annotationEditorUIManager, + page: this.pdfPage, + viewport: viewport.clone({ + dontFlip: true + }), + structTreeLayer: options?.structTreeLayer || null + }); + await this.annotationLayer.render({ + annotations, + imageResourcesPath: this.imageResourcesPath, + renderForms: this.renderForms, + linkService: this.linkService, + downloadManager: this.downloadManager, + annotationStorage: this.annotationStorage, + enableScripting: this.enableScripting, + hasJSActions, + fieldObjects + }); + if (this.linkService.isInPresentationMode) { + this.#updatePresentationModeState(PresentationModeState.FULLSCREEN); + } + if (!this.#eventAbortController) { + this.#eventAbortController = new AbortController(); + this._eventBus?._on("presentationmodechanged", evt => { + this.#updatePresentationModeState(evt.state); + }, { + signal: this.#eventAbortController.signal + }); + } + } + cancel() { + this._cancelled = true; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + } + hide() { + if (!this.div) { + return; + } + this.div.hidden = true; + } + hasEditableAnnotations() { + return !!this.annotationLayer?.hasEditableAnnotations(); + } + #updatePresentationModeState(state) { + if (!this.div) { + return; + } + let disableFormElements = false; + switch (state) { + case PresentationModeState.FULLSCREEN: + disableFormElements = true; + break; + case PresentationModeState.NORMAL: + break; + default: + return; + } + for (const section of this.div.childNodes) { + if (section.hasAttribute("data-internal-link")) { + continue; + } + section.inert = disableFormElements; + } + } +} + +;// ./web/download_manager.js + +function download(blobUrl, filename) { + const a = document.createElement("a"); + if (!a.click) { + throw new Error('DownloadManager: "a.click()" is not supported.'); + } + a.href = blobUrl; + a.target = "_parent"; + if ("download" in a) { + a.download = filename; + } + (document.body || document.documentElement).append(a); + a.click(); + a.remove(); +} +class DownloadManager { + #openBlobUrls = new WeakMap(); + downloadData(data, filename, contentType) { + const blobUrl = URL.createObjectURL(new Blob([data], { + type: contentType + })); + download(blobUrl, filename); + } + openOrDownloadData(data, filename, dest = null) { + const isPdfData = isPdfFile(filename); + const contentType = isPdfData ? "application/pdf" : ""; + this.downloadData(data, filename, contentType); + return false; + } + download(data, url, filename) { + let blobUrl; + if (data) { + blobUrl = URL.createObjectURL(new Blob([data], { + type: "application/pdf" + })); + } else { + if (!createValidAbsoluteUrl(url, "http://example.com")) { + console.error(`download - not a valid URL: ${url}`); + return; + } + blobUrl = url + "#pdfjs.action=download"; + } + download(blobUrl, filename); + } +} + +;// ./web/event_utils.js +const WaitOnType = { + EVENT: "event", + TIMEOUT: "timeout" +}; +async function waitOnEventOrTimeout({ + target, + name, + delay = 0 +}) { + if (typeof target !== "object" || !(name && typeof name === "string") || !(Number.isInteger(delay) && delay >= 0)) { + throw new Error("waitOnEventOrTimeout - invalid parameters."); + } + const { + promise, + resolve + } = Promise.withResolvers(); + const ac = new AbortController(); + function handler(type) { + ac.abort(); + clearTimeout(timeout); + resolve(type); + } + const evtMethod = target instanceof EventBus ? "_on" : "addEventListener"; + target[evtMethod](name, handler.bind(null, WaitOnType.EVENT), { + signal: ac.signal + }); + const timeout = setTimeout(handler.bind(null, WaitOnType.TIMEOUT), delay); + return promise; +} +class EventBus { + #listeners = Object.create(null); + on(eventName, listener, options = null) { + this._on(eventName, listener, { + external: true, + once: options?.once, + signal: options?.signal + }); + } + off(eventName, listener, options = null) { + this._off(eventName, listener); + } + dispatch(eventName, data) { + const eventListeners = this.#listeners[eventName]; + if (!eventListeners || eventListeners.length === 0) { + return; + } + let externalListeners; + for (const { + listener, + external, + once + } of eventListeners.slice(0)) { + if (once) { + this._off(eventName, listener); + } + if (external) { + (externalListeners ||= []).push(listener); + continue; + } + listener(data); + } + if (externalListeners) { + for (const listener of externalListeners) { + listener(data); + } + externalListeners = null; + } + } + _on(eventName, listener, options = null) { + let rmAbort = null; + if (options?.signal instanceof AbortSignal) { + const { + signal + } = options; + if (signal.aborted) { + console.error("Cannot use an `aborted` signal."); + return; + } + const onAbort = () => this._off(eventName, listener); + rmAbort = () => signal.removeEventListener("abort", onAbort); + signal.addEventListener("abort", onAbort); + } + const eventListeners = this.#listeners[eventName] ||= []; + eventListeners.push({ + listener, + external: options?.external === true, + once: options?.once === true, + rmAbort + }); + } + _off(eventName, listener, options = null) { + const eventListeners = this.#listeners[eventName]; + if (!eventListeners) { + return; + } + for (let i = 0, ii = eventListeners.length; i < ii; i++) { + const evt = eventListeners[i]; + if (evt.listener === listener) { + evt.rmAbort?.(); + eventListeners.splice(i, 1); + return; + } + } + } +} +class FirefoxEventBus extends EventBus { + #externalServices; + #globalEventNames; + #isInAutomation; + constructor(globalEventNames, externalServices, isInAutomation) { + super(); + this.#globalEventNames = globalEventNames; + this.#externalServices = externalServices; + this.#isInAutomation = isInAutomation; + } + dispatch(eventName, data) { + throw new Error("Not implemented: FirefoxEventBus.dispatch"); + } +} + +;// ./node_modules/@fluent/bundle/esm/types.js +class FluentType { + constructor(value) { + this.value = value; + } + valueOf() { + return this.value; + } +} +class FluentNone extends FluentType { + constructor(value = "???") { + super(value); + } + toString(scope) { + return `{${this.value}}`; + } +} +class FluentNumber extends FluentType { + constructor(value, opts = {}) { + super(value); + this.opts = opts; + } + toString(scope) { + try { + const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts); + return nf.format(this.value); + } catch (err) { + scope.reportError(err); + return this.value.toString(10); + } + } +} +class FluentDateTime extends FluentType { + constructor(value, opts = {}) { + super(value); + this.opts = opts; + } + toString(scope) { + try { + const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts); + return dtf.format(this.value); + } catch (err) { + scope.reportError(err); + return new Date(this.value).toISOString(); + } + } +} +;// ./node_modules/@fluent/bundle/esm/resolver.js + +const MAX_PLACEABLES = 100; +const FSI = "\u2068"; +const PDI = "\u2069"; +function match(scope, selector, key) { + if (key === selector) { + return true; + } + if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) { + return true; + } + if (selector instanceof FluentNumber && typeof key === "string") { + let category = scope.memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value); + if (key === category) { + return true; + } + } + return false; +} +function getDefault(scope, variants, star) { + if (variants[star]) { + return resolvePattern(scope, variants[star].value); + } + scope.reportError(new RangeError("No default")); + return new FluentNone(); +} +function getArguments(scope, args) { + const positional = []; + const named = Object.create(null); + for (const arg of args) { + if (arg.type === "narg") { + named[arg.name] = resolveExpression(scope, arg.value); + } else { + positional.push(resolveExpression(scope, arg)); + } + } + return { + positional, + named + }; +} +function resolveExpression(scope, expr) { + switch (expr.type) { + case "str": + return expr.value; + case "num": + return new FluentNumber(expr.value, { + minimumFractionDigits: expr.precision + }); + case "var": + return resolveVariableReference(scope, expr); + case "mesg": + return resolveMessageReference(scope, expr); + case "term": + return resolveTermReference(scope, expr); + case "func": + return resolveFunctionReference(scope, expr); + case "select": + return resolveSelectExpression(scope, expr); + default: + return new FluentNone(); + } +} +function resolveVariableReference(scope, { + name +}) { + let arg; + if (scope.params) { + if (Object.prototype.hasOwnProperty.call(scope.params, name)) { + arg = scope.params[name]; + } else { + return new FluentNone(`$${name}`); + } + } else if (scope.args && Object.prototype.hasOwnProperty.call(scope.args, name)) { + arg = scope.args[name]; + } else { + scope.reportError(new ReferenceError(`Unknown variable: $${name}`)); + return new FluentNone(`$${name}`); + } + if (arg instanceof FluentType) { + return arg; + } + switch (typeof arg) { + case "string": + return arg; + case "number": + return new FluentNumber(arg); + case "object": + if (arg instanceof Date) { + return new FluentDateTime(arg.getTime()); + } + default: + scope.reportError(new TypeError(`Variable type not supported: $${name}, ${typeof arg}`)); + return new FluentNone(`$${name}`); + } +} +function resolveMessageReference(scope, { + name, + attr +}) { + const message = scope.bundle._messages.get(name); + if (!message) { + scope.reportError(new ReferenceError(`Unknown message: ${name}`)); + return new FluentNone(name); + } + if (attr) { + const attribute = message.attributes[attr]; + if (attribute) { + return resolvePattern(scope, attribute); + } + scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); + return new FluentNone(`${name}.${attr}`); + } + if (message.value) { + return resolvePattern(scope, message.value); + } + scope.reportError(new ReferenceError(`No value: ${name}`)); + return new FluentNone(name); +} +function resolveTermReference(scope, { + name, + attr, + args +}) { + const id = `-${name}`; + const term = scope.bundle._terms.get(id); + if (!term) { + scope.reportError(new ReferenceError(`Unknown term: ${id}`)); + return new FluentNone(id); + } + if (attr) { + const attribute = term.attributes[attr]; + if (attribute) { + scope.params = getArguments(scope, args).named; + const resolved = resolvePattern(scope, attribute); + scope.params = null; + return resolved; + } + scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`)); + return new FluentNone(`${id}.${attr}`); + } + scope.params = getArguments(scope, args).named; + const resolved = resolvePattern(scope, term.value); + scope.params = null; + return resolved; +} +function resolveFunctionReference(scope, { + name, + args +}) { + let func = scope.bundle._functions[name]; + if (!func) { + scope.reportError(new ReferenceError(`Unknown function: ${name}()`)); + return new FluentNone(`${name}()`); + } + if (typeof func !== "function") { + scope.reportError(new TypeError(`Function ${name}() is not callable`)); + return new FluentNone(`${name}()`); + } + try { + let resolved = getArguments(scope, args); + return func(resolved.positional, resolved.named); + } catch (err) { + scope.reportError(err); + return new FluentNone(`${name}()`); + } +} +function resolveSelectExpression(scope, { + selector, + variants, + star +}) { + let sel = resolveExpression(scope, selector); + if (sel instanceof FluentNone) { + return getDefault(scope, variants, star); + } + for (const variant of variants) { + const key = resolveExpression(scope, variant.key); + if (match(scope, sel, key)) { + return resolvePattern(scope, variant.value); + } + } + return getDefault(scope, variants, star); +} +function resolveComplexPattern(scope, ptn) { + if (scope.dirty.has(ptn)) { + scope.reportError(new RangeError("Cyclic reference")); + return new FluentNone(); + } + scope.dirty.add(ptn); + const result = []; + const useIsolating = scope.bundle._useIsolating && ptn.length > 1; + for (const elem of ptn) { + if (typeof elem === "string") { + result.push(scope.bundle._transform(elem)); + continue; + } + scope.placeables++; + if (scope.placeables > MAX_PLACEABLES) { + scope.dirty.delete(ptn); + throw new RangeError(`Too many placeables expanded: ${scope.placeables}, ` + `max allowed is ${MAX_PLACEABLES}`); + } + if (useIsolating) { + result.push(FSI); + } + result.push(resolveExpression(scope, elem).toString(scope)); + if (useIsolating) { + result.push(PDI); + } + } + scope.dirty.delete(ptn); + return result.join(""); +} +function resolvePattern(scope, value) { + if (typeof value === "string") { + return scope.bundle._transform(value); + } + return resolveComplexPattern(scope, value); +} +;// ./node_modules/@fluent/bundle/esm/scope.js +class Scope { + constructor(bundle, errors, args) { + this.dirty = new WeakSet(); + this.params = null; + this.placeables = 0; + this.bundle = bundle; + this.errors = errors; + this.args = args; + } + reportError(error) { + if (!this.errors || !(error instanceof Error)) { + throw error; + } + this.errors.push(error); + } + memoizeIntlObject(ctor, opts) { + let cache = this.bundle._intls.get(ctor); + if (!cache) { + cache = {}; + this.bundle._intls.set(ctor, cache); + } + let id = JSON.stringify(opts); + if (!cache[id]) { + cache[id] = new ctor(this.bundle.locales, opts); + } + return cache[id]; + } +} +;// ./node_modules/@fluent/bundle/esm/builtins.js + +function values(opts, allowed) { + const unwrapped = Object.create(null); + for (const [name, opt] of Object.entries(opts)) { + if (allowed.includes(name)) { + unwrapped[name] = opt.valueOf(); + } + } + return unwrapped; +} +const NUMBER_ALLOWED = ["unitDisplay", "currencyDisplay", "useGrouping", "minimumIntegerDigits", "minimumFractionDigits", "maximumFractionDigits", "minimumSignificantDigits", "maximumSignificantDigits"]; +function NUMBER(args, opts) { + let arg = args[0]; + if (arg instanceof FluentNone) { + return new FluentNone(`NUMBER(${arg.valueOf()})`); + } + if (arg instanceof FluentNumber) { + return new FluentNumber(arg.valueOf(), { + ...arg.opts, + ...values(opts, NUMBER_ALLOWED) + }); + } + if (arg instanceof FluentDateTime) { + return new FluentNumber(arg.valueOf(), { + ...values(opts, NUMBER_ALLOWED) + }); + } + throw new TypeError("Invalid argument to NUMBER"); +} +const DATETIME_ALLOWED = ["dateStyle", "timeStyle", "fractionalSecondDigits", "dayPeriod", "hour12", "weekday", "era", "year", "month", "day", "hour", "minute", "second", "timeZoneName"]; +function DATETIME(args, opts) { + let arg = args[0]; + if (arg instanceof FluentNone) { + return new FluentNone(`DATETIME(${arg.valueOf()})`); + } + if (arg instanceof FluentDateTime) { + return new FluentDateTime(arg.valueOf(), { + ...arg.opts, + ...values(opts, DATETIME_ALLOWED) + }); + } + if (arg instanceof FluentNumber) { + return new FluentDateTime(arg.valueOf(), { + ...values(opts, DATETIME_ALLOWED) + }); + } + throw new TypeError("Invalid argument to DATETIME"); +} +;// ./node_modules/@fluent/bundle/esm/memoizer.js +const cache = new Map(); +function getMemoizerForLocale(locales) { + const stringLocale = Array.isArray(locales) ? locales.join(" ") : locales; + let memoizer = cache.get(stringLocale); + if (memoizer === undefined) { + memoizer = new Map(); + cache.set(stringLocale, memoizer); + } + return memoizer; +} +;// ./node_modules/@fluent/bundle/esm/bundle.js + + + + + +class FluentBundle { + constructor(locales, { + functions, + useIsolating = true, + transform = v => v + } = {}) { + this._terms = new Map(); + this._messages = new Map(); + this.locales = Array.isArray(locales) ? locales : [locales]; + this._functions = { + NUMBER: NUMBER, + DATETIME: DATETIME, + ...functions + }; + this._useIsolating = useIsolating; + this._transform = transform; + this._intls = getMemoizerForLocale(locales); + } + hasMessage(id) { + return this._messages.has(id); + } + getMessage(id) { + return this._messages.get(id); + } + addResource(res, { + allowOverrides = false + } = {}) { + const errors = []; + for (let i = 0; i < res.body.length; i++) { + let entry = res.body[i]; + if (entry.id.startsWith("-")) { + if (allowOverrides === false && this._terms.has(entry.id)) { + errors.push(new Error(`Attempt to override an existing term: "${entry.id}"`)); + continue; + } + this._terms.set(entry.id, entry); + } else { + if (allowOverrides === false && this._messages.has(entry.id)) { + errors.push(new Error(`Attempt to override an existing message: "${entry.id}"`)); + continue; + } + this._messages.set(entry.id, entry); + } + } + return errors; + } + formatPattern(pattern, args = null, errors = null) { + if (typeof pattern === "string") { + return this._transform(pattern); + } + let scope = new Scope(this, errors, args); + try { + let value = resolveComplexPattern(scope, pattern); + return value.toString(scope); + } catch (err) { + if (scope.errors && err instanceof Error) { + scope.errors.push(err); + return new FluentNone().toString(scope); + } + throw err; + } + } +} +;// ./node_modules/@fluent/bundle/esm/resource.js +const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */gm; +const RE_ATTRIBUTE_START = /\.([a-zA-Z][\w-]*) *= */y; +const RE_VARIANT_START = /\*?\[/y; +const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y; +const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y; +const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y; +const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/; +const RE_TEXT_RUN = /([^{}\n\r]+)/y; +const RE_STRING_RUN = /([^\\"\n\r]*)/y; +const RE_STRING_ESCAPE = /\\([\\"])/y; +const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y; +const RE_LEADING_NEWLINES = /^\n+/; +const RE_TRAILING_SPACES = / +$/; +const RE_BLANK_LINES = / *\r?\n/g; +const RE_INDENT = /( *)$/; +const TOKEN_BRACE_OPEN = /{\s*/y; +const TOKEN_BRACE_CLOSE = /\s*}/y; +const TOKEN_BRACKET_OPEN = /\[\s*/y; +const TOKEN_BRACKET_CLOSE = /\s*] */y; +const TOKEN_PAREN_OPEN = /\s*\(\s*/y; +const TOKEN_ARROW = /\s*->\s*/y; +const TOKEN_COLON = /\s*:\s*/y; +const TOKEN_COMMA = /\s*,?\s*/y; +const TOKEN_BLANK = /\s+/y; +class FluentResource { + constructor(source) { + this.body = []; + RE_MESSAGE_START.lastIndex = 0; + let cursor = 0; + while (true) { + let next = RE_MESSAGE_START.exec(source); + if (next === null) { + break; + } + cursor = RE_MESSAGE_START.lastIndex; + try { + this.body.push(parseMessage(next[1])); + } catch (err) { + if (err instanceof SyntaxError) { + continue; + } + throw err; + } + } + function test(re) { + re.lastIndex = cursor; + return re.test(source); + } + function consumeChar(char, errorClass) { + if (source[cursor] === char) { + cursor++; + return true; + } + if (errorClass) { + throw new errorClass(`Expected ${char}`); + } + return false; + } + function consumeToken(re, errorClass) { + if (test(re)) { + cursor = re.lastIndex; + return true; + } + if (errorClass) { + throw new errorClass(`Expected ${re.toString()}`); + } + return false; + } + function match(re) { + re.lastIndex = cursor; + let result = re.exec(source); + if (result === null) { + throw new SyntaxError(`Expected ${re.toString()}`); + } + cursor = re.lastIndex; + return result; + } + function match1(re) { + return match(re)[1]; + } + function parseMessage(id) { + let value = parsePattern(); + let attributes = parseAttributes(); + if (value === null && Object.keys(attributes).length === 0) { + throw new SyntaxError("Expected message value or attributes"); + } + return { + id, + value, + attributes + }; + } + function parseAttributes() { + let attrs = Object.create(null); + while (test(RE_ATTRIBUTE_START)) { + let name = match1(RE_ATTRIBUTE_START); + let value = parsePattern(); + if (value === null) { + throw new SyntaxError("Expected attribute value"); + } + attrs[name] = value; + } + return attrs; + } + function parsePattern() { + let first; + if (test(RE_TEXT_RUN)) { + first = match1(RE_TEXT_RUN); + } + if (source[cursor] === "{" || source[cursor] === "}") { + return parsePatternElements(first ? [first] : [], Infinity); + } + let indent = parseIndent(); + if (indent) { + if (first) { + return parsePatternElements([first, indent], indent.length); + } + indent.value = trim(indent.value, RE_LEADING_NEWLINES); + return parsePatternElements([indent], indent.length); + } + if (first) { + return trim(first, RE_TRAILING_SPACES); + } + return null; + } + function parsePatternElements(elements = [], commonIndent) { + while (true) { + if (test(RE_TEXT_RUN)) { + elements.push(match1(RE_TEXT_RUN)); + continue; + } + if (source[cursor] === "{") { + elements.push(parsePlaceable()); + continue; + } + if (source[cursor] === "}") { + throw new SyntaxError("Unbalanced closing brace"); + } + let indent = parseIndent(); + if (indent) { + elements.push(indent); + commonIndent = Math.min(commonIndent, indent.length); + continue; + } + break; + } + let lastIndex = elements.length - 1; + let lastElement = elements[lastIndex]; + if (typeof lastElement === "string") { + elements[lastIndex] = trim(lastElement, RE_TRAILING_SPACES); + } + let baked = []; + for (let element of elements) { + if (element instanceof Indent) { + element = element.value.slice(0, element.value.length - commonIndent); + } + if (element) { + baked.push(element); + } + } + return baked; + } + function parsePlaceable() { + consumeToken(TOKEN_BRACE_OPEN, SyntaxError); + let selector = parseInlineExpression(); + if (consumeToken(TOKEN_BRACE_CLOSE)) { + return selector; + } + if (consumeToken(TOKEN_ARROW)) { + let variants = parseVariants(); + consumeToken(TOKEN_BRACE_CLOSE, SyntaxError); + return { + type: "select", + selector, + ...variants + }; + } + throw new SyntaxError("Unclosed placeable"); + } + function parseInlineExpression() { + if (source[cursor] === "{") { + return parsePlaceable(); + } + if (test(RE_REFERENCE)) { + let [, sigil, name, attr = null] = match(RE_REFERENCE); + if (sigil === "$") { + return { + type: "var", + name + }; + } + if (consumeToken(TOKEN_PAREN_OPEN)) { + let args = parseArguments(); + if (sigil === "-") { + return { + type: "term", + name, + attr, + args + }; + } + if (RE_FUNCTION_NAME.test(name)) { + return { + type: "func", + name, + args + }; + } + throw new SyntaxError("Function names must be all upper-case"); + } + if (sigil === "-") { + return { + type: "term", + name, + attr, + args: [] + }; + } + return { + type: "mesg", + name, + attr + }; + } + return parseLiteral(); + } + function parseArguments() { + let args = []; + while (true) { + switch (source[cursor]) { + case ")": + cursor++; + return args; + case undefined: + throw new SyntaxError("Unclosed argument list"); + } + args.push(parseArgument()); + consumeToken(TOKEN_COMMA); + } + } + function parseArgument() { + let expr = parseInlineExpression(); + if (expr.type !== "mesg") { + return expr; + } + if (consumeToken(TOKEN_COLON)) { + return { + type: "narg", + name: expr.name, + value: parseLiteral() + }; + } + return expr; + } + function parseVariants() { + let variants = []; + let count = 0; + let star; + while (test(RE_VARIANT_START)) { + if (consumeChar("*")) { + star = count; + } + let key = parseVariantKey(); + let value = parsePattern(); + if (value === null) { + throw new SyntaxError("Expected variant value"); + } + variants[count++] = { + key, + value + }; + } + if (count === 0) { + return null; + } + if (star === undefined) { + throw new SyntaxError("Expected default variant"); + } + return { + variants, + star + }; + } + function parseVariantKey() { + consumeToken(TOKEN_BRACKET_OPEN, SyntaxError); + let key; + if (test(RE_NUMBER_LITERAL)) { + key = parseNumberLiteral(); + } else { + key = { + type: "str", + value: match1(RE_IDENTIFIER) + }; + } + consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError); + return key; + } + function parseLiteral() { + if (test(RE_NUMBER_LITERAL)) { + return parseNumberLiteral(); + } + if (source[cursor] === '"') { + return parseStringLiteral(); + } + throw new SyntaxError("Invalid expression"); + } + function parseNumberLiteral() { + let [, value, fraction = ""] = match(RE_NUMBER_LITERAL); + let precision = fraction.length; + return { + type: "num", + value: parseFloat(value), + precision + }; + } + function parseStringLiteral() { + consumeChar('"', SyntaxError); + let value = ""; + while (true) { + value += match1(RE_STRING_RUN); + if (source[cursor] === "\\") { + value += parseEscapeSequence(); + continue; + } + if (consumeChar('"')) { + return { + type: "str", + value + }; + } + throw new SyntaxError("Unclosed string literal"); + } + } + function parseEscapeSequence() { + if (test(RE_STRING_ESCAPE)) { + return match1(RE_STRING_ESCAPE); + } + if (test(RE_UNICODE_ESCAPE)) { + let [, codepoint4, codepoint6] = match(RE_UNICODE_ESCAPE); + let codepoint = parseInt(codepoint4 || codepoint6, 16); + return codepoint <= 0xd7ff || 0xe000 <= codepoint ? String.fromCodePoint(codepoint) : "�"; + } + throw new SyntaxError("Unknown escape sequence"); + } + function parseIndent() { + let start = cursor; + consumeToken(TOKEN_BLANK); + switch (source[cursor]) { + case ".": + case "[": + case "*": + case "}": + case undefined: + return false; + case "{": + return makeIndent(source.slice(start, cursor)); + } + if (source[cursor - 1] === " ") { + return makeIndent(source.slice(start, cursor)); + } + return false; + } + function trim(text, re) { + return text.replace(re, ""); + } + function makeIndent(blank) { + let value = blank.replace(RE_BLANK_LINES, "\n"); + let length = RE_INDENT.exec(blank)[1].length; + return new Indent(value, length); + } + } +} +class Indent { + constructor(value, length) { + this.value = value; + this.length = length; + } +} +;// ./node_modules/@fluent/bundle/esm/index.js + + + +;// ./node_modules/@fluent/dom/esm/overlay.js +const reOverlay = /<|&#?\w+;/; +const TEXT_LEVEL_ELEMENTS = { + "http://www.w3.org/1999/xhtml": ["em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data", "time", "code", "var", "samp", "kbd", "sub", "sup", "i", "b", "u", "mark", "bdi", "bdo", "span", "br", "wbr"] +}; +const LOCALIZABLE_ATTRIBUTES = { + "http://www.w3.org/1999/xhtml": { + global: ["title", "aria-label", "aria-valuetext"], + a: ["download"], + area: ["download", "alt"], + input: ["alt", "placeholder"], + menuitem: ["label"], + menu: ["label"], + optgroup: ["label"], + option: ["label"], + track: ["label"], + img: ["alt"], + textarea: ["placeholder"], + th: ["abbr"] + }, + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": { + global: ["accesskey", "aria-label", "aria-valuetext", "label", "title", "tooltiptext"], + description: ["value"], + key: ["key", "keycode"], + label: ["value"], + textbox: ["placeholder", "value"] + } +}; +function translateElement(element, translation) { + const { + value + } = translation; + if (typeof value === "string") { + if (element.localName === "title" && element.namespaceURI === "http://www.w3.org/1999/xhtml") { + element.textContent = value; + } else if (!reOverlay.test(value)) { + element.textContent = value; + } else { + const templateElement = element.ownerDocument.createElementNS("http://www.w3.org/1999/xhtml", "template"); + templateElement.innerHTML = value; + overlayChildNodes(templateElement.content, element); + } + } + overlayAttributes(translation, element); +} +function overlayChildNodes(fromFragment, toElement) { + for (const childNode of fromFragment.childNodes) { + if (childNode.nodeType === childNode.TEXT_NODE) { + continue; + } + if (childNode.hasAttribute("data-l10n-name")) { + const sanitized = getNodeForNamedElement(toElement, childNode); + fromFragment.replaceChild(sanitized, childNode); + continue; + } + if (isElementAllowed(childNode)) { + const sanitized = createSanitizedElement(childNode); + fromFragment.replaceChild(sanitized, childNode); + continue; + } + console.warn(`An element of forbidden type "${childNode.localName}" was found in ` + "the translation. Only safe text-level elements and elements with " + "data-l10n-name are allowed."); + fromFragment.replaceChild(createTextNodeFromTextContent(childNode), childNode); + } + toElement.textContent = ""; + toElement.appendChild(fromFragment); +} +function hasAttribute(attributes, name) { + if (!attributes) { + return false; + } + for (let attr of attributes) { + if (attr.name === name) { + return true; + } + } + return false; +} +function overlayAttributes(fromElement, toElement) { + const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs") ? toElement.getAttribute("data-l10n-attrs").split(",").map(i => i.trim()) : null; + for (const attr of Array.from(toElement.attributes)) { + if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed) && !hasAttribute(fromElement.attributes, attr.name)) { + toElement.removeAttribute(attr.name); + } + } + if (!fromElement.attributes) { + return; + } + for (const attr of Array.from(fromElement.attributes)) { + if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed) && toElement.getAttribute(attr.name) !== attr.value) { + toElement.setAttribute(attr.name, attr.value); + } + } +} +function getNodeForNamedElement(sourceElement, translatedChild) { + const childName = translatedChild.getAttribute("data-l10n-name"); + const sourceChild = sourceElement.querySelector(`[data-l10n-name="${childName}"]`); + if (!sourceChild) { + console.warn(`An element named "${childName}" wasn't found in the source.`); + return createTextNodeFromTextContent(translatedChild); + } + if (sourceChild.localName !== translatedChild.localName) { + console.warn(`An element named "${childName}" was found in the translation ` + `but its type ${translatedChild.localName} didn't match the ` + `element found in the source (${sourceChild.localName}).`); + return createTextNodeFromTextContent(translatedChild); + } + sourceElement.removeChild(sourceChild); + const clone = sourceChild.cloneNode(false); + return shallowPopulateUsing(translatedChild, clone); +} +function createSanitizedElement(element) { + const clone = element.ownerDocument.createElement(element.localName); + return shallowPopulateUsing(element, clone); +} +function createTextNodeFromTextContent(element) { + return element.ownerDocument.createTextNode(element.textContent); +} +function isElementAllowed(element) { + const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI]; + return allowed && allowed.includes(element.localName); +} +function isAttrNameLocalizable(name, element, explicitlyAllowed = null) { + if (explicitlyAllowed && explicitlyAllowed.includes(name)) { + return true; + } + const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI]; + if (!allowed) { + return false; + } + const attrName = name.toLowerCase(); + const elemName = element.localName; + if (allowed.global.includes(attrName)) { + return true; + } + if (!allowed[elemName]) { + return false; + } + if (allowed[elemName].includes(attrName)) { + return true; + } + if (element.namespaceURI === "http://www.w3.org/1999/xhtml" && elemName === "input" && attrName === "value") { + const type = element.type.toLowerCase(); + if (type === "submit" || type === "button" || type === "reset") { + return true; + } + } + return false; +} +function shallowPopulateUsing(fromElement, toElement) { + toElement.textContent = fromElement.textContent; + overlayAttributes(fromElement, toElement); + return toElement; +} +;// ./node_modules/cached-iterable/src/cached_iterable.mjs +class CachedIterable extends Array { + static from(iterable) { + if (iterable instanceof this) { + return iterable; + } + return new this(iterable); + } +} +;// ./node_modules/cached-iterable/src/cached_sync_iterable.mjs + +class CachedSyncIterable extends CachedIterable { + constructor(iterable) { + super(); + if (Symbol.iterator in Object(iterable)) { + this.iterator = iterable[Symbol.iterator](); + } else { + throw new TypeError("Argument must implement the iteration protocol."); + } + } + [Symbol.iterator]() { + const cached = this; + let cur = 0; + return { + next() { + if (cached.length <= cur) { + cached.push(cached.iterator.next()); + } + return cached[cur++]; + } + }; + } + touchNext(count = 1) { + let idx = 0; + while (idx++ < count) { + const last = this[this.length - 1]; + if (last && last.done) { + break; + } + this.push(this.iterator.next()); + } + return this[this.length - 1]; + } +} +;// ./node_modules/cached-iterable/src/cached_async_iterable.mjs + +class CachedAsyncIterable extends CachedIterable { + constructor(iterable) { + super(); + if (Symbol.asyncIterator in Object(iterable)) { + this.iterator = iterable[Symbol.asyncIterator](); + } else if (Symbol.iterator in Object(iterable)) { + this.iterator = iterable[Symbol.iterator](); + } else { + throw new TypeError("Argument must implement the iteration protocol."); + } + } + [Symbol.asyncIterator]() { + const cached = this; + let cur = 0; + return { + async next() { + if (cached.length <= cur) { + cached.push(cached.iterator.next()); + } + return cached[cur++]; + } + }; + } + async touchNext(count = 1) { + let idx = 0; + while (idx++ < count) { + const last = this[this.length - 1]; + if (last && (await last).done) { + break; + } + this.push(this.iterator.next()); + } + return this[this.length - 1]; + } +} +;// ./node_modules/cached-iterable/src/index.mjs + + +;// ./node_modules/@fluent/dom/esm/localization.js + +class Localization { + constructor(resourceIds = [], generateBundles) { + this.resourceIds = resourceIds; + this.generateBundles = generateBundles; + this.onChange(true); + } + addResourceIds(resourceIds, eager = false) { + this.resourceIds.push(...resourceIds); + this.onChange(eager); + return this.resourceIds.length; + } + removeResourceIds(resourceIds) { + this.resourceIds = this.resourceIds.filter(r => !resourceIds.includes(r)); + this.onChange(); + return this.resourceIds.length; + } + async formatWithFallback(keys, method) { + const translations = []; + let hasAtLeastOneBundle = false; + for await (const bundle of this.bundles) { + hasAtLeastOneBundle = true; + const missingIds = keysFromBundle(method, bundle, keys, translations); + if (missingIds.size === 0) { + break; + } + if (typeof console !== "undefined") { + const locale = bundle.locales[0]; + const ids = Array.from(missingIds).join(", "); + console.warn(`[fluent] Missing translations in ${locale}: ${ids}`); + } + } + if (!hasAtLeastOneBundle && typeof console !== "undefined") { + console.warn(`[fluent] Request for keys failed because no resource bundles got generated. + keys: ${JSON.stringify(keys)}. + resourceIds: ${JSON.stringify(this.resourceIds)}.`); + } + return translations; + } + formatMessages(keys) { + return this.formatWithFallback(keys, messageFromBundle); + } + formatValues(keys) { + return this.formatWithFallback(keys, valueFromBundle); + } + async formatValue(id, args) { + const [val] = await this.formatValues([{ + id, + args + }]); + return val; + } + handleEvent() { + this.onChange(); + } + onChange(eager = false) { + this.bundles = CachedAsyncIterable.from(this.generateBundles(this.resourceIds)); + if (eager) { + this.bundles.touchNext(2); + } + } +} +function valueFromBundle(bundle, errors, message, args) { + if (message.value) { + return bundle.formatPattern(message.value, args, errors); + } + return null; +} +function messageFromBundle(bundle, errors, message, args) { + const formatted = { + value: null, + attributes: null + }; + if (message.value) { + formatted.value = bundle.formatPattern(message.value, args, errors); + } + let attrNames = Object.keys(message.attributes); + if (attrNames.length > 0) { + formatted.attributes = new Array(attrNames.length); + for (let [i, name] of attrNames.entries()) { + let value = bundle.formatPattern(message.attributes[name], args, errors); + formatted.attributes[i] = { + name, + value + }; + } + } + return formatted; +} +function keysFromBundle(method, bundle, keys, translations) { + const messageErrors = []; + const missingIds = new Set(); + keys.forEach(({ + id, + args + }, i) => { + if (translations[i] !== undefined) { + return; + } + let message = bundle.getMessage(id); + if (message) { + messageErrors.length = 0; + translations[i] = method(bundle, messageErrors, message, args); + if (messageErrors.length > 0 && typeof console !== "undefined") { + const locale = bundle.locales[0]; + const errors = messageErrors.join(", "); + console.warn(`[fluent][resolver] errors in ${locale}/${id}: ${errors}.`); + } + } else { + missingIds.add(id); + } + }); + return missingIds; +} +;// ./node_modules/@fluent/dom/esm/dom_localization.js + + +const L10NID_ATTR_NAME = "data-l10n-id"; +const L10NARGS_ATTR_NAME = "data-l10n-args"; +const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`; +class DOMLocalization extends Localization { + constructor(resourceIds, generateBundles) { + super(resourceIds, generateBundles); + this.roots = new Set(); + this.pendingrAF = null; + this.pendingElements = new Set(); + this.windowElement = null; + this.mutationObserver = null; + this.observerConfig = { + attributes: true, + characterData: false, + childList: true, + subtree: true, + attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME] + }; + } + onChange(eager = false) { + super.onChange(eager); + if (this.roots) { + this.translateRoots(); + } + } + setAttributes(element, id, args) { + element.setAttribute(L10NID_ATTR_NAME, id); + if (args) { + element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args)); + } else { + element.removeAttribute(L10NARGS_ATTR_NAME); + } + return element; + } + getAttributes(element) { + return { + id: element.getAttribute(L10NID_ATTR_NAME), + args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null) + }; + } + connectRoot(newRoot) { + for (const root of this.roots) { + if (root === newRoot || root.contains(newRoot) || newRoot.contains(root)) { + throw new Error("Cannot add a root that overlaps with existing root."); + } + } + if (this.windowElement) { + if (this.windowElement !== newRoot.ownerDocument.defaultView) { + throw new Error(`Cannot connect a root: + DOMLocalization already has a root from a different window.`); + } + } else { + this.windowElement = newRoot.ownerDocument.defaultView; + this.mutationObserver = new this.windowElement.MutationObserver(mutations => this.translateMutations(mutations)); + } + this.roots.add(newRoot); + this.mutationObserver.observe(newRoot, this.observerConfig); + } + disconnectRoot(root) { + this.roots.delete(root); + this.pauseObserving(); + if (this.roots.size === 0) { + this.mutationObserver = null; + if (this.windowElement && this.pendingrAF) { + this.windowElement.cancelAnimationFrame(this.pendingrAF); + } + this.windowElement = null; + this.pendingrAF = null; + this.pendingElements.clear(); + return true; + } + this.resumeObserving(); + return false; + } + translateRoots() { + const roots = Array.from(this.roots); + return Promise.all(roots.map(root => this.translateFragment(root))); + } + pauseObserving() { + if (!this.mutationObserver) { + return; + } + this.translateMutations(this.mutationObserver.takeRecords()); + this.mutationObserver.disconnect(); + } + resumeObserving() { + if (!this.mutationObserver) { + return; + } + for (const root of this.roots) { + this.mutationObserver.observe(root, this.observerConfig); + } + } + translateMutations(mutations) { + for (const mutation of mutations) { + switch (mutation.type) { + case "attributes": + if (mutation.target.hasAttribute("data-l10n-id")) { + this.pendingElements.add(mutation.target); + } + break; + case "childList": + for (const addedNode of mutation.addedNodes) { + if (addedNode.nodeType === addedNode.ELEMENT_NODE) { + if (addedNode.childElementCount) { + for (const element of this.getTranslatables(addedNode)) { + this.pendingElements.add(element); + } + } else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) { + this.pendingElements.add(addedNode); + } + } + } + break; + } + } + if (this.pendingElements.size > 0) { + if (this.pendingrAF === null) { + this.pendingrAF = this.windowElement.requestAnimationFrame(() => { + this.translateElements(Array.from(this.pendingElements)); + this.pendingElements.clear(); + this.pendingrAF = null; + }); + } + } + } + translateFragment(frag) { + return this.translateElements(this.getTranslatables(frag)); + } + async translateElements(elements) { + if (!elements.length) { + return undefined; + } + const keys = elements.map(this.getKeysForElement); + const translations = await this.formatMessages(keys); + return this.applyTranslations(elements, translations); + } + applyTranslations(elements, translations) { + this.pauseObserving(); + for (let i = 0; i < elements.length; i++) { + if (translations[i] !== undefined) { + translateElement(elements[i], translations[i]); + } + } + this.resumeObserving(); + } + getTranslatables(element) { + const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY)); + if (typeof element.hasAttribute === "function" && element.hasAttribute(L10NID_ATTR_NAME)) { + nodes.push(element); + } + return nodes; + } + getKeysForElement(element) { + return { + id: element.getAttribute(L10NID_ATTR_NAME), + args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null) + }; + } +} +;// ./node_modules/@fluent/dom/esm/index.js + + +;// ./web/l10n.js +class L10n { + #dir; + #elements; + #lang; + #l10n; + constructor({ + lang, + isRTL + }, l10n = null) { + this.#lang = L10n.#fixupLangCode(lang); + this.#l10n = l10n; + this.#dir = isRTL ?? L10n.#isRTL(this.#lang) ? "rtl" : "ltr"; + } + _setL10n(l10n) { + this.#l10n = l10n; + } + getLanguage() { + return this.#lang; + } + getDirection() { + return this.#dir; + } + async get(ids, args = null, fallback) { + if (Array.isArray(ids)) { + ids = ids.map(id => ({ + id + })); + const messages = await this.#l10n.formatMessages(ids); + return messages.map(message => message.value); + } + const messages = await this.#l10n.formatMessages([{ + id: ids, + args + }]); + return messages[0]?.value || fallback; + } + async translate(element) { + (this.#elements ||= new Set()).add(element); + try { + this.#l10n.connectRoot(element); + await this.#l10n.translateRoots(); + } catch {} + } + async translateOnce(element) { + try { + await this.#l10n.translateElements([element]); + } catch (ex) { + console.error("translateOnce:", ex); + } + } + async destroy() { + if (this.#elements) { + for (const element of this.#elements) { + this.#l10n.disconnectRoot(element); + } + this.#elements.clear(); + this.#elements = null; + } + this.#l10n.pauseObserving(); + } + pause() { + this.#l10n.pauseObserving(); + } + resume() { + this.#l10n.resumeObserving(); + } + static #fixupLangCode(langCode) { + langCode = langCode?.toLowerCase() || "en-us"; + const PARTIAL_LANG_CODES = { + en: "en-us", + es: "es-es", + fy: "fy-nl", + ga: "ga-ie", + gu: "gu-in", + hi: "hi-in", + hy: "hy-am", + nb: "nb-no", + ne: "ne-np", + nn: "nn-no", + pa: "pa-in", + pt: "pt-pt", + sv: "sv-se", + zh: "zh-cn" + }; + return PARTIAL_LANG_CODES[langCode] || langCode; + } + static #isRTL(lang) { + const shortCode = lang.split("-", 1)[0]; + return ["ar", "he", "fa", "ps", "ur"].includes(shortCode); + } +} +const GenericL10n = null; + +;// ./web/genericl10n.js + + + + +function createBundle(lang, text) { + const resource = new FluentResource(text); + const bundle = new FluentBundle(lang); + const errors = bundle.addResource(resource); + if (errors.length) { + console.error("L10n errors", errors); + } + return bundle; +} +class genericl10n_GenericL10n extends L10n { + constructor(lang) { + super({ + lang + }); + const generateBundles = !lang ? genericl10n_GenericL10n.#generateBundlesFallback.bind(genericl10n_GenericL10n, this.getLanguage()) : genericl10n_GenericL10n.#generateBundles.bind(genericl10n_GenericL10n, "en-us", this.getLanguage()); + this._setL10n(new DOMLocalization([], generateBundles)); + } + static async *#generateBundles(defaultLang, baseLang) { + const { + baseURL, + paths + } = await this.#getPaths(); + const langs = [baseLang]; + if (defaultLang !== baseLang) { + const shortLang = baseLang.split("-", 1)[0]; + if (shortLang !== baseLang) { + langs.push(shortLang); + } + langs.push(defaultLang); + } + for (const lang of langs) { + const bundle = await this.#createBundle(lang, baseURL, paths); + if (bundle) { + yield bundle; + } else if (lang === "en-us") { + yield this.#createBundleFallback(lang); + } + } + } + static async #createBundle(lang, baseURL, paths) { + const path = paths[lang]; + if (!path) { + return null; + } + const url = new URL(path, baseURL); + const text = await fetchData(url, "text"); + return createBundle(lang, text); + } + static async #getPaths() { + try { + const { + href + } = document.querySelector(`link[type="application/l10n"]`); + const paths = await fetchData(href, "json"); + return { + baseURL: href.replace(/[^/]*$/, "") || "./", + paths + }; + } catch {} + return { + baseURL: "./", + paths: Object.create(null) + }; + } + static async *#generateBundlesFallback(lang) { + yield this.#createBundleFallback(lang); + } + static async #createBundleFallback(lang) { + const text = "pdfjs-previous-button =\n .title = Previous Page\npdfjs-previous-button-label = Previous\npdfjs-next-button =\n .title = Next Page\npdfjs-next-button-label = Next\npdfjs-page-input =\n .title = Page\npdfjs-of-pages = of { $pagesCount }\npdfjs-page-of-pages = ({ $pageNumber } of { $pagesCount })\npdfjs-zoom-out-button =\n .title = Zoom Out\npdfjs-zoom-out-button-label = Zoom Out\npdfjs-zoom-in-button =\n .title = Zoom In\npdfjs-zoom-in-button-label = Zoom In\npdfjs-zoom-select =\n .title = Zoom\npdfjs-presentation-mode-button =\n .title = Switch to Presentation Mode\npdfjs-presentation-mode-button-label = Presentation Mode\npdfjs-open-file-button =\n .title = Open File\npdfjs-open-file-button-label = Open\npdfjs-print-button =\n .title = Print\npdfjs-print-button-label = Print\npdfjs-save-button =\n .title = Save\npdfjs-save-button-label = Save\npdfjs-download-button =\n .title = Download\npdfjs-download-button-label = Download\npdfjs-bookmark-button =\n .title = Current Page (View URL from Current Page)\npdfjs-bookmark-button-label = Current Page\npdfjs-tools-button =\n .title = Tools\npdfjs-tools-button-label = Tools\npdfjs-first-page-button =\n .title = Go to First Page\npdfjs-first-page-button-label = Go to First Page\npdfjs-last-page-button =\n .title = Go to Last Page\npdfjs-last-page-button-label = Go to Last Page\npdfjs-page-rotate-cw-button =\n .title = Rotate Clockwise\npdfjs-page-rotate-cw-button-label = Rotate Clockwise\npdfjs-page-rotate-ccw-button =\n .title = Rotate Counterclockwise\npdfjs-page-rotate-ccw-button-label = Rotate Counterclockwise\npdfjs-cursor-text-select-tool-button =\n .title = Enable Text Selection Tool\npdfjs-cursor-text-select-tool-button-label = Text Selection Tool\npdfjs-cursor-hand-tool-button =\n .title = Enable Hand Tool\npdfjs-cursor-hand-tool-button-label = Hand Tool\npdfjs-scroll-page-button =\n .title = Use Page Scrolling\npdfjs-scroll-page-button-label = Page Scrolling\npdfjs-scroll-vertical-button =\n .title = Use Vertical Scrolling\npdfjs-scroll-vertical-button-label = Vertical Scrolling\npdfjs-scroll-horizontal-button =\n .title = Use Horizontal Scrolling\npdfjs-scroll-horizontal-button-label = Horizontal Scrolling\npdfjs-scroll-wrapped-button =\n .title = Use Wrapped Scrolling\npdfjs-scroll-wrapped-button-label = Wrapped Scrolling\npdfjs-spread-none-button =\n .title = Do not join page spreads\npdfjs-spread-none-button-label = No Spreads\npdfjs-spread-odd-button =\n .title = Join page spreads starting with odd-numbered pages\npdfjs-spread-odd-button-label = Odd Spreads\npdfjs-spread-even-button =\n .title = Join page spreads starting with even-numbered pages\npdfjs-spread-even-button-label = Even Spreads\npdfjs-document-properties-button =\n .title = Document Properties\u2026\npdfjs-document-properties-button-label = Document Properties\u2026\npdfjs-document-properties-file-name = File name:\npdfjs-document-properties-file-size = File size:\npdfjs-document-properties-size-kb = { NUMBER($kb, maximumSignificantDigits: 3) } KB ({ $b } bytes)\npdfjs-document-properties-size-mb = { NUMBER($mb, maximumSignificantDigits: 3) } MB ({ $b } bytes)\npdfjs-document-properties-title = Title:\npdfjs-document-properties-author = Author:\npdfjs-document-properties-subject = Subject:\npdfjs-document-properties-keywords = Keywords:\npdfjs-document-properties-creation-date = Creation Date:\npdfjs-document-properties-modification-date = Modification Date:\npdfjs-document-properties-date-time-string = { DATETIME($dateObj, dateStyle: \"short\", timeStyle: \"medium\") }\npdfjs-document-properties-creator = Creator:\npdfjs-document-properties-producer = PDF Producer:\npdfjs-document-properties-version = PDF Version:\npdfjs-document-properties-page-count = Page Count:\npdfjs-document-properties-page-size = Page Size:\npdfjs-document-properties-page-size-unit-inches = in\npdfjs-document-properties-page-size-unit-millimeters = mm\npdfjs-document-properties-page-size-orientation-portrait = portrait\npdfjs-document-properties-page-size-orientation-landscape = landscape\npdfjs-document-properties-page-size-name-a-three = A3\npdfjs-document-properties-page-size-name-a-four = A4\npdfjs-document-properties-page-size-name-letter = Letter\npdfjs-document-properties-page-size-name-legal = Legal\npdfjs-document-properties-page-size-dimension-string = { $width } \xD7 { $height } { $unit } ({ $orientation })\npdfjs-document-properties-page-size-dimension-name-string = { $width } \xD7 { $height } { $unit } ({ $name }, { $orientation })\npdfjs-document-properties-linearized = Fast Web View:\npdfjs-document-properties-linearized-yes = Yes\npdfjs-document-properties-linearized-no = No\npdfjs-document-properties-close-button = Close\npdfjs-print-progress-message = Preparing document for printing\u2026\npdfjs-print-progress-percent = { $progress }%\npdfjs-print-progress-close-button = Cancel\npdfjs-printing-not-supported = Warning: Printing is not fully supported by this browser.\npdfjs-printing-not-ready = Warning: The PDF is not fully loaded for printing.\npdfjs-toggle-sidebar-button =\n .title = Toggle Sidebar\npdfjs-toggle-sidebar-notification-button =\n .title = Toggle Sidebar (document contains outline/attachments/layers)\npdfjs-toggle-sidebar-button-label = Toggle Sidebar\npdfjs-document-outline-button =\n .title = Show Document Outline (double-click to expand/collapse all items)\npdfjs-document-outline-button-label = Document Outline\npdfjs-attachments-button =\n .title = Show Attachments\npdfjs-attachments-button-label = Attachments\npdfjs-layers-button =\n .title = Show Layers (double-click to reset all layers to the default state)\npdfjs-layers-button-label = Layers\npdfjs-thumbs-button =\n .title = Show Thumbnails\npdfjs-thumbs-button-label = Thumbnails\npdfjs-current-outline-item-button =\n .title = Find Current Outline Item\npdfjs-current-outline-item-button-label = Current Outline Item\npdfjs-findbar-button =\n .title = Find in Document\npdfjs-findbar-button-label = Find\npdfjs-additional-layers = Additional Layers\npdfjs-thumb-page-title =\n .title = Page { $page }\npdfjs-thumb-page-canvas =\n .aria-label = Thumbnail of Page { $page }\npdfjs-find-input =\n .title = Find\n .placeholder = Find in document\u2026\npdfjs-find-previous-button =\n .title = Find the previous occurrence of the phrase\npdfjs-find-previous-button-label = Previous\npdfjs-find-next-button =\n .title = Find the next occurrence of the phrase\npdfjs-find-next-button-label = Next\npdfjs-find-highlight-checkbox = Highlight All\npdfjs-find-match-case-checkbox-label = Match Case\npdfjs-find-match-diacritics-checkbox-label = Match Diacritics\npdfjs-find-entire-word-checkbox-label = Whole Words\npdfjs-find-reached-top = Reached top of document, continued from bottom\npdfjs-find-reached-bottom = Reached end of document, continued from top\npdfjs-find-match-count =\n { $total ->\n [one] { $current } of { $total } match\n *[other] { $current } of { $total } matches\n }\npdfjs-find-match-count-limit =\n { $limit ->\n [one] More than { $limit } match\n *[other] More than { $limit } matches\n }\npdfjs-find-not-found = Phrase not found\npdfjs-page-scale-width = Page Width\npdfjs-page-scale-fit = Page Fit\npdfjs-page-scale-auto = Automatic Zoom\npdfjs-page-scale-actual = Actual Size\npdfjs-page-scale-percent = { $scale }%\npdfjs-page-landmark =\n .aria-label = Page { $page }\npdfjs-loading-error = An error occurred while loading the PDF.\npdfjs-invalid-file-error = Invalid or corrupted PDF file.\npdfjs-missing-file-error = Missing PDF file.\npdfjs-unexpected-response-error = Unexpected server response.\npdfjs-rendering-error = An error occurred while rendering the page.\npdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: \"short\", timeStyle: \"medium\") }\npdfjs-text-annotation-type =\n .alt = [{ $type } Annotation]\npdfjs-password-label = Enter the password to open this PDF file.\npdfjs-password-invalid = Invalid password. Please try again.\npdfjs-password-ok-button = OK\npdfjs-password-cancel-button = Cancel\npdfjs-web-fonts-disabled = Web fonts are disabled: unable to use embedded PDF fonts.\npdfjs-editor-free-text-button =\n .title = Text\npdfjs-editor-free-text-button-label = Text\npdfjs-editor-ink-button =\n .title = Draw\npdfjs-editor-ink-button-label = Draw\npdfjs-editor-stamp-button =\n .title = Add or edit images\npdfjs-editor-stamp-button-label = Add or edit images\npdfjs-editor-highlight-button =\n .title = Highlight\npdfjs-editor-highlight-button-label = Highlight\npdfjs-highlight-floating-button1 =\n .title = Highlight\n .aria-label = Highlight\npdfjs-highlight-floating-button-label = Highlight\npdfjs-editor-remove-ink-button =\n .title = Remove drawing\npdfjs-editor-remove-freetext-button =\n .title = Remove text\npdfjs-editor-remove-stamp-button =\n .title = Remove image\npdfjs-editor-remove-highlight-button =\n .title = Remove highlight\npdfjs-editor-free-text-color-input = Color\npdfjs-editor-free-text-size-input = Size\npdfjs-editor-ink-color-input = Color\npdfjs-editor-ink-thickness-input = Thickness\npdfjs-editor-ink-opacity-input = Opacity\npdfjs-editor-stamp-add-image-button =\n .title = Add image\npdfjs-editor-stamp-add-image-button-label = Add image\npdfjs-editor-free-highlight-thickness-input = Thickness\npdfjs-editor-free-highlight-thickness-title =\n .title = Change thickness when highlighting items other than text\npdfjs-free-text2 =\n .aria-label = Text Editor\n .default-content = Start typing\u2026\npdfjs-ink =\n .aria-label = Draw Editor\npdfjs-ink-canvas =\n .aria-label = User-created image\npdfjs-editor-alt-text-button =\n .aria-label = Alt text\npdfjs-editor-alt-text-button-label = Alt text\npdfjs-editor-alt-text-edit-button =\n .aria-label = Edit alt text\npdfjs-editor-alt-text-dialog-label = Choose an option\npdfjs-editor-alt-text-dialog-description = Alt text (alternative text) helps when people can\u2019t see the image or when it doesn\u2019t load.\npdfjs-editor-alt-text-add-description-label = Add a description\npdfjs-editor-alt-text-add-description-description = Aim for 1-2 sentences that describe the subject, setting, or actions.\npdfjs-editor-alt-text-mark-decorative-label = Mark as decorative\npdfjs-editor-alt-text-mark-decorative-description = This is used for ornamental images, like borders or watermarks.\npdfjs-editor-alt-text-cancel-button = Cancel\npdfjs-editor-alt-text-save-button = Save\npdfjs-editor-alt-text-decorative-tooltip = Marked as decorative\npdfjs-editor-alt-text-textarea =\n .placeholder = For example, \u201CA young man sits down at a table to eat a meal\u201D\npdfjs-editor-resizer-top-left =\n .aria-label = Top left corner \u2014 resize\npdfjs-editor-resizer-top-middle =\n .aria-label = Top middle \u2014 resize\npdfjs-editor-resizer-top-right =\n .aria-label = Top right corner \u2014 resize\npdfjs-editor-resizer-middle-right =\n .aria-label = Middle right \u2014 resize\npdfjs-editor-resizer-bottom-right =\n .aria-label = Bottom right corner \u2014 resize\npdfjs-editor-resizer-bottom-middle =\n .aria-label = Bottom middle \u2014 resize\npdfjs-editor-resizer-bottom-left =\n .aria-label = Bottom left corner \u2014 resize\npdfjs-editor-resizer-middle-left =\n .aria-label = Middle left \u2014 resize\npdfjs-editor-highlight-colorpicker-label = Highlight color\npdfjs-editor-colorpicker-button =\n .title = Change color\npdfjs-editor-colorpicker-dropdown =\n .aria-label = Color choices\npdfjs-editor-colorpicker-yellow =\n .title = Yellow\npdfjs-editor-colorpicker-green =\n .title = Green\npdfjs-editor-colorpicker-blue =\n .title = Blue\npdfjs-editor-colorpicker-pink =\n .title = Pink\npdfjs-editor-colorpicker-red =\n .title = Red\npdfjs-editor-highlight-show-all-button-label = Show all\npdfjs-editor-highlight-show-all-button =\n .title = Show all\npdfjs-editor-new-alt-text-dialog-edit-label = Edit alt text (image description)\npdfjs-editor-new-alt-text-dialog-add-label = Add alt text (image description)\npdfjs-editor-new-alt-text-textarea =\n .placeholder = Write your description here\u2026\npdfjs-editor-new-alt-text-description = Short description for people who can\u2019t see the image or when the image doesn\u2019t load.\npdfjs-editor-new-alt-text-disclaimer1 = This alt text was created automatically and may be inaccurate.\npdfjs-editor-new-alt-text-disclaimer-learn-more-url = Learn more\npdfjs-editor-new-alt-text-create-automatically-button-label = Create alt text automatically\npdfjs-editor-new-alt-text-not-now-button = Not now\npdfjs-editor-new-alt-text-error-title = Couldn\u2019t create alt text automatically\npdfjs-editor-new-alt-text-error-description = Please write your own alt text or try again later.\npdfjs-editor-new-alt-text-error-close-button = Close\npdfjs-editor-new-alt-text-ai-model-downloading-progress = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB)\n .aria-valuetext = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB)\npdfjs-editor-new-alt-text-added-button =\n .aria-label = Alt text added\npdfjs-editor-new-alt-text-added-button-label = Alt text added\npdfjs-editor-new-alt-text-missing-button =\n .aria-label = Missing alt text\npdfjs-editor-new-alt-text-missing-button-label = Missing alt text\npdfjs-editor-new-alt-text-to-review-button =\n .aria-label = Review alt text\npdfjs-editor-new-alt-text-to-review-button-label = Review alt text\npdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = Created automatically: { $generatedAltText }\npdfjs-image-alt-text-settings-button =\n .title = Image alt text settings\npdfjs-image-alt-text-settings-button-label = Image alt text settings\npdfjs-editor-alt-text-settings-dialog-label = Image alt text settings\npdfjs-editor-alt-text-settings-automatic-title = Automatic alt text\npdfjs-editor-alt-text-settings-create-model-button-label = Create alt text automatically\npdfjs-editor-alt-text-settings-create-model-description = Suggests descriptions to help people who can\u2019t see the image or when the image doesn\u2019t load.\npdfjs-editor-alt-text-settings-download-model-label = Alt text AI model ({ $totalSize } MB)\npdfjs-editor-alt-text-settings-ai-model-description = Runs locally on your device so your data stays private. Required for automatic alt text.\npdfjs-editor-alt-text-settings-delete-model-button = Delete\npdfjs-editor-alt-text-settings-download-model-button = Download\npdfjs-editor-alt-text-settings-downloading-model-button = Downloading\u2026\npdfjs-editor-alt-text-settings-editor-title = Alt text editor\npdfjs-editor-alt-text-settings-show-dialog-button-label = Show alt text editor right away when adding an image\npdfjs-editor-alt-text-settings-show-dialog-description = Helps you make sure all your images have alt text.\npdfjs-editor-alt-text-settings-close-button = Close\npdfjs-editor-undo-bar-message-highlight = Highlight removed\npdfjs-editor-undo-bar-message-freetext = Text removed\npdfjs-editor-undo-bar-message-ink = Drawing removed\npdfjs-editor-undo-bar-message-stamp = Image removed\npdfjs-editor-undo-bar-message-multiple =\n { $count ->\n [one] { $count } annotation removed\n *[other] { $count } annotations removed\n }\npdfjs-editor-undo-bar-undo-button =\n .title = Undo\npdfjs-editor-undo-bar-undo-button-label = Undo\npdfjs-editor-undo-bar-close-button =\n .title = Close\npdfjs-editor-undo-bar-close-button-label = Close"; + return createBundle(lang, text); + } +} + +;// ./web/pdf_history.js + + +const HASH_CHANGE_TIMEOUT = 1000; +const POSITION_UPDATED_THRESHOLD = 50; +const UPDATE_VIEWAREA_TIMEOUT = 1000; +function getCurrentHash() { + return document.location.hash; +} +class PDFHistory { + #eventAbortController = null; + constructor({ + linkService, + eventBus + }) { + this.linkService = linkService; + this.eventBus = eventBus; + this._initialized = false; + this._fingerprint = ""; + this.reset(); + this.eventBus._on("pagesinit", () => { + this._isPagesLoaded = false; + this.eventBus._on("pagesloaded", evt => { + this._isPagesLoaded = !!evt.pagesCount; + }, { + once: true + }); + }); + } + initialize({ + fingerprint, + resetHistory = false, + updateUrl = false + }) { + if (!fingerprint || typeof fingerprint !== "string") { + console.error('PDFHistory.initialize: The "fingerprint" must be a non-empty string.'); + return; + } + if (this._initialized) { + this.reset(); + } + const reInitialized = this._fingerprint !== "" && this._fingerprint !== fingerprint; + this._fingerprint = fingerprint; + this._updateUrl = updateUrl === true; + this._initialized = true; + this.#bindEvents(); + const state = window.history.state; + this._popStateInProgress = false; + this._blockHashChange = 0; + this._currentHash = getCurrentHash(); + this._numPositionUpdates = 0; + this._uid = this._maxUid = 0; + this._destination = null; + this._position = null; + if (!this.#isValidState(state, true) || resetHistory) { + const { + hash, + page, + rotation + } = this.#parseCurrentHash(true); + if (!hash || reInitialized || resetHistory) { + this.#pushOrReplaceState(null, true); + return; + } + this.#pushOrReplaceState({ + hash, + page, + rotation + }, true); + return; + } + const destination = state.destination; + this.#updateInternalState(destination, state.uid, true); + if (destination.rotation !== undefined) { + this._initialRotation = destination.rotation; + } + if (destination.dest) { + this._initialBookmark = JSON.stringify(destination.dest); + this._destination.page = null; + } else if (destination.hash) { + this._initialBookmark = destination.hash; + } else if (destination.page) { + this._initialBookmark = `page=${destination.page}`; + } + } + reset() { + if (this._initialized) { + this.#pageHide(); + this._initialized = false; + this.#unbindEvents(); + } + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + this._initialBookmark = null; + this._initialRotation = null; + } + push({ + namedDest = null, + explicitDest, + pageNumber + }) { + if (!this._initialized) { + return; + } + if (namedDest && typeof namedDest !== "string") { + console.error("PDFHistory.push: " + `"${namedDest}" is not a valid namedDest parameter.`); + return; + } else if (!Array.isArray(explicitDest)) { + console.error("PDFHistory.push: " + `"${explicitDest}" is not a valid explicitDest parameter.`); + return; + } else if (!this.#isValidPage(pageNumber)) { + if (pageNumber !== null || this._destination) { + console.error("PDFHistory.push: " + `"${pageNumber}" is not a valid pageNumber parameter.`); + return; + } + } + const hash = namedDest || JSON.stringify(explicitDest); + if (!hash) { + return; + } + let forceReplace = false; + if (this._destination && (isDestHashesEqual(this._destination.hash, hash) || isDestArraysEqual(this._destination.dest, explicitDest))) { + if (this._destination.page) { + return; + } + forceReplace = true; + } + if (this._popStateInProgress && !forceReplace) { + return; + } + this.#pushOrReplaceState({ + dest: explicitDest, + hash, + page: pageNumber, + rotation: this.linkService.rotation + }, forceReplace); + if (!this._popStateInProgress) { + this._popStateInProgress = true; + Promise.resolve().then(() => { + this._popStateInProgress = false; + }); + } + } + pushPage(pageNumber) { + if (!this._initialized) { + return; + } + if (!this.#isValidPage(pageNumber)) { + console.error(`PDFHistory.pushPage: "${pageNumber}" is not a valid page number.`); + return; + } + if (this._destination?.page === pageNumber) { + return; + } + if (this._popStateInProgress) { + return; + } + this.#pushOrReplaceState({ + dest: null, + hash: `page=${pageNumber}`, + page: pageNumber, + rotation: this.linkService.rotation + }); + if (!this._popStateInProgress) { + this._popStateInProgress = true; + Promise.resolve().then(() => { + this._popStateInProgress = false; + }); + } + } + pushCurrentPosition() { + if (!this._initialized || this._popStateInProgress) { + return; + } + this.#tryPushCurrentPosition(); + } + back() { + if (!this._initialized || this._popStateInProgress) { + return; + } + const state = window.history.state; + if (this.#isValidState(state) && state.uid > 0) { + window.history.back(); + } + } + forward() { + if (!this._initialized || this._popStateInProgress) { + return; + } + const state = window.history.state; + if (this.#isValidState(state) && state.uid < this._maxUid) { + window.history.forward(); + } + } + get popStateInProgress() { + return this._initialized && (this._popStateInProgress || this._blockHashChange > 0); + } + get initialBookmark() { + return this._initialized ? this._initialBookmark : null; + } + get initialRotation() { + return this._initialized ? this._initialRotation : null; + } + #pushOrReplaceState(destination, forceReplace = false) { + const shouldReplace = forceReplace || !this._destination; + const newState = { + fingerprint: this._fingerprint, + uid: shouldReplace ? this._uid : this._uid + 1, + destination + }; + this.#updateInternalState(destination, newState.uid); + let newUrl; + if (this._updateUrl && destination?.hash) { + const baseUrl = document.location.href.split("#", 1)[0]; + if (!baseUrl.startsWith("file://")) { + newUrl = `${baseUrl}#${destination.hash}`; + } + } + if (shouldReplace) { + window.history.replaceState(newState, "", newUrl); + } else { + window.history.pushState(newState, "", newUrl); + } + } + #tryPushCurrentPosition(temporary = false) { + if (!this._position) { + return; + } + let position = this._position; + if (temporary) { + position = Object.assign(Object.create(null), this._position); + position.temporary = true; + } + if (!this._destination) { + this.#pushOrReplaceState(position); + return; + } + if (this._destination.temporary) { + this.#pushOrReplaceState(position, true); + return; + } + if (this._destination.hash === position.hash) { + return; + } + if (!this._destination.page && (POSITION_UPDATED_THRESHOLD <= 0 || this._numPositionUpdates <= POSITION_UPDATED_THRESHOLD)) { + return; + } + let forceReplace = false; + if (this._destination.page >= position.first && this._destination.page <= position.page) { + if (this._destination.dest !== undefined || !this._destination.first) { + return; + } + forceReplace = true; + } + this.#pushOrReplaceState(position, forceReplace); + } + #isValidPage(val) { + return Number.isInteger(val) && val > 0 && val <= this.linkService.pagesCount; + } + #isValidState(state, checkReload = false) { + if (!state) { + return false; + } + if (state.fingerprint !== this._fingerprint) { + if (checkReload) { + if (typeof state.fingerprint !== "string" || state.fingerprint.length !== this._fingerprint.length) { + return false; + } + const [perfEntry] = performance.getEntriesByType("navigation"); + if (perfEntry?.type !== "reload") { + return false; + } + } else { + return false; + } + } + if (!Number.isInteger(state.uid) || state.uid < 0) { + return false; + } + if (state.destination === null || typeof state.destination !== "object") { + return false; + } + return true; + } + #updateInternalState(destination, uid, removeTemporary = false) { + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + if (removeTemporary && destination?.temporary) { + delete destination.temporary; + } + this._destination = destination; + this._uid = uid; + this._maxUid = Math.max(this._maxUid, uid); + this._numPositionUpdates = 0; + } + #parseCurrentHash(checkNameddest = false) { + const hash = unescape(getCurrentHash()).substring(1); + const params = parseQueryString(hash); + const nameddest = params.get("nameddest") || ""; + let page = params.get("page") | 0; + if (!this.#isValidPage(page) || checkNameddest && nameddest.length > 0) { + page = null; + } + return { + hash, + page, + rotation: this.linkService.rotation + }; + } + #updateViewarea({ + location + }) { + if (this._updateViewareaTimeout) { + clearTimeout(this._updateViewareaTimeout); + this._updateViewareaTimeout = null; + } + this._position = { + hash: location.pdfOpenParams.substring(1), + page: this.linkService.page, + first: location.pageNumber, + rotation: location.rotation + }; + if (this._popStateInProgress) { + return; + } + if (POSITION_UPDATED_THRESHOLD > 0 && this._isPagesLoaded && this._destination && !this._destination.page) { + this._numPositionUpdates++; + } + if (UPDATE_VIEWAREA_TIMEOUT > 0) { + this._updateViewareaTimeout = setTimeout(() => { + if (!this._popStateInProgress) { + this.#tryPushCurrentPosition(true); + } + this._updateViewareaTimeout = null; + }, UPDATE_VIEWAREA_TIMEOUT); + } + } + #popState({ + state + }) { + const newHash = getCurrentHash(), + hashChanged = this._currentHash !== newHash; + this._currentHash = newHash; + if (!state) { + this._uid++; + const { + hash, + page, + rotation + } = this.#parseCurrentHash(); + this.#pushOrReplaceState({ + hash, + page, + rotation + }, true); + return; + } + if (!this.#isValidState(state)) { + return; + } + this._popStateInProgress = true; + if (hashChanged) { + this._blockHashChange++; + waitOnEventOrTimeout({ + target: window, + name: "hashchange", + delay: HASH_CHANGE_TIMEOUT + }).then(() => { + this._blockHashChange--; + }); + } + const destination = state.destination; + this.#updateInternalState(destination, state.uid, true); + if (isValidRotation(destination.rotation)) { + this.linkService.rotation = destination.rotation; + } + if (destination.dest) { + this.linkService.goToDestination(destination.dest); + } else if (destination.hash) { + this.linkService.setHash(destination.hash); + } else if (destination.page) { + this.linkService.page = destination.page; + } + Promise.resolve().then(() => { + this._popStateInProgress = false; + }); + } + #pageHide() { + if (!this._destination || this._destination.temporary) { + this.#tryPushCurrentPosition(); + } + } + #bindEvents() { + if (this.#eventAbortController) { + return; + } + this.#eventAbortController = new AbortController(); + const { + signal + } = this.#eventAbortController; + this.eventBus._on("updateviewarea", this.#updateViewarea.bind(this), { + signal + }); + window.addEventListener("popstate", this.#popState.bind(this), { + signal + }); + window.addEventListener("pagehide", this.#pageHide.bind(this), { + signal + }); + } + #unbindEvents() { + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + } +} +function isDestHashesEqual(destHash, pushHash) { + if (typeof destHash !== "string" || typeof pushHash !== "string") { + return false; + } + if (destHash === pushHash) { + return true; + } + const nameddest = parseQueryString(destHash).get("nameddest"); + if (nameddest === pushHash) { + return true; + } + return false; +} +function isDestArraysEqual(firstDest, secondDest) { + function isEntryEqual(first, second) { + if (typeof first !== typeof second) { + return false; + } + if (Array.isArray(first) || Array.isArray(second)) { + return false; + } + if (first !== null && typeof first === "object" && second !== null) { + if (Object.keys(first).length !== Object.keys(second).length) { + return false; + } + for (const key in first) { + if (!isEntryEqual(first[key], second[key])) { + return false; + } + } + return true; + } + return first === second || Number.isNaN(first) && Number.isNaN(second); + } + if (!(Array.isArray(firstDest) && Array.isArray(secondDest))) { + return false; + } + if (firstDest.length !== secondDest.length) { + return false; + } + for (let i = 0, ii = firstDest.length; i < ii; i++) { + if (!isEntryEqual(firstDest[i], secondDest[i])) { + return false; + } + } + return true; +} + +;// ./web/annotation_editor_layer_builder.js + + +class AnnotationEditorLayerBuilder { + #annotationLayer = null; + #drawLayer = null; + #onAppend = null; + #structTreeLayer = null; + #textLayer = null; + #uiManager; + constructor(options) { + this.pdfPage = options.pdfPage; + this.accessibilityManager = options.accessibilityManager; + this.l10n = options.l10n; + this.l10n ||= new genericl10n_GenericL10n(); + this.annotationEditorLayer = null; + this.div = null; + this._cancelled = false; + this.#uiManager = options.uiManager; + this.#annotationLayer = options.annotationLayer || null; + this.#textLayer = options.textLayer || null; + this.#drawLayer = options.drawLayer || null; + this.#onAppend = options.onAppend || null; + this.#structTreeLayer = options.structTreeLayer || null; + } + async render(viewport, intent = "display") { + if (intent !== "display") { + return; + } + if (this._cancelled) { + return; + } + const clonedViewport = viewport.clone({ + dontFlip: true + }); + if (this.div) { + this.annotationEditorLayer.update({ + viewport: clonedViewport + }); + this.show(); + return; + } + const div = this.div = document.createElement("div"); + div.className = "annotationEditorLayer"; + div.hidden = true; + div.dir = this.#uiManager.direction; + this.#onAppend?.(div); + this.annotationEditorLayer = new AnnotationEditorLayer({ + uiManager: this.#uiManager, + div, + structTreeLayer: this.#structTreeLayer, + accessibilityManager: this.accessibilityManager, + pageIndex: this.pdfPage.pageNumber - 1, + l10n: this.l10n, + viewport: clonedViewport, + annotationLayer: this.#annotationLayer, + textLayer: this.#textLayer, + drawLayer: this.#drawLayer + }); + const parameters = { + viewport: clonedViewport, + div, + annotations: null, + intent + }; + this.annotationEditorLayer.render(parameters); + this.show(); + } + cancel() { + this._cancelled = true; + if (!this.div) { + return; + } + this.annotationEditorLayer.destroy(); + } + hide() { + if (!this.div) { + return; + } + this.annotationEditorLayer.pause(true); + this.div.hidden = true; + } + show() { + if (!this.div || this.annotationEditorLayer.isInvisible) { + return; + } + this.div.hidden = false; + this.annotationEditorLayer.pause(false); + } +} + +;// ./web/app_options.js +{ + var compatParams = new Map(); + const userAgent = navigator.userAgent || ""; + const platform = navigator.platform || ""; + const maxTouchPoints = navigator.maxTouchPoints || 1; + const isAndroid = /Android/.test(userAgent); + const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent) || platform === "MacIntel" && maxTouchPoints > 1; + (function () { + if (isIOS || isAndroid) { + compatParams.set("maxCanvasPixels", 5242880); + } + })(); + (function () { + if (isAndroid) { + compatParams.set("useSystemFonts", false); + } + })(); +} +const OptionKind = { + BROWSER: 0x01, + VIEWER: 0x02, + API: 0x04, + WORKER: 0x08, + EVENT_DISPATCH: 0x10, + PREFERENCE: 0x80 +}; +const Type = { + BOOLEAN: 0x01, + NUMBER: 0x02, + OBJECT: 0x04, + STRING: 0x08, + UNDEFINED: 0x10 +}; +const defaultOptions = { + allowedGlobalEvents: { + value: null, + kind: OptionKind.BROWSER + }, + canvasMaxAreaInBytes: { + value: -1, + kind: OptionKind.BROWSER + OptionKind.API + }, + isInAutomation: { + value: false, + kind: OptionKind.BROWSER + }, + localeProperties: { + value: { + lang: navigator.language || "en-US" + }, + kind: OptionKind.BROWSER + }, + nimbusDataStr: { + value: "", + kind: OptionKind.BROWSER + }, + supportsCaretBrowsingMode: { + value: false, + kind: OptionKind.BROWSER + }, + supportsDocumentFonts: { + value: true, + kind: OptionKind.BROWSER + }, + supportsIntegratedFind: { + value: false, + kind: OptionKind.BROWSER + }, + supportsMouseWheelZoomCtrlKey: { + value: true, + kind: OptionKind.BROWSER + }, + supportsMouseWheelZoomMetaKey: { + value: true, + kind: OptionKind.BROWSER + }, + supportsPinchToZoom: { + value: true, + kind: OptionKind.BROWSER + }, + toolbarDensity: { + value: 0, + kind: OptionKind.BROWSER + OptionKind.EVENT_DISPATCH + }, + altTextLearnMoreUrl: { + value: "", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + annotationEditorMode: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + annotationMode: { + value: 2, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + cursorToolOnLoad: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + debuggerSrc: { + value: "./debugger.mjs", + kind: OptionKind.VIEWER + }, + defaultZoomDelay: { + value: 400, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + defaultZoomValue: { + value: "", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + disableHistory: { + value: false, + kind: OptionKind.VIEWER + }, + disablePageLabels: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableAltText: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableAltTextModelDownload: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + OptionKind.EVENT_DISPATCH + }, + enableGuessAltText: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + OptionKind.EVENT_DISPATCH + }, + enableHighlightFloatingButton: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableNewAltTextWhenAddingImage: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enablePermissions: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enablePrintAutoRotate: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableScripting: { + value: true, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableUpdatedAddImage: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + externalLinkRel: { + value: "noopener noreferrer nofollow", + kind: OptionKind.VIEWER + }, + externalLinkTarget: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + highlightEditorColors: { + value: "yellow=#FFFF98,green=#53FFBC,blue=#80EBFF,pink=#FFCBE6,red=#FF4F5F", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + historyUpdateUrl: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + ignoreDestinationZoom: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + imageResourcesPath: { + value: "./images/", + kind: OptionKind.VIEWER + }, + maxCanvasPixels: { + value: 2 ** 25, + kind: OptionKind.VIEWER + }, + forcePageColors: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + pageColorsBackground: { + value: "Canvas", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + pageColorsForeground: { + value: "CanvasText", + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + pdfBugEnabled: { + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + printResolution: { + value: 150, + kind: OptionKind.VIEWER + }, + sidebarViewOnLoad: { + value: -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + scrollModeOnLoad: { + value: -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + spreadModeOnLoad: { + value: -1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + textLayerMode: { + value: 1, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + viewOnLoad: { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }, + cMapPacked: { + value: true, + kind: OptionKind.API + }, + cMapUrl: { + value: "../web/cmaps/", + kind: OptionKind.API + }, + disableAutoFetch: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + disableFontFace: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + disableRange: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + disableStream: { + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + docBaseUrl: { + value: "", + kind: OptionKind.API + }, + enableHWA: { + value: true, + kind: OptionKind.API + OptionKind.VIEWER + OptionKind.PREFERENCE + }, + enableXfa: { + value: true, + kind: OptionKind.API + OptionKind.PREFERENCE + }, + fontExtraProperties: { + value: false, + kind: OptionKind.API + }, + isEvalSupported: { + value: true, + kind: OptionKind.API + }, + isOffscreenCanvasSupported: { + value: true, + kind: OptionKind.API + }, + maxImageSize: { + value: -1, + kind: OptionKind.API + }, + pdfBug: { + value: false, + kind: OptionKind.API + }, + standardFontDataUrl: { + value: "../web/standard_fonts/", + kind: OptionKind.API + }, + useSystemFonts: { + value: undefined, + kind: OptionKind.API, + type: Type.BOOLEAN + Type.UNDEFINED + }, + verbosity: { + value: 1, + kind: OptionKind.API + }, + workerPort: { + value: null, + kind: OptionKind.WORKER + }, + workerSrc: { + value: "../build/pdf.worker.mjs", + kind: OptionKind.WORKER + } +}; +{ + defaultOptions.defaultUrl = { + value: "compressed.tracemonkey-pldi-09.pdf", + kind: OptionKind.VIEWER + }; + defaultOptions.sandboxBundleSrc = { + value: "../build/pdf.sandbox.mjs", + kind: OptionKind.VIEWER + }; + defaultOptions.viewerCssTheme = { + value: 0, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE + }; + defaultOptions.enableFakeMLManager = { + value: true, + kind: OptionKind.VIEWER + }; +} +{ + defaultOptions.disablePreferences = { + value: false, + kind: OptionKind.VIEWER + }; +} +class AppOptions { + static eventBus; + static #opts = new Map(); + static { + for (const name in defaultOptions) { + this.#opts.set(name, defaultOptions[name].value); + } + for (const [name, value] of compatParams) { + this.#opts.set(name, value); + } + this._hasInvokedSet = false; + this._checkDisablePreferences = () => { + if (this.get("disablePreferences")) { + return true; + } + if (this._hasInvokedSet) { + console.warn("The Preferences may override manually set AppOptions; " + 'please use the "disablePreferences"-option to prevent that.'); + } + return false; + }; + } + static get(name) { + return this.#opts.get(name); + } + static getAll(kind = null, defaultOnly = false) { + const options = Object.create(null); + for (const name in defaultOptions) { + const defaultOpt = defaultOptions[name]; + if (kind && !(kind & defaultOpt.kind)) { + continue; + } + options[name] = !defaultOnly ? this.#opts.get(name) : defaultOpt.value; + } + return options; + } + static set(name, value) { + this.setAll({ + [name]: value + }); + } + static setAll(options, prefs = false) { + this._hasInvokedSet ||= true; + let events; + for (const name in options) { + const defaultOpt = defaultOptions[name], + userOpt = options[name]; + if (!defaultOpt || !(typeof userOpt === typeof defaultOpt.value || Type[(typeof userOpt).toUpperCase()] & defaultOpt.type)) { + continue; + } + const { + kind + } = defaultOpt; + if (prefs && !(kind & OptionKind.BROWSER || kind & OptionKind.PREFERENCE)) { + continue; + } + if (this.eventBus && kind & OptionKind.EVENT_DISPATCH) { + (events ||= new Map()).set(name, userOpt); + } + this.#opts.set(name, userOpt); + } + if (events) { + for (const [name, value] of events) { + this.eventBus.dispatch(name.toLowerCase(), { + source: this, + value + }); + } + } + } +} + +;// ./web/draw_layer_builder.js + +class DrawLayerBuilder { + #drawLayer = null; + constructor(options) { + this.pageIndex = options.pageIndex; + } + async render(intent = "display") { + if (intent !== "display" || this.#drawLayer || this._cancelled) { + return; + } + this.#drawLayer = new DrawLayer({ + pageIndex: this.pageIndex + }); + } + cancel() { + this._cancelled = true; + if (!this.#drawLayer) { + return; + } + this.#drawLayer.destroy(); + this.#drawLayer = null; + } + setParent(parent) { + this.#drawLayer?.setParent(parent); + } + getDrawLayer() { + return this.#drawLayer; + } +} + +;// ./web/struct_tree_layer_builder.js + +const PDF_ROLE_TO_HTML_ROLE = { + Document: null, + DocumentFragment: null, + Part: "group", + Sect: "group", + Div: "group", + Aside: "note", + NonStruct: "none", + P: null, + H: "heading", + Title: null, + FENote: "note", + Sub: "group", + Lbl: null, + Span: null, + Em: null, + Strong: null, + Link: "link", + Annot: "note", + Form: "form", + Ruby: null, + RB: null, + RT: null, + RP: null, + Warichu: null, + WT: null, + WP: null, + L: "list", + LI: "listitem", + LBody: null, + Table: "table", + TR: "row", + TH: "columnheader", + TD: "cell", + THead: "columnheader", + TBody: null, + TFoot: null, + Caption: null, + Figure: "figure", + Formula: null, + Artifact: null +}; +const HEADING_PATTERN = /^H(\d+)$/; +class StructTreeLayerBuilder { + #promise; + #treeDom = null; + #treePromise; + #elementAttributes = new Map(); + #rawDims; + #elementsToAddToTextLayer = null; + constructor(pdfPage, rawDims) { + this.#promise = pdfPage.getStructTree(); + this.#rawDims = rawDims; + } + async render() { + if (this.#treePromise) { + return this.#treePromise; + } + const { + promise, + resolve, + reject + } = Promise.withResolvers(); + this.#treePromise = promise; + try { + this.#treeDom = this.#walk(await this.#promise); + } catch (ex) { + reject(ex); + } + this.#promise = null; + this.#treeDom?.classList.add("structTree"); + resolve(this.#treeDom); + return promise; + } + async getAriaAttributes(annotationId) { + try { + await this.render(); + return this.#elementAttributes.get(annotationId); + } catch {} + return null; + } + hide() { + if (this.#treeDom && !this.#treeDom.hidden) { + this.#treeDom.hidden = true; + } + } + show() { + if (this.#treeDom?.hidden) { + this.#treeDom.hidden = false; + } + } + #setAttributes(structElement, htmlElement) { + const { + alt, + id, + lang + } = structElement; + if (alt !== undefined) { + let added = false; + const label = removeNullCharacters(alt); + for (const child of structElement.children) { + if (child.type === "annotation") { + let attrs = this.#elementAttributes.get(child.id); + if (!attrs) { + attrs = new Map(); + this.#elementAttributes.set(child.id, attrs); + } + attrs.set("aria-label", label); + added = true; + } + } + if (!added) { + htmlElement.setAttribute("aria-label", label); + } + } + if (id !== undefined) { + htmlElement.setAttribute("aria-owns", id); + } + if (lang !== undefined) { + htmlElement.setAttribute("lang", removeNullCharacters(lang, true)); + } + } + #addImageInTextLayer(node, element) { + const { + alt, + bbox, + children + } = node; + const child = children?.[0]; + if (!this.#rawDims || !alt || !bbox || child?.type !== "content") { + return false; + } + const { + id + } = child; + if (!id) { + return false; + } + element.setAttribute("aria-owns", id); + const img = document.createElement("span"); + (this.#elementsToAddToTextLayer ||= new Map()).set(id, img); + img.setAttribute("role", "img"); + img.setAttribute("aria-label", removeNullCharacters(alt)); + const { + pageHeight, + pageX, + pageY + } = this.#rawDims; + const calc = "calc(var(--scale-factor)*"; + const { + style + } = img; + style.width = `${calc}${bbox[2] - bbox[0]}px)`; + style.height = `${calc}${bbox[3] - bbox[1]}px)`; + style.left = `${calc}${bbox[0] - pageX}px)`; + style.top = `${calc}${pageHeight - bbox[3] + pageY}px)`; + return true; + } + addElementsToTextLayer() { + if (!this.#elementsToAddToTextLayer) { + return; + } + for (const [id, img] of this.#elementsToAddToTextLayer) { + document.getElementById(id)?.append(img); + } + this.#elementsToAddToTextLayer.clear(); + this.#elementsToAddToTextLayer = null; + } + #walk(node) { + if (!node) { + return null; + } + const element = document.createElement("span"); + if ("role" in node) { + const { + role + } = node; + const match = role.match(HEADING_PATTERN); + if (match) { + element.setAttribute("role", "heading"); + element.setAttribute("aria-level", match[1]); + } else if (PDF_ROLE_TO_HTML_ROLE[role]) { + element.setAttribute("role", PDF_ROLE_TO_HTML_ROLE[role]); + } + if (role === "Figure" && this.#addImageInTextLayer(node, element)) { + return element; + } + } + this.#setAttributes(node, element); + if (node.children) { + if (node.children.length === 1 && "id" in node.children[0]) { + this.#setAttributes(node.children[0], element); + } else { + for (const kid of node.children) { + element.append(this.#walk(kid)); + } + } + } + return element; + } +} + +;// ./web/text_accessibility.js + +class TextAccessibilityManager { + #enabled = false; + #textChildren = null; + #textNodes = new Map(); + #waitingElements = new Map(); + setTextMapping(textDivs) { + this.#textChildren = textDivs; + } + static #compareElementPositions(e1, e2) { + const rect1 = e1.getBoundingClientRect(); + const rect2 = e2.getBoundingClientRect(); + if (rect1.width === 0 && rect1.height === 0) { + return +1; + } + if (rect2.width === 0 && rect2.height === 0) { + return -1; + } + const top1 = rect1.y; + const bot1 = rect1.y + rect1.height; + const mid1 = rect1.y + rect1.height / 2; + const top2 = rect2.y; + const bot2 = rect2.y + rect2.height; + const mid2 = rect2.y + rect2.height / 2; + if (mid1 <= top2 && mid2 >= bot1) { + return -1; + } + if (mid2 <= top1 && mid1 >= bot2) { + return +1; + } + const centerX1 = rect1.x + rect1.width / 2; + const centerX2 = rect2.x + rect2.width / 2; + return centerX1 - centerX2; + } + enable() { + if (this.#enabled) { + throw new Error("TextAccessibilityManager is already enabled."); + } + if (!this.#textChildren) { + throw new Error("Text divs and strings have not been set."); + } + this.#enabled = true; + this.#textChildren = this.#textChildren.slice(); + this.#textChildren.sort(TextAccessibilityManager.#compareElementPositions); + if (this.#textNodes.size > 0) { + const textChildren = this.#textChildren; + for (const [id, nodeIndex] of this.#textNodes) { + const element = document.getElementById(id); + if (!element) { + this.#textNodes.delete(id); + continue; + } + this.#addIdToAriaOwns(id, textChildren[nodeIndex]); + } + } + for (const [element, isRemovable] of this.#waitingElements) { + this.addPointerInTextLayer(element, isRemovable); + } + this.#waitingElements.clear(); + } + disable() { + if (!this.#enabled) { + return; + } + this.#waitingElements.clear(); + this.#textChildren = null; + this.#enabled = false; + } + removePointerInTextLayer(element) { + if (!this.#enabled) { + this.#waitingElements.delete(element); + return; + } + const children = this.#textChildren; + if (!children || children.length === 0) { + return; + } + const { + id + } = element; + const nodeIndex = this.#textNodes.get(id); + if (nodeIndex === undefined) { + return; + } + const node = children[nodeIndex]; + this.#textNodes.delete(id); + let owns = node.getAttribute("aria-owns"); + if (owns?.includes(id)) { + owns = owns.split(" ").filter(x => x !== id).join(" "); + if (owns) { + node.setAttribute("aria-owns", owns); + } else { + node.removeAttribute("aria-owns"); + node.setAttribute("role", "presentation"); + } + } + } + #addIdToAriaOwns(id, node) { + const owns = node.getAttribute("aria-owns"); + if (!owns?.includes(id)) { + node.setAttribute("aria-owns", owns ? `${owns} ${id}` : id); + } + node.removeAttribute("role"); + } + addPointerInTextLayer(element, isRemovable) { + const { + id + } = element; + if (!id) { + return null; + } + if (!this.#enabled) { + this.#waitingElements.set(element, isRemovable); + return null; + } + if (isRemovable) { + this.removePointerInTextLayer(element); + } + const children = this.#textChildren; + if (!children || children.length === 0) { + return null; + } + const index = binarySearchFirstItem(children, node => TextAccessibilityManager.#compareElementPositions(element, node) < 0); + const nodeIndex = Math.max(0, index - 1); + const child = children[nodeIndex]; + this.#addIdToAriaOwns(id, child); + this.#textNodes.set(id, nodeIndex); + const parent = child.parentNode; + return parent?.classList.contains("markedContent") ? parent.id : null; + } + moveElementInDOM(container, element, contentElement, isRemovable) { + const id = this.addPointerInTextLayer(contentElement, isRemovable); + if (!container.hasChildNodes()) { + container.append(element); + return id; + } + const children = Array.from(container.childNodes).filter(node => node !== element); + if (children.length === 0) { + return id; + } + const elementToCompare = contentElement || element; + const index = binarySearchFirstItem(children, node => TextAccessibilityManager.#compareElementPositions(elementToCompare, node) < 0); + if (index === 0) { + children[0].before(element); + } else { + children[index - 1].after(element); + } + return id; + } +} + +;// ./web/text_highlighter.js +class TextHighlighter { + #eventAbortController = null; + constructor({ + findController, + eventBus, + pageIndex + }) { + this.findController = findController; + this.matches = []; + this.eventBus = eventBus; + this.pageIdx = pageIndex; + this.textDivs = null; + this.textContentItemsStr = null; + this.enabled = false; + } + setTextMapping(divs, texts) { + this.textDivs = divs; + this.textContentItemsStr = texts; + } + enable() { + if (!this.textDivs || !this.textContentItemsStr) { + throw new Error("Text divs and strings have not been set."); + } + if (this.enabled) { + throw new Error("TextHighlighter is already enabled."); + } + this.enabled = true; + if (!this.#eventAbortController) { + this.#eventAbortController = new AbortController(); + this.eventBus._on("updatetextlayermatches", evt => { + if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) { + this._updateMatches(); + } + }, { + signal: this.#eventAbortController.signal + }); + } + this._updateMatches(); + } + disable() { + if (!this.enabled) { + return; + } + this.enabled = false; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this._updateMatches(true); + } + _convertMatches(matches, matchesLength) { + if (!matches) { + return []; + } + const { + textContentItemsStr + } = this; + let i = 0, + iIndex = 0; + const end = textContentItemsStr.length - 1; + const result = []; + for (let m = 0, mm = matches.length; m < mm; m++) { + let matchIdx = matches[m]; + while (i !== end && matchIdx >= iIndex + textContentItemsStr[i].length) { + iIndex += textContentItemsStr[i].length; + i++; + } + if (i === textContentItemsStr.length) { + console.error("Could not find a matching mapping"); + } + const match = { + begin: { + divIdx: i, + offset: matchIdx - iIndex + } + }; + matchIdx += matchesLength[m]; + while (i !== end && matchIdx > iIndex + textContentItemsStr[i].length) { + iIndex += textContentItemsStr[i].length; + i++; + } + match.end = { + divIdx: i, + offset: matchIdx - iIndex + }; + result.push(match); + } + return result; + } + _renderMatches(matches) { + if (matches.length === 0) { + return; + } + const { + findController, + pageIdx + } = this; + const { + textContentItemsStr, + textDivs + } = this; + const isSelectedPage = pageIdx === findController.selected.pageIdx; + const selectedMatchIdx = findController.selected.matchIdx; + const highlightAll = findController.state.highlightAll; + let prevEnd = null; + const infinity = { + divIdx: -1, + offset: undefined + }; + function beginText(begin, className) { + const divIdx = begin.divIdx; + textDivs[divIdx].textContent = ""; + return appendTextToDiv(divIdx, 0, begin.offset, className); + } + function appendTextToDiv(divIdx, fromOffset, toOffset, className) { + let div = textDivs[divIdx]; + if (div.nodeType === Node.TEXT_NODE) { + const span = document.createElement("span"); + div.before(span); + span.append(div); + textDivs[divIdx] = span; + div = span; + } + const content = textContentItemsStr[divIdx].substring(fromOffset, toOffset); + const node = document.createTextNode(content); + if (className) { + const span = document.createElement("span"); + span.className = `${className} appended`; + span.append(node); + div.append(span); + if (className.includes("selected")) { + const { + left + } = span.getClientRects()[0]; + const parentLeft = div.getBoundingClientRect().left; + return left - parentLeft; + } + return 0; + } + div.append(node); + return 0; + } + let i0 = selectedMatchIdx, + i1 = i0 + 1; + if (highlightAll) { + i0 = 0; + i1 = matches.length; + } else if (!isSelectedPage) { + return; + } + let lastDivIdx = -1; + let lastOffset = -1; + for (let i = i0; i < i1; i++) { + const match = matches[i]; + const begin = match.begin; + if (begin.divIdx === lastDivIdx && begin.offset === lastOffset) { + continue; + } + lastDivIdx = begin.divIdx; + lastOffset = begin.offset; + const end = match.end; + const isSelected = isSelectedPage && i === selectedMatchIdx; + const highlightSuffix = isSelected ? " selected" : ""; + let selectedLeft = 0; + if (!prevEnd || begin.divIdx !== prevEnd.divIdx) { + if (prevEnd !== null) { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset); + } + beginText(begin); + } else { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset); + } + if (begin.divIdx === end.divIdx) { + selectedLeft = appendTextToDiv(begin.divIdx, begin.offset, end.offset, "highlight" + highlightSuffix); + } else { + selectedLeft = appendTextToDiv(begin.divIdx, begin.offset, infinity.offset, "highlight begin" + highlightSuffix); + for (let n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) { + textDivs[n0].className = "highlight middle" + highlightSuffix; + } + beginText(end, "highlight end" + highlightSuffix); + } + prevEnd = end; + if (isSelected) { + findController.scrollMatchIntoView({ + element: textDivs[begin.divIdx], + selectedLeft, + pageIndex: pageIdx, + matchIndex: selectedMatchIdx + }); + } + } + if (prevEnd) { + appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset); + } + } + _updateMatches(reset = false) { + if (!this.enabled && !reset) { + return; + } + const { + findController, + matches, + pageIdx + } = this; + const { + textContentItemsStr, + textDivs + } = this; + let clearedUntilDivIdx = -1; + for (const match of matches) { + const begin = Math.max(clearedUntilDivIdx, match.begin.divIdx); + for (let n = begin, end = match.end.divIdx; n <= end; n++) { + const div = textDivs[n]; + div.textContent = textContentItemsStr[n]; + div.className = ""; + } + clearedUntilDivIdx = match.end.divIdx + 1; + } + if (!findController?.highlightMatches || reset) { + return; + } + const pageMatches = findController.pageMatches[pageIdx] || null; + const pageMatchesLength = findController.pageMatchesLength[pageIdx] || null; + this.matches = this._convertMatches(pageMatches, pageMatchesLength); + this._renderMatches(this.matches); + } +} + +;// ./web/text_layer_builder.js + + +class TextLayerBuilder { + #enablePermissions = false; + #onAppend = null; + #renderingDone = false; + #textLayer = null; + static #textLayers = new Map(); + static #selectionChangeAbortController = null; + constructor({ + pdfPage, + highlighter = null, + accessibilityManager = null, + enablePermissions = false, + onAppend = null + }) { + this.pdfPage = pdfPage; + this.highlighter = highlighter; + this.accessibilityManager = accessibilityManager; + this.#enablePermissions = enablePermissions === true; + this.#onAppend = onAppend; + this.div = document.createElement("div"); + this.div.tabIndex = 0; + this.div.className = "textLayer"; + } + async render(viewport, textContentParams = null) { + if (this.#renderingDone && this.#textLayer) { + this.#textLayer.update({ + viewport, + onBefore: this.hide.bind(this) + }); + this.show(); + return; + } + this.cancel(); + this.#textLayer = new TextLayer({ + textContentSource: this.pdfPage.streamTextContent(textContentParams || { + includeMarkedContent: true, + disableNormalization: true + }), + container: this.div, + viewport + }); + const { + textDivs, + textContentItemsStr + } = this.#textLayer; + this.highlighter?.setTextMapping(textDivs, textContentItemsStr); + this.accessibilityManager?.setTextMapping(textDivs); + await this.#textLayer.render(); + this.#renderingDone = true; + const endOfContent = document.createElement("div"); + endOfContent.className = "endOfContent"; + this.div.append(endOfContent); + this.#bindMouse(endOfContent); + this.#onAppend?.(this.div); + this.highlighter?.enable(); + this.accessibilityManager?.enable(); + } + hide() { + if (!this.div.hidden && this.#renderingDone) { + this.highlighter?.disable(); + this.div.hidden = true; + } + } + show() { + if (this.div.hidden && this.#renderingDone) { + this.div.hidden = false; + this.highlighter?.enable(); + } + } + cancel() { + this.#textLayer?.cancel(); + this.#textLayer = null; + this.highlighter?.disable(); + this.accessibilityManager?.disable(); + TextLayerBuilder.#removeGlobalSelectionListener(this.div); + } + #bindMouse(end) { + const { + div + } = this; + div.addEventListener("mousedown", () => { + div.classList.add("selecting"); + }); + div.addEventListener("copy", event => { + if (!this.#enablePermissions) { + const selection = document.getSelection(); + event.clipboardData.setData("text/plain", removeNullCharacters(normalizeUnicode(selection.toString()))); + } + stopEvent(event); + }); + TextLayerBuilder.#textLayers.set(div, end); + TextLayerBuilder.#enableGlobalSelectionListener(); + } + static #removeGlobalSelectionListener(textLayerDiv) { + this.#textLayers.delete(textLayerDiv); + if (this.#textLayers.size === 0) { + this.#selectionChangeAbortController?.abort(); + this.#selectionChangeAbortController = null; + } + } + static #enableGlobalSelectionListener() { + if (this.#selectionChangeAbortController) { + return; + } + this.#selectionChangeAbortController = new AbortController(); + const { + signal + } = this.#selectionChangeAbortController; + const reset = (end, textLayer) => { + textLayer.append(end); + end.style.width = ""; + end.style.height = ""; + textLayer.classList.remove("selecting"); + }; + let isPointerDown = false; + document.addEventListener("pointerdown", () => { + isPointerDown = true; + }, { + signal + }); + document.addEventListener("pointerup", () => { + isPointerDown = false; + this.#textLayers.forEach(reset); + }, { + signal + }); + window.addEventListener("blur", () => { + isPointerDown = false; + this.#textLayers.forEach(reset); + }, { + signal + }); + document.addEventListener("keyup", () => { + if (!isPointerDown) { + this.#textLayers.forEach(reset); + } + }, { + signal + }); + var isFirefox, prevRange; + document.addEventListener("selectionchange", () => { + const selection = document.getSelection(); + if (selection.rangeCount === 0) { + this.#textLayers.forEach(reset); + return; + } + const activeTextLayers = new Set(); + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + for (const textLayerDiv of this.#textLayers.keys()) { + if (!activeTextLayers.has(textLayerDiv) && range.intersectsNode(textLayerDiv)) { + activeTextLayers.add(textLayerDiv); + } + } + } + for (const [textLayerDiv, endDiv] of this.#textLayers) { + if (activeTextLayers.has(textLayerDiv)) { + textLayerDiv.classList.add("selecting"); + } else { + reset(endDiv, textLayerDiv); + } + } + isFirefox ??= getComputedStyle(this.#textLayers.values().next().value).getPropertyValue("-moz-user-select") === "none"; + if (isFirefox) { + return; + } + const range = selection.getRangeAt(0); + const modifyStart = prevRange && (range.compareBoundaryPoints(Range.END_TO_END, prevRange) === 0 || range.compareBoundaryPoints(Range.START_TO_END, prevRange) === 0); + let anchor = modifyStart ? range.startContainer : range.endContainer; + if (anchor.nodeType === Node.TEXT_NODE) { + anchor = anchor.parentNode; + } + const parentTextLayer = anchor.parentElement?.closest(".textLayer"); + const endDiv = this.#textLayers.get(parentTextLayer); + if (endDiv) { + endDiv.style.width = parentTextLayer.style.width; + endDiv.style.height = parentTextLayer.style.height; + anchor.parentElement.insertBefore(endDiv, modifyStart ? anchor : anchor.nextSibling); + } + prevRange = range.cloneRange(); + }, { + signal + }); + } +} + +;// ./web/xfa_layer_builder.js + +class XfaLayerBuilder { + constructor({ + pdfPage, + annotationStorage = null, + linkService, + xfaHtml = null + }) { + this.pdfPage = pdfPage; + this.annotationStorage = annotationStorage; + this.linkService = linkService; + this.xfaHtml = xfaHtml; + this.div = null; + this._cancelled = false; + } + async render(viewport, intent = "display") { + if (intent === "print") { + const parameters = { + viewport: viewport.clone({ + dontFlip: true + }), + div: this.div, + xfaHtml: this.xfaHtml, + annotationStorage: this.annotationStorage, + linkService: this.linkService, + intent + }; + this.div = document.createElement("div"); + parameters.div = this.div; + return XfaLayer.render(parameters); + } + const xfaHtml = await this.pdfPage.getXfa(); + if (this._cancelled || !xfaHtml) { + return { + textDivs: [] + }; + } + const parameters = { + viewport: viewport.clone({ + dontFlip: true + }), + div: this.div, + xfaHtml, + annotationStorage: this.annotationStorage, + linkService: this.linkService, + intent + }; + if (this.div) { + return XfaLayer.update(parameters); + } + this.div = document.createElement("div"); + parameters.div = this.div; + return XfaLayer.render(parameters); + } + cancel() { + this._cancelled = true; + } + hide() { + if (!this.div) { + return; + } + this.div.hidden = true; + } +} + +;// ./web/pdf_page_view.js + + + + + + + + + + + + + +const DEFAULT_LAYER_PROPERTIES = { + annotationEditorUIManager: null, + annotationStorage: null, + downloadManager: null, + enableScripting: false, + fieldObjectsPromise: null, + findController: null, + hasJSActionsPromise: null, + get linkService() { + return new SimpleLinkService(); + } +}; +const LAYERS_ORDER = new Map([["canvasWrapper", 0], ["textLayer", 1], ["annotationLayer", 2], ["annotationEditorLayer", 3], ["xfaLayer", 3]]); +class PDFPageView { + #annotationMode = AnnotationMode.ENABLE_FORMS; + #canvasWrapper = null; + #enableHWA = false; + #hasRestrictedScaling = false; + #isEditing = false; + #layerProperties = null; + #loadingId = null; + #originalViewport = null; + #previousRotation = null; + #scaleRoundX = 1; + #scaleRoundY = 1; + #renderError = null; + #renderingState = RenderingStates.INITIAL; + #textLayerMode = TextLayerMode.ENABLE; + #useThumbnailCanvas = { + directDrawing: true, + initialOptionalContent: true, + regularAnnotations: true + }; + #layers = [null, null, null, null]; + constructor(options) { + const container = options.container; + const defaultViewport = options.defaultViewport; + this.id = options.id; + this.renderingId = "page" + this.id; + this.#layerProperties = options.layerProperties || DEFAULT_LAYER_PROPERTIES; + this.pdfPage = null; + this.pageLabel = null; + this.rotation = 0; + this.scale = options.scale || DEFAULT_SCALE; + this.viewport = defaultViewport; + this.pdfPageRotate = defaultViewport.rotation; + this._optionalContentConfigPromise = options.optionalContentConfigPromise || null; + this.#textLayerMode = options.textLayerMode ?? TextLayerMode.ENABLE; + this.#annotationMode = options.annotationMode ?? AnnotationMode.ENABLE_FORMS; + this.imageResourcesPath = options.imageResourcesPath || ""; + this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); + this.pageColors = options.pageColors || null; + this.#enableHWA = options.enableHWA || false; + this.eventBus = options.eventBus; + this.renderingQueue = options.renderingQueue; + this.l10n = options.l10n; + this.l10n ||= new genericl10n_GenericL10n(); + this.renderTask = null; + this.resume = null; + this._isStandalone = !this.renderingQueue?.hasViewer(); + this._container = container; + this._annotationCanvasMap = null; + this.annotationLayer = null; + this.annotationEditorLayer = null; + this.textLayer = null; + this.xfaLayer = null; + this.structTreeLayer = null; + this.drawLayer = null; + const div = document.createElement("div"); + div.className = "page"; + div.setAttribute("data-page-number", this.id); + div.setAttribute("role", "region"); + div.setAttribute("data-l10n-id", "pdfjs-page-landmark"); + div.setAttribute("data-l10n-args", JSON.stringify({ + page: this.id + })); + this.div = div; + this.#setDimensions(); + container?.append(div); + if (this._isStandalone) { + container?.style.setProperty("--scale-factor", this.scale * PixelsPerInch.PDF_TO_CSS_UNITS); + if (this.pageColors?.background) { + container?.style.setProperty("--page-bg-color", this.pageColors.background); + } + const { + optionalContentConfigPromise + } = options; + if (optionalContentConfigPromise) { + optionalContentConfigPromise.then(optionalContentConfig => { + if (optionalContentConfigPromise !== this._optionalContentConfigPromise) { + return; + } + this.#useThumbnailCanvas.initialOptionalContent = optionalContentConfig.hasInitialVisibility; + }); + } + if (!options.l10n) { + this.l10n.translate(this.div); + } + } + } + #addLayer(div, name) { + const pos = LAYERS_ORDER.get(name); + const oldDiv = this.#layers[pos]; + this.#layers[pos] = div; + if (oldDiv) { + oldDiv.replaceWith(div); + return; + } + for (let i = pos - 1; i >= 0; i--) { + const layer = this.#layers[i]; + if (layer) { + layer.after(div); + return; + } + } + this.div.prepend(div); + } + get renderingState() { + return this.#renderingState; + } + set renderingState(state) { + if (state === this.#renderingState) { + return; + } + this.#renderingState = state; + if (this.#loadingId) { + clearTimeout(this.#loadingId); + this.#loadingId = null; + } + switch (state) { + case RenderingStates.PAUSED: + this.div.classList.remove("loading"); + break; + case RenderingStates.RUNNING: + this.div.classList.add("loadingIcon"); + this.#loadingId = setTimeout(() => { + this.div.classList.add("loading"); + this.#loadingId = null; + }, 0); + break; + case RenderingStates.INITIAL: + case RenderingStates.FINISHED: + this.div.classList.remove("loadingIcon", "loading"); + break; + } + } + #setDimensions() { + const { + viewport + } = this; + if (this.pdfPage) { + if (this.#previousRotation === viewport.rotation) { + return; + } + this.#previousRotation = viewport.rotation; + } + setLayerDimensions(this.div, viewport, true, false); + } + setPdfPage(pdfPage) { + if (this._isStandalone && (this.pageColors?.foreground === "CanvasText" || this.pageColors?.background === "Canvas")) { + this._container?.style.setProperty("--hcm-highlight-filter", pdfPage.filterFactory.addHighlightHCMFilter("highlight", "CanvasText", "Canvas", "HighlightText", "Highlight")); + this._container?.style.setProperty("--hcm-highlight-selected-filter", pdfPage.filterFactory.addHighlightHCMFilter("highlight_selected", "CanvasText", "Canvas", "HighlightText", "Highlight")); + } + this.pdfPage = pdfPage; + this.pdfPageRotate = pdfPage.rotate; + const totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = pdfPage.getViewport({ + scale: this.scale * PixelsPerInch.PDF_TO_CSS_UNITS, + rotation: totalRotation + }); + this.#setDimensions(); + this.reset(); + } + destroy() { + this.reset(); + this.pdfPage?.cleanup(); + } + hasEditableAnnotations() { + return !!this.annotationLayer?.hasEditableAnnotations(); + } + get _textHighlighter() { + return shadow(this, "_textHighlighter", new TextHighlighter({ + pageIndex: this.id - 1, + eventBus: this.eventBus, + findController: this.#layerProperties.findController + })); + } + #dispatchLayerRendered(name, error) { + this.eventBus.dispatch(name, { + source: this, + pageNumber: this.id, + error + }); + } + async #renderAnnotationLayer() { + let error = null; + try { + await this.annotationLayer.render(this.viewport, { + structTreeLayer: this.structTreeLayer + }, "display"); + } catch (ex) { + console.error("#renderAnnotationLayer:", ex); + error = ex; + } finally { + this.#dispatchLayerRendered("annotationlayerrendered", error); + } + } + async #renderAnnotationEditorLayer() { + let error = null; + try { + await this.annotationEditorLayer.render(this.viewport, "display"); + } catch (ex) { + console.error("#renderAnnotationEditorLayer:", ex); + error = ex; + } finally { + this.#dispatchLayerRendered("annotationeditorlayerrendered", error); + } + } + async #renderDrawLayer() { + try { + await this.drawLayer.render("display"); + } catch (ex) { + console.error("#renderDrawLayer:", ex); + } + } + async #renderXfaLayer() { + let error = null; + try { + const result = await this.xfaLayer.render(this.viewport, "display"); + if (result?.textDivs && this._textHighlighter) { + this.#buildXfaTextContentItems(result.textDivs); + } + } catch (ex) { + console.error("#renderXfaLayer:", ex); + error = ex; + } finally { + if (this.xfaLayer?.div) { + this.l10n.pause(); + this.#addLayer(this.xfaLayer.div, "xfaLayer"); + this.l10n.resume(); + } + this.#dispatchLayerRendered("xfalayerrendered", error); + } + } + async #renderTextLayer() { + if (!this.textLayer) { + return; + } + let error = null; + try { + await this.textLayer.render(this.viewport); + } catch (ex) { + if (ex instanceof AbortException) { + return; + } + console.error("#renderTextLayer:", ex); + error = ex; + } + this.#dispatchLayerRendered("textlayerrendered", error); + this.#renderStructTreeLayer(); + } + async #renderStructTreeLayer() { + if (!this.textLayer) { + return; + } + const treeDom = await this.structTreeLayer?.render(); + if (treeDom) { + this.l10n.pause(); + this.structTreeLayer?.addElementsToTextLayer(); + if (this.canvas && treeDom.parentNode !== this.canvas) { + this.canvas.append(treeDom); + } + this.l10n.resume(); + } + this.structTreeLayer?.show(); + } + async #buildXfaTextContentItems(textDivs) { + const text = await this.pdfPage.getTextContent(); + const items = []; + for (const item of text.items) { + items.push(item.str); + } + this._textHighlighter.setTextMapping(textDivs, items); + this._textHighlighter.enable(); + } + #resetCanvas() { + const { + canvas + } = this; + if (!canvas) { + return; + } + canvas.remove(); + canvas.width = canvas.height = 0; + this.canvas = null; + this.#originalViewport = null; + } + reset({ + keepAnnotationLayer = false, + keepAnnotationEditorLayer = false, + keepXfaLayer = false, + keepTextLayer = false, + keepCanvasWrapper = false + } = {}) { + this.cancelRendering({ + keepAnnotationLayer, + keepAnnotationEditorLayer, + keepXfaLayer, + keepTextLayer + }); + this.renderingState = RenderingStates.INITIAL; + const div = this.div; + const childNodes = div.childNodes, + annotationLayerNode = keepAnnotationLayer && this.annotationLayer?.div || null, + annotationEditorLayerNode = keepAnnotationEditorLayer && this.annotationEditorLayer?.div || null, + xfaLayerNode = keepXfaLayer && this.xfaLayer?.div || null, + textLayerNode = keepTextLayer && this.textLayer?.div || null, + canvasWrapperNode = keepCanvasWrapper && this.#canvasWrapper || null; + for (let i = childNodes.length - 1; i >= 0; i--) { + const node = childNodes[i]; + switch (node) { + case annotationLayerNode: + case annotationEditorLayerNode: + case xfaLayerNode: + case textLayerNode: + case canvasWrapperNode: + continue; + } + node.remove(); + const layerIndex = this.#layers.indexOf(node); + if (layerIndex >= 0) { + this.#layers[layerIndex] = null; + } + } + div.removeAttribute("data-loaded"); + if (annotationLayerNode) { + this.annotationLayer.hide(); + } + if (annotationEditorLayerNode) { + this.annotationEditorLayer.hide(); + } + if (xfaLayerNode) { + this.xfaLayer.hide(); + } + if (textLayerNode) { + this.textLayer.hide(); + } + this.structTreeLayer?.hide(); + if (!keepCanvasWrapper && this.#canvasWrapper) { + this.#canvasWrapper = null; + this.#resetCanvas(); + } + } + toggleEditingMode(isEditing) { + if (!this.hasEditableAnnotations()) { + return; + } + this.#isEditing = isEditing; + this.reset({ + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + keepCanvasWrapper: true + }); + } + update({ + scale = 0, + rotation = null, + optionalContentConfigPromise = null, + drawingDelay = -1 + }) { + this.scale = scale || this.scale; + if (typeof rotation === "number") { + this.rotation = rotation; + } + if (optionalContentConfigPromise instanceof Promise) { + this._optionalContentConfigPromise = optionalContentConfigPromise; + optionalContentConfigPromise.then(optionalContentConfig => { + if (optionalContentConfigPromise !== this._optionalContentConfigPromise) { + return; + } + this.#useThumbnailCanvas.initialOptionalContent = optionalContentConfig.hasInitialVisibility; + }); + } + this.#useThumbnailCanvas.directDrawing = true; + const totalRotation = (this.rotation + this.pdfPageRotate) % 360; + this.viewport = this.viewport.clone({ + scale: this.scale * PixelsPerInch.PDF_TO_CSS_UNITS, + rotation: totalRotation + }); + this.#setDimensions(); + if (this._isStandalone) { + this._container?.style.setProperty("--scale-factor", this.viewport.scale); + } + if (this.canvas) { + let onlyCssZoom = false; + if (this.#hasRestrictedScaling) { + if (this.maxCanvasPixels === 0) { + onlyCssZoom = true; + } else if (this.maxCanvasPixels > 0) { + const { + width, + height + } = this.viewport; + const { + sx, + sy + } = this.outputScale; + onlyCssZoom = (Math.floor(width) * sx | 0) * (Math.floor(height) * sy | 0) > this.maxCanvasPixels; + } + } + const postponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; + if (postponeDrawing || onlyCssZoom) { + if (postponeDrawing && !onlyCssZoom && this.renderingState !== RenderingStates.FINISHED) { + this.cancelRendering({ + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + cancelExtraDelay: drawingDelay + }); + this.renderingState = RenderingStates.FINISHED; + this.#useThumbnailCanvas.directDrawing = false; + } + this.cssTransform({ + redrawAnnotationLayer: true, + redrawAnnotationEditorLayer: true, + redrawXfaLayer: true, + redrawTextLayer: !postponeDrawing, + hideTextLayer: postponeDrawing + }); + if (postponeDrawing) { + return; + } + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform: true, + timestamp: performance.now(), + error: this.#renderError + }); + return; + } + } + this.cssTransform({}); + this.reset({ + keepAnnotationLayer: true, + keepAnnotationEditorLayer: true, + keepXfaLayer: true, + keepTextLayer: true, + keepCanvasWrapper: true + }); + } + cancelRendering({ + keepAnnotationLayer = false, + keepAnnotationEditorLayer = false, + keepXfaLayer = false, + keepTextLayer = false, + cancelExtraDelay = 0 + } = {}) { + if (this.renderTask) { + this.renderTask.cancel(cancelExtraDelay); + this.renderTask = null; + } + this.resume = null; + if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) { + this.textLayer.cancel(); + this.textLayer = null; + } + if (this.annotationLayer && (!keepAnnotationLayer || !this.annotationLayer.div)) { + this.annotationLayer.cancel(); + this.annotationLayer = null; + this._annotationCanvasMap = null; + } + if (this.structTreeLayer && !this.textLayer) { + this.structTreeLayer = null; + } + if (this.annotationEditorLayer && (!keepAnnotationEditorLayer || !this.annotationEditorLayer.div)) { + if (this.drawLayer) { + this.drawLayer.cancel(); + this.drawLayer = null; + } + this.annotationEditorLayer.cancel(); + this.annotationEditorLayer = null; + } + if (this.xfaLayer && (!keepXfaLayer || !this.xfaLayer.div)) { + this.xfaLayer.cancel(); + this.xfaLayer = null; + this._textHighlighter?.disable(); + } + } + cssTransform({ + redrawAnnotationLayer = false, + redrawAnnotationEditorLayer = false, + redrawXfaLayer = false, + redrawTextLayer = false, + hideTextLayer = false + }) { + const { + canvas + } = this; + if (!canvas) { + return; + } + const originalViewport = this.#originalViewport; + if (this.viewport !== originalViewport) { + const relativeRotation = (360 + this.viewport.rotation - originalViewport.rotation) % 360; + if (relativeRotation === 90 || relativeRotation === 270) { + const { + width, + height + } = this.viewport; + const scaleX = height / width; + const scaleY = width / height; + canvas.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX},${scaleY})`; + } else { + canvas.style.transform = relativeRotation === 0 ? "" : `rotate(${relativeRotation}deg)`; + } + } + if (redrawAnnotationLayer && this.annotationLayer) { + this.#renderAnnotationLayer(); + } + if (redrawAnnotationEditorLayer && this.annotationEditorLayer) { + if (this.drawLayer) { + this.#renderDrawLayer(); + } + this.#renderAnnotationEditorLayer(); + } + if (redrawXfaLayer && this.xfaLayer) { + this.#renderXfaLayer(); + } + if (this.textLayer) { + if (hideTextLayer) { + this.textLayer.hide(); + this.structTreeLayer?.hide(); + } else if (redrawTextLayer) { + this.#renderTextLayer(); + } + } + } + get width() { + return this.viewport.width; + } + get height() { + return this.viewport.height; + } + getPagePoint(x, y) { + return this.viewport.convertToPdfPoint(x, y); + } + async #finishRenderTask(renderTask, error = null) { + if (renderTask === this.renderTask) { + this.renderTask = null; + } + if (error instanceof RenderingCancelledException) { + this.#renderError = null; + return; + } + this.#renderError = error; + this.renderingState = RenderingStates.FINISHED; + this.#useThumbnailCanvas.regularAnnotations = !renderTask.separateAnnots; + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform: false, + timestamp: performance.now(), + error: this.#renderError + }); + if (error) { + throw error; + } + } + async draw() { + if (this.renderingState !== RenderingStates.INITIAL) { + console.error("Must be in new state before drawing"); + this.reset(); + } + const { + div, + l10n, + pageColors, + pdfPage, + viewport + } = this; + if (!pdfPage) { + this.renderingState = RenderingStates.FINISHED; + throw new Error("pdfPage is not loaded"); + } + this.renderingState = RenderingStates.RUNNING; + let canvasWrapper = this.#canvasWrapper; + if (!canvasWrapper) { + canvasWrapper = this.#canvasWrapper = document.createElement("div"); + canvasWrapper.classList.add("canvasWrapper"); + this.#addLayer(canvasWrapper, "canvasWrapper"); + } + if (!this.textLayer && this.#textLayerMode !== TextLayerMode.DISABLE && !pdfPage.isPureXfa) { + this._accessibilityManager ||= new TextAccessibilityManager(); + this.textLayer = new TextLayerBuilder({ + pdfPage, + highlighter: this._textHighlighter, + accessibilityManager: this._accessibilityManager, + enablePermissions: this.#textLayerMode === TextLayerMode.ENABLE_PERMISSIONS, + onAppend: textLayerDiv => { + this.l10n.pause(); + this.#addLayer(textLayerDiv, "textLayer"); + this.l10n.resume(); + } + }); + } + if (!this.annotationLayer && this.#annotationMode !== AnnotationMode.DISABLE) { + const { + annotationStorage, + annotationEditorUIManager, + downloadManager, + enableScripting, + fieldObjectsPromise, + hasJSActionsPromise, + linkService + } = this.#layerProperties; + this._annotationCanvasMap ||= new Map(); + this.annotationLayer = new AnnotationLayerBuilder({ + pdfPage, + annotationStorage, + imageResourcesPath: this.imageResourcesPath, + renderForms: this.#annotationMode === AnnotationMode.ENABLE_FORMS, + linkService, + downloadManager, + enableScripting, + hasJSActionsPromise, + fieldObjectsPromise, + annotationCanvasMap: this._annotationCanvasMap, + accessibilityManager: this._accessibilityManager, + annotationEditorUIManager, + onAppend: annotationLayerDiv => { + this.#addLayer(annotationLayerDiv, "annotationLayer"); + } + }); + } + const renderContinueCallback = cont => { + showCanvas?.(false); + if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) { + this.renderingState = RenderingStates.PAUSED; + this.resume = () => { + this.renderingState = RenderingStates.RUNNING; + cont(); + }; + return; + } + cont(); + }; + const { + width, + height + } = viewport; + const canvas = document.createElement("canvas"); + canvas.setAttribute("role", "presentation"); + const hasHCM = !!(pageColors?.background && pageColors?.foreground); + const prevCanvas = this.canvas; + const updateOnFirstShow = !prevCanvas && !hasHCM; + this.canvas = canvas; + this.#originalViewport = viewport; + let showCanvas = isLastShow => { + if (updateOnFirstShow) { + canvasWrapper.prepend(canvas); + showCanvas = null; + return; + } + if (!isLastShow) { + return; + } + if (prevCanvas) { + prevCanvas.replaceWith(canvas); + prevCanvas.width = prevCanvas.height = 0; + } else { + canvasWrapper.prepend(canvas); + } + showCanvas = null; + }; + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !this.#enableHWA + }); + const outputScale = this.outputScale = new OutputScale(); + if (this.maxCanvasPixels === 0) { + const invScale = 1 / this.scale; + outputScale.sx *= invScale; + outputScale.sy *= invScale; + this.#hasRestrictedScaling = true; + } else if (this.maxCanvasPixels > 0) { + const pixelsInViewport = width * height; + const maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); + if (outputScale.sx > maxScale || outputScale.sy > maxScale) { + outputScale.sx = maxScale; + outputScale.sy = maxScale; + this.#hasRestrictedScaling = true; + } else { + this.#hasRestrictedScaling = false; + } + } + const sfx = approximateFraction(outputScale.sx); + const sfy = approximateFraction(outputScale.sy); + const canvasWidth = canvas.width = floorToDivide(calcRound(width * outputScale.sx), sfx[0]); + const canvasHeight = canvas.height = floorToDivide(calcRound(height * outputScale.sy), sfy[0]); + const pageWidth = floorToDivide(calcRound(width), sfx[1]); + const pageHeight = floorToDivide(calcRound(height), sfy[1]); + outputScale.sx = canvasWidth / pageWidth; + outputScale.sy = canvasHeight / pageHeight; + if (this.#scaleRoundX !== sfx[1]) { + div.style.setProperty("--scale-round-x", `${sfx[1]}px`); + this.#scaleRoundX = sfx[1]; + } + if (this.#scaleRoundY !== sfy[1]) { + div.style.setProperty("--scale-round-y", `${sfy[1]}px`); + this.#scaleRoundY = sfy[1]; + } + const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; + const renderContext = { + canvasContext: ctx, + transform, + viewport, + annotationMode: this.#annotationMode, + optionalContentConfigPromise: this._optionalContentConfigPromise, + annotationCanvasMap: this._annotationCanvasMap, + pageColors, + isEditing: this.#isEditing + }; + const renderTask = this.renderTask = pdfPage.render(renderContext); + renderTask.onContinue = renderContinueCallback; + const resultPromise = renderTask.promise.then(async () => { + showCanvas?.(true); + await this.#finishRenderTask(renderTask); + this.structTreeLayer ||= new StructTreeLayerBuilder(pdfPage, viewport.rawDims); + this.#renderTextLayer(); + if (this.annotationLayer) { + await this.#renderAnnotationLayer(); + } + const { + annotationEditorUIManager + } = this.#layerProperties; + if (!annotationEditorUIManager) { + return; + } + this.drawLayer ||= new DrawLayerBuilder({ + pageIndex: this.id + }); + await this.#renderDrawLayer(); + this.drawLayer.setParent(canvasWrapper); + this.annotationEditorLayer ||= new AnnotationEditorLayerBuilder({ + uiManager: annotationEditorUIManager, + pdfPage, + l10n, + structTreeLayer: this.structTreeLayer, + accessibilityManager: this._accessibilityManager, + annotationLayer: this.annotationLayer?.annotationLayer, + textLayer: this.textLayer, + drawLayer: this.drawLayer.getDrawLayer(), + onAppend: annotationEditorLayerDiv => { + this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); + } + }); + this.#renderAnnotationEditorLayer(); + }, error => { + if (!(error instanceof RenderingCancelledException)) { + showCanvas?.(true); + } else { + prevCanvas?.remove(); + this.#resetCanvas(); + } + return this.#finishRenderTask(renderTask, error); + }); + if (pdfPage.isPureXfa) { + if (!this.xfaLayer) { + const { + annotationStorage, + linkService + } = this.#layerProperties; + this.xfaLayer = new XfaLayerBuilder({ + pdfPage, + annotationStorage, + linkService + }); + } + this.#renderXfaLayer(); + } + div.setAttribute("data-loaded", true); + this.eventBus.dispatch("pagerender", { + source: this, + pageNumber: this.id + }); + return resultPromise; + } + setPageLabel(label) { + this.pageLabel = typeof label === "string" ? label : null; + this.div.setAttribute("data-l10n-args", JSON.stringify({ + page: this.pageLabel ?? this.id + })); + if (this.pageLabel !== null) { + this.div.setAttribute("data-page-label", this.pageLabel); + } else { + this.div.removeAttribute("data-page-label"); + } + } + get thumbnailCanvas() { + const { + directDrawing, + initialOptionalContent, + regularAnnotations + } = this.#useThumbnailCanvas; + return directDrawing && initialOptionalContent && regularAnnotations ? this.canvas : null; + } +} + +;// ./web/generic_scripting.js + +async function docProperties(pdfDocument) { + const url = "", + baseUrl = url.split("#", 1)[0]; + let { + info, + metadata, + contentDispositionFilename, + contentLength + } = await pdfDocument.getMetadata(); + if (!contentLength) { + const { + length + } = await pdfDocument.getDownloadInfo(); + contentLength = length; + } + return { + ...info, + baseURL: baseUrl, + filesize: contentLength, + filename: contentDispositionFilename || getPdfFilenameFromUrl(url), + metadata: metadata?.getRaw(), + authors: metadata?.get("dc:creator"), + numPages: pdfDocument.numPages, + URL: url + }; +} +class GenericScripting { + constructor(sandboxBundleSrc) { + this._ready = new Promise((resolve, reject) => { + const sandbox = import(/*webpackIgnore: true*/sandboxBundleSrc); + sandbox.then(pdfjsSandbox => { + resolve(pdfjsSandbox.QuickJSSandbox()); + }).catch(reject); + }); + } + async createSandbox(data) { + const sandbox = await this._ready; + sandbox.create(data); + } + async dispatchEventInSandbox(event) { + const sandbox = await this._ready; + setTimeout(() => sandbox.dispatchEvent(event), 0); + } + async destroySandbox() { + const sandbox = await this._ready; + sandbox.nukeSandbox(); + } +} + +;// ./web/pdf_scripting_manager.js + + +class PDFScriptingManager { + #closeCapability = null; + #destroyCapability = null; + #docProperties = null; + #eventAbortController = null; + #eventBus = null; + #externalServices = null; + #pdfDocument = null; + #pdfViewer = null; + #ready = false; + #scripting = null; + #willPrintCapability = null; + constructor({ + eventBus, + externalServices = null, + docProperties = null + }) { + this.#eventBus = eventBus; + this.#externalServices = externalServices; + this.#docProperties = docProperties; + } + setViewer(pdfViewer) { + this.#pdfViewer = pdfViewer; + } + async setDocument(pdfDocument) { + if (this.#pdfDocument) { + await this.#destroyScripting(); + } + this.#pdfDocument = pdfDocument; + if (!pdfDocument) { + return; + } + const [objects, calculationOrder, docActions] = await Promise.all([pdfDocument.getFieldObjects(), pdfDocument.getCalculationOrderIds(), pdfDocument.getJSActions()]); + if (!objects && !docActions) { + await this.#destroyScripting(); + return; + } + if (pdfDocument !== this.#pdfDocument) { + return; + } + try { + this.#scripting = this.#initScripting(); + } catch (error) { + console.error("setDocument:", error); + await this.#destroyScripting(); + return; + } + const eventBus = this.#eventBus; + this.#eventAbortController = new AbortController(); + const { + signal + } = this.#eventAbortController; + eventBus._on("updatefromsandbox", event => { + if (event?.source === window) { + this.#updateFromSandbox(event.detail); + } + }, { + signal + }); + eventBus._on("dispatcheventinsandbox", event => { + this.#scripting?.dispatchEventInSandbox(event.detail); + }, { + signal + }); + eventBus._on("pagechanging", ({ + pageNumber, + previous + }) => { + if (pageNumber === previous) { + return; + } + this.#dispatchPageClose(previous); + this.#dispatchPageOpen(pageNumber); + }, { + signal + }); + eventBus._on("pagerendered", ({ + pageNumber + }) => { + if (!this._pageOpenPending.has(pageNumber)) { + return; + } + if (pageNumber !== this.#pdfViewer.currentPageNumber) { + return; + } + this.#dispatchPageOpen(pageNumber); + }, { + signal + }); + eventBus._on("pagesdestroy", async () => { + await this.#dispatchPageClose(this.#pdfViewer.currentPageNumber); + await this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "WillClose" + }); + this.#closeCapability?.resolve(); + }, { + signal + }); + try { + const docProperties = await this.#docProperties(pdfDocument); + if (pdfDocument !== this.#pdfDocument) { + return; + } + await this.#scripting.createSandbox({ + objects, + calculationOrder, + appInfo: { + platform: navigator.platform, + language: navigator.language + }, + docInfo: { + ...docProperties, + actions: docActions + } + }); + eventBus.dispatch("sandboxcreated", { + source: this + }); + } catch (error) { + console.error("setDocument:", error); + await this.#destroyScripting(); + return; + } + await this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "Open" + }); + await this.#dispatchPageOpen(this.#pdfViewer.currentPageNumber, true); + Promise.resolve().then(() => { + if (pdfDocument === this.#pdfDocument) { + this.#ready = true; + } + }); + } + async dispatchWillSave() { + return this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "WillSave" + }); + } + async dispatchDidSave() { + return this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "DidSave" + }); + } + async dispatchWillPrint() { + if (!this.#scripting) { + return; + } + await this.#willPrintCapability?.promise; + this.#willPrintCapability = Promise.withResolvers(); + try { + await this.#scripting.dispatchEventInSandbox({ + id: "doc", + name: "WillPrint" + }); + } catch (ex) { + this.#willPrintCapability.resolve(); + this.#willPrintCapability = null; + throw ex; + } + await this.#willPrintCapability.promise; + } + async dispatchDidPrint() { + return this.#scripting?.dispatchEventInSandbox({ + id: "doc", + name: "DidPrint" + }); + } + get destroyPromise() { + return this.#destroyCapability?.promise || null; + } + get ready() { + return this.#ready; + } + get _pageOpenPending() { + return shadow(this, "_pageOpenPending", new Set()); + } + get _visitedPages() { + return shadow(this, "_visitedPages", new Map()); + } + async #updateFromSandbox(detail) { + const pdfViewer = this.#pdfViewer; + const isInPresentationMode = pdfViewer.isInPresentationMode || pdfViewer.isChangingPresentationMode; + const { + id, + siblings, + command, + value + } = detail; + if (!id) { + switch (command) { + case "clear": + console.clear(); + break; + case "error": + console.error(value); + break; + case "layout": + if (!isInPresentationMode) { + const modes = apiPageLayoutToViewerModes(value); + pdfViewer.spreadMode = modes.spreadMode; + } + break; + case "page-num": + pdfViewer.currentPageNumber = value + 1; + break; + case "print": + await pdfViewer.pagesPromise; + this.#eventBus.dispatch("print", { + source: this + }); + break; + case "println": + console.log(value); + break; + case "zoom": + if (!isInPresentationMode) { + pdfViewer.currentScaleValue = value; + } + break; + case "SaveAs": + this.#eventBus.dispatch("download", { + source: this + }); + break; + case "FirstPage": + pdfViewer.currentPageNumber = 1; + break; + case "LastPage": + pdfViewer.currentPageNumber = pdfViewer.pagesCount; + break; + case "NextPage": + pdfViewer.nextPage(); + break; + case "PrevPage": + pdfViewer.previousPage(); + break; + case "ZoomViewIn": + if (!isInPresentationMode) { + pdfViewer.increaseScale(); + } + break; + case "ZoomViewOut": + if (!isInPresentationMode) { + pdfViewer.decreaseScale(); + } + break; + case "WillPrintFinished": + this.#willPrintCapability?.resolve(); + this.#willPrintCapability = null; + break; + } + return; + } + if (isInPresentationMode && detail.focus) { + return; + } + delete detail.id; + delete detail.siblings; + const ids = siblings ? [id, ...siblings] : [id]; + for (const elementId of ids) { + const element = document.querySelector(`[data-element-id="${elementId}"]`); + if (element) { + element.dispatchEvent(new CustomEvent("updatefromsandbox", { + detail + })); + } else { + this.#pdfDocument?.annotationStorage.setValue(elementId, detail); + } + } + } + async #dispatchPageOpen(pageNumber, initialize = false) { + const pdfDocument = this.#pdfDocument, + visitedPages = this._visitedPages; + if (initialize) { + this.#closeCapability = Promise.withResolvers(); + } + if (!this.#closeCapability) { + return; + } + const pageView = this.#pdfViewer.getPageView(pageNumber - 1); + if (pageView?.renderingState !== RenderingStates.FINISHED) { + this._pageOpenPending.add(pageNumber); + return; + } + this._pageOpenPending.delete(pageNumber); + const actionsPromise = (async () => { + const actions = await (!visitedPages.has(pageNumber) ? pageView.pdfPage?.getJSActions() : null); + if (pdfDocument !== this.#pdfDocument) { + return; + } + await this.#scripting?.dispatchEventInSandbox({ + id: "page", + name: "PageOpen", + pageNumber, + actions + }); + })(); + visitedPages.set(pageNumber, actionsPromise); + } + async #dispatchPageClose(pageNumber) { + const pdfDocument = this.#pdfDocument, + visitedPages = this._visitedPages; + if (!this.#closeCapability) { + return; + } + if (this._pageOpenPending.has(pageNumber)) { + return; + } + const actionsPromise = visitedPages.get(pageNumber); + if (!actionsPromise) { + return; + } + visitedPages.set(pageNumber, null); + await actionsPromise; + if (pdfDocument !== this.#pdfDocument) { + return; + } + await this.#scripting?.dispatchEventInSandbox({ + id: "page", + name: "PageClose", + pageNumber + }); + } + #initScripting() { + this.#destroyCapability = Promise.withResolvers(); + if (this.#scripting) { + throw new Error("#initScripting: Scripting already exists."); + } + return this.#externalServices.createScripting(); + } + async #destroyScripting() { + if (!this.#scripting) { + this.#pdfDocument = null; + this.#destroyCapability?.resolve(); + return; + } + if (this.#closeCapability) { + await Promise.race([this.#closeCapability.promise, new Promise(resolve => { + setTimeout(resolve, 1000); + })]).catch(() => {}); + this.#closeCapability = null; + } + this.#pdfDocument = null; + try { + await this.#scripting.destroySandbox(); + } catch {} + this.#willPrintCapability?.reject(new Error("Scripting destroyed.")); + this.#willPrintCapability = null; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this._pageOpenPending.clear(); + this._visitedPages.clear(); + this.#scripting = null; + this.#ready = false; + this.#destroyCapability?.resolve(); + } +} + +;// ./web/pdf_scripting_manager.component.js + + +class PDFScriptingManagerComponents extends PDFScriptingManager { + constructor(options) { + if (!options.externalServices) { + window.addEventListener("updatefromsandbox", event => { + options.eventBus.dispatch("updatefromsandbox", { + source: window, + detail: event.detail + }); + }); + } + options.externalServices ||= { + createScripting: () => new GenericScripting(options.sandboxBundleSrc) + }; + options.docProperties ||= pdfDocument => docProperties(pdfDocument); + super(options); + } +} + +;// ./web/pdf_rendering_queue.js + + +const CLEANUP_TIMEOUT = 30000; +class PDFRenderingQueue { + constructor() { + this.pdfViewer = null; + this.pdfThumbnailViewer = null; + this.onIdle = null; + this.highestPriorityPage = null; + this.idleTimeout = null; + this.printing = false; + this.isThumbnailViewEnabled = false; + Object.defineProperty(this, "hasViewer", { + value: () => !!this.pdfViewer + }); + } + setViewer(pdfViewer) { + this.pdfViewer = pdfViewer; + } + setThumbnailViewer(pdfThumbnailViewer) { + this.pdfThumbnailViewer = pdfThumbnailViewer; + } + isHighestPriority(view) { + return this.highestPriorityPage === view.renderingId; + } + renderHighestPriority(currentlyVisiblePages) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + if (this.pdfViewer.forceRendering(currentlyVisiblePages)) { + return; + } + if (this.isThumbnailViewEnabled && this.pdfThumbnailViewer?.forceRendering()) { + return; + } + if (this.printing) { + return; + } + if (this.onIdle) { + this.idleTimeout = setTimeout(this.onIdle.bind(this), CLEANUP_TIMEOUT); + } + } + getHighestPriority(visible, views, scrolledDown, preRenderExtra = false) { + const visibleViews = visible.views, + numVisible = visibleViews.length; + if (numVisible === 0) { + return null; + } + for (let i = 0; i < numVisible; i++) { + const view = visibleViews[i].view; + if (!this.isViewFinished(view)) { + return view; + } + } + const firstId = visible.first.id, + lastId = visible.last.id; + if (lastId - firstId + 1 > numVisible) { + const visibleIds = visible.ids; + for (let i = 1, ii = lastId - firstId; i < ii; i++) { + const holeId = scrolledDown ? firstId + i : lastId - i; + if (visibleIds.has(holeId)) { + continue; + } + const holeView = views[holeId - 1]; + if (!this.isViewFinished(holeView)) { + return holeView; + } + } + } + let preRenderIndex = scrolledDown ? lastId : firstId - 2; + let preRenderView = views[preRenderIndex]; + if (preRenderView && !this.isViewFinished(preRenderView)) { + return preRenderView; + } + if (preRenderExtra) { + preRenderIndex += scrolledDown ? 1 : -1; + preRenderView = views[preRenderIndex]; + if (preRenderView && !this.isViewFinished(preRenderView)) { + return preRenderView; + } + } + return null; + } + isViewFinished(view) { + return view.renderingState === RenderingStates.FINISHED; + } + renderView(view) { + switch (view.renderingState) { + case RenderingStates.FINISHED: + return false; + case RenderingStates.PAUSED: + this.highestPriorityPage = view.renderingId; + view.resume(); + break; + case RenderingStates.RUNNING: + this.highestPriorityPage = view.renderingId; + break; + case RenderingStates.INITIAL: + this.highestPriorityPage = view.renderingId; + view.draw().finally(() => { + this.renderHighestPriority(); + }).catch(reason => { + if (reason instanceof RenderingCancelledException) { + return; + } + console.error("renderView:", reason); + }); + break; + } + return true; + } +} + +;// ./web/pdf_viewer.js + + + + + + +const DEFAULT_CACHE_SIZE = 10; +const PagesCountLimit = { + FORCE_SCROLL_MODE_PAGE: 10000, + FORCE_LAZY_PAGE_INIT: 5000, + PAUSE_EAGER_PAGE_INIT: 250 +}; +function isValidAnnotationEditorMode(mode) { + return Object.values(AnnotationEditorType).includes(mode) && mode !== AnnotationEditorType.DISABLE; +} +class PDFPageViewBuffer { + #buf = new Set(); + #size = 0; + constructor(size) { + this.#size = size; + } + push(view) { + const buf = this.#buf; + if (buf.has(view)) { + buf.delete(view); + } + buf.add(view); + if (buf.size > this.#size) { + this.#destroyFirstView(); + } + } + resize(newSize, idsToKeep = null) { + this.#size = newSize; + const buf = this.#buf; + if (idsToKeep) { + const ii = buf.size; + let i = 1; + for (const view of buf) { + if (idsToKeep.has(view.id)) { + buf.delete(view); + buf.add(view); + } + if (++i > ii) { + break; + } + } + } + while (buf.size > this.#size) { + this.#destroyFirstView(); + } + } + has(view) { + return this.#buf.has(view); + } + [Symbol.iterator]() { + return this.#buf.keys(); + } + #destroyFirstView() { + const firstView = this.#buf.keys().next().value; + firstView?.destroy(); + this.#buf.delete(firstView); + } +} +class PDFViewer { + #buffer = null; + #altTextManager = null; + #annotationEditorHighlightColors = null; + #annotationEditorMode = AnnotationEditorType.NONE; + #annotationEditorUIManager = null; + #annotationMode = AnnotationMode.ENABLE_FORMS; + #containerTopLeft = null; + #editorUndoBar = null; + #enableHWA = false; + #enableHighlightFloatingButton = false; + #enablePermissions = false; + #enableUpdatedAddImage = false; + #enableNewAltTextWhenAddingImage = false; + #eventAbortController = null; + #mlManager = null; + #switchAnnotationEditorModeAC = null; + #switchAnnotationEditorModeTimeoutId = null; + #getAllTextInProgress = false; + #hiddenCopyElement = null; + #interruptCopyCondition = false; + #previousContainerHeight = 0; + #resizeObserver = new ResizeObserver(this.#resizeObserverCallback.bind(this)); + #scrollModePageState = null; + #scaleTimeoutId = null; + #supportsPinchToZoom = true; + #textLayerMode = TextLayerMode.ENABLE; + constructor(options) { + const viewerVersion = "4.10.38"; + if (version !== viewerVersion) { + throw new Error(`The API version "${version}" does not match the Viewer version "${viewerVersion}".`); + } + this.container = options.container; + this.viewer = options.viewer || options.container.firstElementChild; + if (this.container?.tagName !== "DIV" || this.viewer?.tagName !== "DIV") { + throw new Error("Invalid `container` and/or `viewer` option."); + } + if (this.container.offsetParent && getComputedStyle(this.container).position !== "absolute") { + throw new Error("The `container` must be absolutely positioned."); + } + this.#resizeObserver.observe(this.container); + this.eventBus = options.eventBus; + this.linkService = options.linkService || new SimpleLinkService(); + this.downloadManager = options.downloadManager || null; + this.findController = options.findController || null; + this.#altTextManager = options.altTextManager || null; + this.#editorUndoBar = options.editorUndoBar || null; + if (this.findController) { + this.findController.onIsPageVisible = pageNumber => this._getVisiblePages().ids.has(pageNumber); + } + this._scriptingManager = options.scriptingManager || null; + this.#textLayerMode = options.textLayerMode ?? TextLayerMode.ENABLE; + this.#annotationMode = options.annotationMode ?? AnnotationMode.ENABLE_FORMS; + this.#annotationEditorMode = options.annotationEditorMode ?? AnnotationEditorType.NONE; + this.#annotationEditorHighlightColors = options.annotationEditorHighlightColors || null; + this.#enableHighlightFloatingButton = options.enableHighlightFloatingButton === true; + this.#enableUpdatedAddImage = options.enableUpdatedAddImage === true; + this.#enableNewAltTextWhenAddingImage = options.enableNewAltTextWhenAddingImage === true; + this.imageResourcesPath = options.imageResourcesPath || ""; + this.enablePrintAutoRotate = options.enablePrintAutoRotate || false; + this.removePageBorders = options.removePageBorders || false; + this.maxCanvasPixels = options.maxCanvasPixels; + this.l10n = options.l10n; + this.l10n ||= new genericl10n_GenericL10n(); + this.#enablePermissions = options.enablePermissions || false; + this.pageColors = options.pageColors || null; + this.#mlManager = options.mlManager || null; + this.#enableHWA = options.enableHWA || false; + this.#supportsPinchToZoom = options.supportsPinchToZoom !== false; + this.defaultRenderingQueue = !options.renderingQueue; + if (this.defaultRenderingQueue) { + this.renderingQueue = new PDFRenderingQueue(); + this.renderingQueue.setViewer(this); + } else { + this.renderingQueue = options.renderingQueue; + } + const { + abortSignal + } = options; + abortSignal?.addEventListener("abort", () => { + this.#resizeObserver.disconnect(); + this.#resizeObserver = null; + }, { + once: true + }); + this.scroll = watchScroll(this.container, this._scrollUpdate.bind(this), abortSignal); + this.presentationModeState = PresentationModeState.UNKNOWN; + this._resetView(); + if (this.removePageBorders) { + this.viewer.classList.add("removePageBorders"); + } + this.#updateContainerHeightCss(); + this.eventBus._on("thumbnailrendered", ({ + pageNumber, + pdfPage + }) => { + const pageView = this._pages[pageNumber - 1]; + if (!this.#buffer.has(pageView)) { + pdfPage?.cleanup(); + } + }); + if (!options.l10n) { + this.l10n.translate(this.container); + } + } + get pagesCount() { + return this._pages.length; + } + getPageView(index) { + return this._pages[index]; + } + getCachedPageViews() { + return new Set(this.#buffer); + } + get pageViewsReady() { + return this._pages.every(pageView => pageView?.pdfPage); + } + get renderForms() { + return this.#annotationMode === AnnotationMode.ENABLE_FORMS; + } + get enableScripting() { + return !!this._scriptingManager; + } + get currentPageNumber() { + return this._currentPageNumber; + } + set currentPageNumber(val) { + if (!Number.isInteger(val)) { + throw new Error("Invalid page number."); + } + if (!this.pdfDocument) { + return; + } + if (!this._setCurrentPageNumber(val, true)) { + console.error(`currentPageNumber: "${val}" is not a valid page.`); + } + } + _setCurrentPageNumber(val, resetCurrentPageView = false) { + if (this._currentPageNumber === val) { + if (resetCurrentPageView) { + this.#resetCurrentPageView(); + } + return true; + } + if (!(0 < val && val <= this.pagesCount)) { + return false; + } + const previous = this._currentPageNumber; + this._currentPageNumber = val; + this.eventBus.dispatch("pagechanging", { + source: this, + pageNumber: val, + pageLabel: this._pageLabels?.[val - 1] ?? null, + previous + }); + if (resetCurrentPageView) { + this.#resetCurrentPageView(); + } + return true; + } + get currentPageLabel() { + return this._pageLabels?.[this._currentPageNumber - 1] ?? null; + } + set currentPageLabel(val) { + if (!this.pdfDocument) { + return; + } + let page = val | 0; + if (this._pageLabels) { + const i = this._pageLabels.indexOf(val); + if (i >= 0) { + page = i + 1; + } + } + if (!this._setCurrentPageNumber(page, true)) { + console.error(`currentPageLabel: "${val}" is not a valid page.`); + } + } + get currentScale() { + return this._currentScale !== UNKNOWN_SCALE ? this._currentScale : DEFAULT_SCALE; + } + set currentScale(val) { + if (isNaN(val)) { + throw new Error("Invalid numeric scale."); + } + if (!this.pdfDocument) { + return; + } + this.#setScale(val, { + noScroll: false + }); + } + get currentScaleValue() { + return this._currentScaleValue; + } + set currentScaleValue(val) { + if (!this.pdfDocument) { + return; + } + this.#setScale(val, { + noScroll: false + }); + } + get pagesRotation() { + return this._pagesRotation; + } + set pagesRotation(rotation) { + if (!isValidRotation(rotation)) { + throw new Error("Invalid pages rotation angle."); + } + if (!this.pdfDocument) { + return; + } + rotation %= 360; + if (rotation < 0) { + rotation += 360; + } + if (this._pagesRotation === rotation) { + return; + } + this._pagesRotation = rotation; + const pageNumber = this._currentPageNumber; + this.refresh(true, { + rotation + }); + if (this._currentScaleValue) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this.eventBus.dispatch("rotationchanging", { + source: this, + pagesRotation: rotation, + pageNumber + }); + if (this.defaultRenderingQueue) { + this.update(); + } + } + get firstPagePromise() { + return this.pdfDocument ? this._firstPageCapability.promise : null; + } + get onePageRendered() { + return this.pdfDocument ? this._onePageRenderedCapability.promise : null; + } + get pagesPromise() { + return this.pdfDocument ? this._pagesCapability.promise : null; + } + get _layerProperties() { + const self = this; + return shadow(this, "_layerProperties", { + get annotationEditorUIManager() { + return self.#annotationEditorUIManager; + }, + get annotationStorage() { + return self.pdfDocument?.annotationStorage; + }, + get downloadManager() { + return self.downloadManager; + }, + get enableScripting() { + return !!self._scriptingManager; + }, + get fieldObjectsPromise() { + return self.pdfDocument?.getFieldObjects(); + }, + get findController() { + return self.findController; + }, + get hasJSActionsPromise() { + return self.pdfDocument?.hasJSActions(); + }, + get linkService() { + return self.linkService; + } + }); + } + #initializePermissions(permissions) { + const params = { + annotationEditorMode: this.#annotationEditorMode, + annotationMode: this.#annotationMode, + textLayerMode: this.#textLayerMode + }; + if (!permissions) { + return params; + } + if (!permissions.includes(PermissionFlag.COPY) && this.#textLayerMode === TextLayerMode.ENABLE) { + params.textLayerMode = TextLayerMode.ENABLE_PERMISSIONS; + } + if (!permissions.includes(PermissionFlag.MODIFY_CONTENTS)) { + params.annotationEditorMode = AnnotationEditorType.DISABLE; + } + if (!permissions.includes(PermissionFlag.MODIFY_ANNOTATIONS) && !permissions.includes(PermissionFlag.FILL_INTERACTIVE_FORMS) && this.#annotationMode === AnnotationMode.ENABLE_FORMS) { + params.annotationMode = AnnotationMode.ENABLE; + } + return params; + } + async #onePageRenderedOrForceFetch(signal) { + if (document.visibilityState === "hidden" || !this.container.offsetParent || this._getVisiblePages().views.length === 0) { + return; + } + const hiddenCapability = Promise.withResolvers(), + ac = new AbortController(); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + hiddenCapability.resolve(); + } + }, { + signal: typeof AbortSignal.any === "function" ? AbortSignal.any([signal, ac.signal]) : signal + }); + await Promise.race([this._onePageRenderedCapability.promise, hiddenCapability.promise]); + ac.abort(); + } + async getAllText() { + const texts = []; + const buffer = []; + for (let pageNum = 1, pagesCount = this.pdfDocument.numPages; pageNum <= pagesCount; ++pageNum) { + if (this.#interruptCopyCondition) { + return null; + } + buffer.length = 0; + const page = await this.pdfDocument.getPage(pageNum); + const { + items + } = await page.getTextContent(); + for (const item of items) { + if (item.str) { + buffer.push(item.str); + } + if (item.hasEOL) { + buffer.push("\n"); + } + } + texts.push(removeNullCharacters(buffer.join(""))); + } + return texts.join("\n"); + } + #copyCallback(textLayerMode, event) { + const selection = document.getSelection(); + const { + focusNode, + anchorNode + } = selection; + if (anchorNode && focusNode && selection.containsNode(this.#hiddenCopyElement)) { + if (this.#getAllTextInProgress || textLayerMode === TextLayerMode.ENABLE_PERMISSIONS) { + stopEvent(event); + return; + } + this.#getAllTextInProgress = true; + const { + classList + } = this.viewer; + classList.add("copyAll"); + const ac = new AbortController(); + window.addEventListener("keydown", ev => this.#interruptCopyCondition = ev.key === "Escape", { + signal: ac.signal + }); + this.getAllText().then(async text => { + if (text !== null) { + await navigator.clipboard.writeText(text); + } + }).catch(reason => { + console.warn(`Something goes wrong when extracting the text: ${reason.message}`); + }).finally(() => { + this.#getAllTextInProgress = false; + this.#interruptCopyCondition = false; + ac.abort(); + classList.remove("copyAll"); + }); + stopEvent(event); + } + } + setDocument(pdfDocument) { + if (this.pdfDocument) { + this.eventBus.dispatch("pagesdestroy", { + source: this + }); + this._cancelRendering(); + this._resetView(); + this.findController?.setDocument(null); + this._scriptingManager?.setDocument(null); + this.#annotationEditorUIManager?.destroy(); + this.#annotationEditorUIManager = null; + } + this.pdfDocument = pdfDocument; + if (!pdfDocument) { + return; + } + const pagesCount = pdfDocument.numPages; + const firstPagePromise = pdfDocument.getPage(1); + const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({ + intent: "display" + }); + const permissionsPromise = this.#enablePermissions ? pdfDocument.getPermissions() : Promise.resolve(); + const { + eventBus, + pageColors, + viewer + } = this; + this.#eventAbortController = new AbortController(); + const { + signal + } = this.#eventAbortController; + if (pagesCount > PagesCountLimit.FORCE_SCROLL_MODE_PAGE) { + console.warn("Forcing PAGE-scrolling for performance reasons, given the length of the document."); + const mode = this._scrollMode = ScrollMode.PAGE; + eventBus.dispatch("scrollmodechanged", { + source: this, + mode + }); + } + this._pagesCapability.promise.then(() => { + eventBus.dispatch("pagesloaded", { + source: this, + pagesCount + }); + }, () => {}); + const onBeforeDraw = evt => { + const pageView = this._pages[evt.pageNumber - 1]; + if (!pageView) { + return; + } + this.#buffer.push(pageView); + }; + eventBus._on("pagerender", onBeforeDraw, { + signal + }); + const onAfterDraw = evt => { + if (evt.cssTransform) { + return; + } + this._onePageRenderedCapability.resolve({ + timestamp: evt.timestamp + }); + eventBus._off("pagerendered", onAfterDraw); + }; + eventBus._on("pagerendered", onAfterDraw, { + signal + }); + Promise.all([firstPagePromise, permissionsPromise]).then(([firstPdfPage, permissions]) => { + if (pdfDocument !== this.pdfDocument) { + return; + } + this._firstPageCapability.resolve(firstPdfPage); + this._optionalContentConfigPromise = optionalContentConfigPromise; + const { + annotationEditorMode, + annotationMode, + textLayerMode + } = this.#initializePermissions(permissions); + if (textLayerMode !== TextLayerMode.DISABLE) { + const element = this.#hiddenCopyElement = document.createElement("div"); + element.id = "hiddenCopyElement"; + viewer.before(element); + } + if (typeof AbortSignal.any === "function" && annotationEditorMode !== AnnotationEditorType.DISABLE) { + const mode = annotationEditorMode; + if (pdfDocument.isPureXfa) { + console.warn("Warning: XFA-editing is not implemented."); + } else if (isValidAnnotationEditorMode(mode)) { + this.#annotationEditorUIManager = new AnnotationEditorUIManager(this.container, viewer, this.#altTextManager, eventBus, pdfDocument, pageColors, this.#annotationEditorHighlightColors, this.#enableHighlightFloatingButton, this.#enableUpdatedAddImage, this.#enableNewAltTextWhenAddingImage, this.#mlManager, this.#editorUndoBar, this.#supportsPinchToZoom); + eventBus.dispatch("annotationeditoruimanager", { + source: this, + uiManager: this.#annotationEditorUIManager + }); + if (mode !== AnnotationEditorType.NONE) { + if (mode === AnnotationEditorType.STAMP) { + this.#mlManager?.loadModel("altText"); + } + this.#annotationEditorUIManager.updateMode(mode); + } + } else { + console.error(`Invalid AnnotationEditor mode: ${mode}`); + } + } + const viewerElement = this._scrollMode === ScrollMode.PAGE ? null : viewer; + const scale = this.currentScale; + const viewport = firstPdfPage.getViewport({ + scale: scale * PixelsPerInch.PDF_TO_CSS_UNITS + }); + viewer.style.setProperty("--scale-factor", viewport.scale); + if (pageColors?.background) { + viewer.style.setProperty("--page-bg-color", pageColors.background); + } + if (pageColors?.foreground === "CanvasText" || pageColors?.background === "Canvas") { + viewer.style.setProperty("--hcm-highlight-filter", pdfDocument.filterFactory.addHighlightHCMFilter("highlight", "CanvasText", "Canvas", "HighlightText", "Highlight")); + viewer.style.setProperty("--hcm-highlight-selected-filter", pdfDocument.filterFactory.addHighlightHCMFilter("highlight_selected", "CanvasText", "Canvas", "HighlightText", "ButtonText")); + } + for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { + const pageView = new PDFPageView({ + container: viewerElement, + eventBus, + id: pageNum, + scale, + defaultViewport: viewport.clone(), + optionalContentConfigPromise, + renderingQueue: this.renderingQueue, + textLayerMode, + annotationMode, + imageResourcesPath: this.imageResourcesPath, + maxCanvasPixels: this.maxCanvasPixels, + pageColors, + l10n: this.l10n, + layerProperties: this._layerProperties, + enableHWA: this.#enableHWA + }); + this._pages.push(pageView); + } + this._pages[0]?.setPdfPage(firstPdfPage); + if (this._scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + } else if (this._spreadMode !== SpreadMode.NONE) { + this._updateSpreadMode(); + } + this.#onePageRenderedOrForceFetch(signal).then(async () => { + if (pdfDocument !== this.pdfDocument) { + return; + } + this.findController?.setDocument(pdfDocument); + this._scriptingManager?.setDocument(pdfDocument); + if (this.#hiddenCopyElement) { + document.addEventListener("copy", this.#copyCallback.bind(this, textLayerMode), { + signal + }); + } + if (this.#annotationEditorUIManager) { + eventBus.dispatch("annotationeditormodechanged", { + source: this, + mode: this.#annotationEditorMode + }); + } + if (pdfDocument.loadingParams.disableAutoFetch || pagesCount > PagesCountLimit.FORCE_LAZY_PAGE_INIT) { + this._pagesCapability.resolve(); + return; + } + let getPagesLeft = pagesCount - 1; + if (getPagesLeft <= 0) { + this._pagesCapability.resolve(); + return; + } + for (let pageNum = 2; pageNum <= pagesCount; ++pageNum) { + const promise = pdfDocument.getPage(pageNum).then(pdfPage => { + const pageView = this._pages[pageNum - 1]; + if (!pageView.pdfPage) { + pageView.setPdfPage(pdfPage); + } + if (--getPagesLeft === 0) { + this._pagesCapability.resolve(); + } + }, reason => { + console.error(`Unable to get page ${pageNum} to initialize viewer`, reason); + if (--getPagesLeft === 0) { + this._pagesCapability.resolve(); + } + }); + if (pageNum % PagesCountLimit.PAUSE_EAGER_PAGE_INIT === 0) { + await promise; + } + } + }); + eventBus.dispatch("pagesinit", { + source: this + }); + pdfDocument.getMetadata().then(({ + info + }) => { + if (pdfDocument !== this.pdfDocument) { + return; + } + if (info.Language) { + viewer.lang = info.Language; + } + }); + if (this.defaultRenderingQueue) { + this.update(); + } + }).catch(reason => { + console.error("Unable to initialize viewer", reason); + this._pagesCapability.reject(reason); + }); + } + setPageLabels(labels) { + if (!this.pdfDocument) { + return; + } + if (!labels) { + this._pageLabels = null; + } else if (!(Array.isArray(labels) && this.pdfDocument.numPages === labels.length)) { + this._pageLabels = null; + console.error(`setPageLabels: Invalid page labels.`); + } else { + this._pageLabels = labels; + } + for (let i = 0, ii = this._pages.length; i < ii; i++) { + this._pages[i].setPageLabel(this._pageLabels?.[i] ?? null); + } + } + _resetView() { + this._pages = []; + this._currentPageNumber = 1; + this._currentScale = UNKNOWN_SCALE; + this._currentScaleValue = null; + this._pageLabels = null; + this.#buffer = new PDFPageViewBuffer(DEFAULT_CACHE_SIZE); + this._location = null; + this._pagesRotation = 0; + this._optionalContentConfigPromise = null; + this._firstPageCapability = Promise.withResolvers(); + this._onePageRenderedCapability = Promise.withResolvers(); + this._pagesCapability = Promise.withResolvers(); + this._scrollMode = ScrollMode.VERTICAL; + this._previousScrollMode = ScrollMode.UNKNOWN; + this._spreadMode = SpreadMode.NONE; + this.#scrollModePageState = { + previousPageNumber: 1, + scrollDown: true, + pages: [] + }; + this.#eventAbortController?.abort(); + this.#eventAbortController = null; + this.viewer.textContent = ""; + this._updateScrollMode(); + this.viewer.removeAttribute("lang"); + this.#hiddenCopyElement?.remove(); + this.#hiddenCopyElement = null; + this.#cleanupSwitchAnnotationEditorMode(); + } + #ensurePageViewVisible() { + if (this._scrollMode !== ScrollMode.PAGE) { + throw new Error("#ensurePageViewVisible: Invalid scrollMode value."); + } + const pageNumber = this._currentPageNumber, + state = this.#scrollModePageState, + viewer = this.viewer; + viewer.textContent = ""; + state.pages.length = 0; + if (this._spreadMode === SpreadMode.NONE && !this.isInPresentationMode) { + const pageView = this._pages[pageNumber - 1]; + viewer.append(pageView.div); + state.pages.push(pageView); + } else { + const pageIndexSet = new Set(), + parity = this._spreadMode - 1; + if (parity === -1) { + pageIndexSet.add(pageNumber - 1); + } else if (pageNumber % 2 !== parity) { + pageIndexSet.add(pageNumber - 1); + pageIndexSet.add(pageNumber); + } else { + pageIndexSet.add(pageNumber - 2); + pageIndexSet.add(pageNumber - 1); + } + const spread = document.createElement("div"); + spread.className = "spread"; + if (this.isInPresentationMode) { + const dummyPage = document.createElement("div"); + dummyPage.className = "dummyPage"; + spread.append(dummyPage); + } + for (const i of pageIndexSet) { + const pageView = this._pages[i]; + if (!pageView) { + continue; + } + spread.append(pageView.div); + state.pages.push(pageView); + } + viewer.append(spread); + } + state.scrollDown = pageNumber >= state.previousPageNumber; + state.previousPageNumber = pageNumber; + } + _scrollUpdate() { + if (this.pagesCount === 0) { + return; + } + this.update(); + } + #scrollIntoView(pageView, pageSpot = null) { + const { + div, + id + } = pageView; + if (this._currentPageNumber !== id) { + this._setCurrentPageNumber(id); + } + if (this._scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + this.update(); + } + if (!pageSpot && !this.isInPresentationMode) { + const left = div.offsetLeft + div.clientLeft, + right = left + div.clientWidth; + const { + scrollLeft, + clientWidth + } = this.container; + if (this._scrollMode === ScrollMode.HORIZONTAL || left < scrollLeft || right > scrollLeft + clientWidth) { + pageSpot = { + left: 0, + top: 0 + }; + } + } + scrollIntoView(div, pageSpot); + if (!this._currentScaleValue && this._location) { + this._location = null; + } + } + #isSameScale(newScale) { + return newScale === this._currentScale || Math.abs(newScale - this._currentScale) < 1e-15; + } + #setScaleUpdatePages(newScale, newValue, { + noScroll = false, + preset = false, + drawingDelay = -1, + origin = null + }) { + this._currentScaleValue = newValue.toString(); + if (this.#isSameScale(newScale)) { + if (preset) { + this.eventBus.dispatch("scalechanging", { + source: this, + scale: newScale, + presetValue: newValue + }); + } + return; + } + this.viewer.style.setProperty("--scale-factor", newScale * PixelsPerInch.PDF_TO_CSS_UNITS); + const postponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; + this.refresh(true, { + scale: newScale, + drawingDelay: postponeDrawing ? drawingDelay : -1 + }); + if (postponeDrawing) { + this.#scaleTimeoutId = setTimeout(() => { + this.#scaleTimeoutId = null; + this.refresh(); + }, drawingDelay); + } + const previousScale = this._currentScale; + this._currentScale = newScale; + if (!noScroll) { + let page = this._currentPageNumber, + dest; + if (this._location && !(this.isInPresentationMode || this.isChangingPresentationMode)) { + page = this._location.pageNumber; + dest = [null, { + name: "XYZ" + }, this._location.left, this._location.top, null]; + } + this.scrollPageIntoView({ + pageNumber: page, + destArray: dest, + allowNegativeOffset: true + }); + if (Array.isArray(origin)) { + const scaleDiff = newScale / previousScale - 1; + const [top, left] = this.containerTopLeft; + this.container.scrollLeft += (origin[0] - left) * scaleDiff; + this.container.scrollTop += (origin[1] - top) * scaleDiff; + } + } + this.eventBus.dispatch("scalechanging", { + source: this, + scale: newScale, + presetValue: preset ? newValue : undefined + }); + if (this.defaultRenderingQueue) { + this.update(); + } + } + get #pageWidthScaleFactor() { + if (this._spreadMode !== SpreadMode.NONE && this._scrollMode !== ScrollMode.HORIZONTAL) { + return 2; + } + return 1; + } + #setScale(value, options) { + let scale = parseFloat(value); + if (scale > 0) { + options.preset = false; + this.#setScaleUpdatePages(scale, value, options); + } else { + const currentPage = this._pages[this._currentPageNumber - 1]; + if (!currentPage) { + return; + } + let hPadding = SCROLLBAR_PADDING, + vPadding = VERTICAL_PADDING; + if (this.isInPresentationMode) { + hPadding = vPadding = 4; + if (this._spreadMode !== SpreadMode.NONE) { + hPadding *= 2; + } + } else if (this.removePageBorders) { + hPadding = vPadding = 0; + } else if (this._scrollMode === ScrollMode.HORIZONTAL) { + [hPadding, vPadding] = [vPadding, hPadding]; + } + const pageWidthScale = (this.container.clientWidth - hPadding) / currentPage.width * currentPage.scale / this.#pageWidthScaleFactor; + const pageHeightScale = (this.container.clientHeight - vPadding) / currentPage.height * currentPage.scale; + switch (value) { + case "page-actual": + scale = 1; + break; + case "page-width": + scale = pageWidthScale; + break; + case "page-height": + scale = pageHeightScale; + break; + case "page-fit": + scale = Math.min(pageWidthScale, pageHeightScale); + break; + case "auto": + const horizontalScale = isPortraitOrientation(currentPage) ? pageWidthScale : Math.min(pageHeightScale, pageWidthScale); + scale = Math.min(MAX_AUTO_SCALE, horizontalScale); + break; + default: + console.error(`#setScale: "${value}" is an unknown zoom value.`); + return; + } + options.preset = true; + this.#setScaleUpdatePages(scale, value, options); + } + } + #resetCurrentPageView() { + const pageView = this._pages[this._currentPageNumber - 1]; + if (this.isInPresentationMode) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this.#scrollIntoView(pageView); + } + pageLabelToPageNumber(label) { + if (!this._pageLabels) { + return null; + } + const i = this._pageLabels.indexOf(label); + if (i < 0) { + return null; + } + return i + 1; + } + scrollPageIntoView({ + pageNumber, + destArray = null, + allowNegativeOffset = false, + ignoreDestinationZoom = false + }) { + if (!this.pdfDocument) { + return; + } + const pageView = Number.isInteger(pageNumber) && this._pages[pageNumber - 1]; + if (!pageView) { + console.error(`scrollPageIntoView: "${pageNumber}" is not a valid pageNumber parameter.`); + return; + } + if (this.isInPresentationMode || !destArray) { + this._setCurrentPageNumber(pageNumber, true); + return; + } + let x = 0, + y = 0; + let width = 0, + height = 0, + widthScale, + heightScale; + const changeOrientation = pageView.rotation % 180 !== 0; + const pageWidth = (changeOrientation ? pageView.height : pageView.width) / pageView.scale / PixelsPerInch.PDF_TO_CSS_UNITS; + const pageHeight = (changeOrientation ? pageView.width : pageView.height) / pageView.scale / PixelsPerInch.PDF_TO_CSS_UNITS; + let scale = 0; + switch (destArray[1].name) { + case "XYZ": + x = destArray[2]; + y = destArray[3]; + scale = destArray[4]; + x = x !== null ? x : 0; + y = y !== null ? y : pageHeight; + break; + case "Fit": + case "FitB": + scale = "page-fit"; + break; + case "FitH": + case "FitBH": + y = destArray[2]; + scale = "page-width"; + if (y === null && this._location) { + x = this._location.left; + y = this._location.top; + } else if (typeof y !== "number" || y < 0) { + y = pageHeight; + } + break; + case "FitV": + case "FitBV": + x = destArray[2]; + width = pageWidth; + height = pageHeight; + scale = "page-height"; + break; + case "FitR": + x = destArray[2]; + y = destArray[3]; + width = destArray[4] - x; + height = destArray[5] - y; + let hPadding = SCROLLBAR_PADDING, + vPadding = VERTICAL_PADDING; + if (this.removePageBorders) { + hPadding = vPadding = 0; + } + widthScale = (this.container.clientWidth - hPadding) / width / PixelsPerInch.PDF_TO_CSS_UNITS; + heightScale = (this.container.clientHeight - vPadding) / height / PixelsPerInch.PDF_TO_CSS_UNITS; + scale = Math.min(Math.abs(widthScale), Math.abs(heightScale)); + break; + default: + console.error(`scrollPageIntoView: "${destArray[1].name}" is not a valid destination type.`); + return; + } + if (!ignoreDestinationZoom) { + if (scale && scale !== this._currentScale) { + this.currentScaleValue = scale; + } else if (this._currentScale === UNKNOWN_SCALE) { + this.currentScaleValue = DEFAULT_SCALE_VALUE; + } + } + if (scale === "page-fit" && !destArray[4]) { + this.#scrollIntoView(pageView); + return; + } + const boundingRect = [pageView.viewport.convertToViewportPoint(x, y), pageView.viewport.convertToViewportPoint(x + width, y + height)]; + let left = Math.min(boundingRect[0][0], boundingRect[1][0]); + let top = Math.min(boundingRect[0][1], boundingRect[1][1]); + if (!allowNegativeOffset) { + left = Math.max(left, 0); + top = Math.max(top, 0); + } + this.#scrollIntoView(pageView, { + left, + top + }); + } + _updateLocation(firstPage) { + const currentScale = this._currentScale; + const currentScaleValue = this._currentScaleValue; + const normalizedScaleValue = parseFloat(currentScaleValue) === currentScale ? Math.round(currentScale * 10000) / 100 : currentScaleValue; + const pageNumber = firstPage.id; + const currentPageView = this._pages[pageNumber - 1]; + const container = this.container; + const topLeft = currentPageView.getPagePoint(container.scrollLeft - firstPage.x, container.scrollTop - firstPage.y); + const intLeft = Math.round(topLeft[0]); + const intTop = Math.round(topLeft[1]); + let pdfOpenParams = `#page=${pageNumber}`; + if (!this.isInPresentationMode) { + pdfOpenParams += `&zoom=${normalizedScaleValue},${intLeft},${intTop}`; + } + this._location = { + pageNumber, + scale: normalizedScaleValue, + top: intTop, + left: intLeft, + rotation: this._pagesRotation, + pdfOpenParams + }; + } + update() { + const visible = this._getVisiblePages(); + const visiblePages = visible.views, + numVisiblePages = visiblePages.length; + if (numVisiblePages === 0) { + return; + } + const newCacheSize = Math.max(DEFAULT_CACHE_SIZE, 2 * numVisiblePages + 1); + this.#buffer.resize(newCacheSize, visible.ids); + this.renderingQueue.renderHighestPriority(visible); + const isSimpleLayout = this._spreadMode === SpreadMode.NONE && (this._scrollMode === ScrollMode.PAGE || this._scrollMode === ScrollMode.VERTICAL); + const currentId = this._currentPageNumber; + let stillFullyVisible = false; + for (const page of visiblePages) { + if (page.percent < 100) { + break; + } + if (page.id === currentId && isSimpleLayout) { + stillFullyVisible = true; + break; + } + } + this._setCurrentPageNumber(stillFullyVisible ? currentId : visiblePages[0].id); + this._updateLocation(visible.first); + this.eventBus.dispatch("updateviewarea", { + source: this, + location: this._location + }); + } + #switchToEditAnnotationMode() { + const visible = this._getVisiblePages(); + const pagesToRefresh = []; + const { + ids, + views + } = visible; + for (const page of views) { + const { + view + } = page; + if (!view.hasEditableAnnotations()) { + ids.delete(view.id); + continue; + } + pagesToRefresh.push(page); + } + if (pagesToRefresh.length === 0) { + return null; + } + this.renderingQueue.renderHighestPriority({ + first: pagesToRefresh[0], + last: pagesToRefresh.at(-1), + views: pagesToRefresh, + ids + }); + return ids; + } + containsElement(element) { + return this.container.contains(element); + } + focus() { + this.container.focus(); + } + get _isContainerRtl() { + return getComputedStyle(this.container).direction === "rtl"; + } + get isInPresentationMode() { + return this.presentationModeState === PresentationModeState.FULLSCREEN; + } + get isChangingPresentationMode() { + return this.presentationModeState === PresentationModeState.CHANGING; + } + get isHorizontalScrollbarEnabled() { + return this.isInPresentationMode ? false : this.container.scrollWidth > this.container.clientWidth; + } + get isVerticalScrollbarEnabled() { + return this.isInPresentationMode ? false : this.container.scrollHeight > this.container.clientHeight; + } + _getVisiblePages() { + const views = this._scrollMode === ScrollMode.PAGE ? this.#scrollModePageState.pages : this._pages, + horizontal = this._scrollMode === ScrollMode.HORIZONTAL, + rtl = horizontal && this._isContainerRtl; + return getVisibleElements({ + scrollEl: this.container, + views, + sortByVisibility: true, + horizontal, + rtl + }); + } + cleanup() { + for (const pageView of this._pages) { + if (pageView.renderingState !== RenderingStates.FINISHED) { + pageView.reset(); + } + } + } + _cancelRendering() { + for (const pageView of this._pages) { + pageView.cancelRendering(); + } + } + async #ensurePdfPageLoaded(pageView) { + if (pageView.pdfPage) { + return pageView.pdfPage; + } + try { + const pdfPage = await this.pdfDocument.getPage(pageView.id); + if (!pageView.pdfPage) { + pageView.setPdfPage(pdfPage); + } + return pdfPage; + } catch (reason) { + console.error("Unable to get page for page view", reason); + return null; + } + } + #getScrollAhead(visible) { + if (visible.first?.id === 1) { + return true; + } else if (visible.last?.id === this.pagesCount) { + return false; + } + switch (this._scrollMode) { + case ScrollMode.PAGE: + return this.#scrollModePageState.scrollDown; + case ScrollMode.HORIZONTAL: + return this.scroll.right; + } + return this.scroll.down; + } + forceRendering(currentlyVisiblePages) { + const visiblePages = currentlyVisiblePages || this._getVisiblePages(); + const scrollAhead = this.#getScrollAhead(visiblePages); + const preRenderExtra = this._spreadMode !== SpreadMode.NONE && this._scrollMode !== ScrollMode.HORIZONTAL; + const pageView = this.renderingQueue.getHighestPriority(visiblePages, this._pages, scrollAhead, preRenderExtra); + if (pageView) { + this.#ensurePdfPageLoaded(pageView).then(() => { + this.renderingQueue.renderView(pageView); + }); + return true; + } + return false; + } + get hasEqualPageSizes() { + const firstPageView = this._pages[0]; + for (let i = 1, ii = this._pages.length; i < ii; ++i) { + const pageView = this._pages[i]; + if (pageView.width !== firstPageView.width || pageView.height !== firstPageView.height) { + return false; + } + } + return true; + } + getPagesOverview() { + let initialOrientation; + return this._pages.map(pageView => { + const viewport = pageView.pdfPage.getViewport({ + scale: 1 + }); + const orientation = isPortraitOrientation(viewport); + if (initialOrientation === undefined) { + initialOrientation = orientation; + } else if (this.enablePrintAutoRotate && orientation !== initialOrientation) { + return { + width: viewport.height, + height: viewport.width, + rotation: (viewport.rotation - 90) % 360 + }; + } + return { + width: viewport.width, + height: viewport.height, + rotation: viewport.rotation + }; + }); + } + get optionalContentConfigPromise() { + if (!this.pdfDocument) { + return Promise.resolve(null); + } + if (!this._optionalContentConfigPromise) { + console.error("optionalContentConfigPromise: Not initialized yet."); + return this.pdfDocument.getOptionalContentConfig({ + intent: "display" + }); + } + return this._optionalContentConfigPromise; + } + set optionalContentConfigPromise(promise) { + if (!(promise instanceof Promise)) { + throw new Error(`Invalid optionalContentConfigPromise: ${promise}`); + } + if (!this.pdfDocument) { + return; + } + if (!this._optionalContentConfigPromise) { + return; + } + this._optionalContentConfigPromise = promise; + this.refresh(false, { + optionalContentConfigPromise: promise + }); + this.eventBus.dispatch("optionalcontentconfigchanged", { + source: this, + promise + }); + } + get scrollMode() { + return this._scrollMode; + } + set scrollMode(mode) { + if (this._scrollMode === mode) { + return; + } + if (!isValidScrollMode(mode)) { + throw new Error(`Invalid scroll mode: ${mode}`); + } + if (this.pagesCount > PagesCountLimit.FORCE_SCROLL_MODE_PAGE) { + return; + } + this._previousScrollMode = this._scrollMode; + this._scrollMode = mode; + this.eventBus.dispatch("scrollmodechanged", { + source: this, + mode + }); + this._updateScrollMode(this._currentPageNumber); + } + _updateScrollMode(pageNumber = null) { + const scrollMode = this._scrollMode, + viewer = this.viewer; + viewer.classList.toggle("scrollHorizontal", scrollMode === ScrollMode.HORIZONTAL); + viewer.classList.toggle("scrollWrapped", scrollMode === ScrollMode.WRAPPED); + if (!this.pdfDocument || !pageNumber) { + return; + } + if (scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + } else if (this._previousScrollMode === ScrollMode.PAGE) { + this._updateSpreadMode(); + } + if (this._currentScaleValue && isNaN(this._currentScaleValue)) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this._setCurrentPageNumber(pageNumber, true); + this.update(); + } + get spreadMode() { + return this._spreadMode; + } + set spreadMode(mode) { + if (this._spreadMode === mode) { + return; + } + if (!isValidSpreadMode(mode)) { + throw new Error(`Invalid spread mode: ${mode}`); + } + this._spreadMode = mode; + this.eventBus.dispatch("spreadmodechanged", { + source: this, + mode + }); + this._updateSpreadMode(this._currentPageNumber); + } + _updateSpreadMode(pageNumber = null) { + if (!this.pdfDocument) { + return; + } + const viewer = this.viewer, + pages = this._pages; + if (this._scrollMode === ScrollMode.PAGE) { + this.#ensurePageViewVisible(); + } else { + viewer.textContent = ""; + if (this._spreadMode === SpreadMode.NONE) { + for (const pageView of this._pages) { + viewer.append(pageView.div); + } + } else { + const parity = this._spreadMode - 1; + let spread = null; + for (let i = 0, ii = pages.length; i < ii; ++i) { + if (spread === null) { + spread = document.createElement("div"); + spread.className = "spread"; + viewer.append(spread); + } else if (i % 2 === parity) { + spread = spread.cloneNode(false); + viewer.append(spread); + } + spread.append(pages[i].div); + } + } + } + if (!pageNumber) { + return; + } + if (this._currentScaleValue && isNaN(this._currentScaleValue)) { + this.#setScale(this._currentScaleValue, { + noScroll: true + }); + } + this._setCurrentPageNumber(pageNumber, true); + this.update(); + } + _getPageAdvance(currentPageNumber, previous = false) { + switch (this._scrollMode) { + case ScrollMode.WRAPPED: + { + const { + views + } = this._getVisiblePages(), + pageLayout = new Map(); + for (const { + id, + y, + percent, + widthPercent + } of views) { + if (percent === 0 || widthPercent < 100) { + continue; + } + let yArray = pageLayout.get(y); + if (!yArray) { + pageLayout.set(y, yArray ||= []); + } + yArray.push(id); + } + for (const yArray of pageLayout.values()) { + const currentIndex = yArray.indexOf(currentPageNumber); + if (currentIndex === -1) { + continue; + } + const numPages = yArray.length; + if (numPages === 1) { + break; + } + if (previous) { + for (let i = currentIndex - 1, ii = 0; i >= ii; i--) { + const currentId = yArray[i], + expectedId = yArray[i + 1] - 1; + if (currentId < expectedId) { + return currentPageNumber - expectedId; + } + } + } else { + for (let i = currentIndex + 1, ii = numPages; i < ii; i++) { + const currentId = yArray[i], + expectedId = yArray[i - 1] + 1; + if (currentId > expectedId) { + return expectedId - currentPageNumber; + } + } + } + if (previous) { + const firstId = yArray[0]; + if (firstId < currentPageNumber) { + return currentPageNumber - firstId + 1; + } + } else { + const lastId = yArray[numPages - 1]; + if (lastId > currentPageNumber) { + return lastId - currentPageNumber + 1; + } + } + break; + } + break; + } + case ScrollMode.HORIZONTAL: + { + break; + } + case ScrollMode.PAGE: + case ScrollMode.VERTICAL: + { + if (this._spreadMode === SpreadMode.NONE) { + break; + } + const parity = this._spreadMode - 1; + if (previous && currentPageNumber % 2 !== parity) { + break; + } else if (!previous && currentPageNumber % 2 === parity) { + break; + } + const { + views + } = this._getVisiblePages(), + expectedId = previous ? currentPageNumber - 1 : currentPageNumber + 1; + for (const { + id, + percent, + widthPercent + } of views) { + if (id !== expectedId) { + continue; + } + if (percent > 0 && widthPercent === 100) { + return 2; + } + break; + } + break; + } + } + return 1; + } + nextPage() { + const currentPageNumber = this._currentPageNumber, + pagesCount = this.pagesCount; + if (currentPageNumber >= pagesCount) { + return false; + } + const advance = this._getPageAdvance(currentPageNumber, false) || 1; + this.currentPageNumber = Math.min(currentPageNumber + advance, pagesCount); + return true; + } + previousPage() { + const currentPageNumber = this._currentPageNumber; + if (currentPageNumber <= 1) { + return false; + } + const advance = this._getPageAdvance(currentPageNumber, true) || 1; + this.currentPageNumber = Math.max(currentPageNumber - advance, 1); + return true; + } + updateScale({ + drawingDelay, + scaleFactor = null, + steps = null, + origin + }) { + if (steps === null && scaleFactor === null) { + throw new Error("Invalid updateScale options: either `steps` or `scaleFactor` must be provided."); + } + if (!this.pdfDocument) { + return; + } + let newScale = this._currentScale; + if (scaleFactor > 0 && scaleFactor !== 1) { + newScale = Math.round(newScale * scaleFactor * 100) / 100; + } else if (steps) { + const delta = steps > 0 ? DEFAULT_SCALE_DELTA : 1 / DEFAULT_SCALE_DELTA; + const round = steps > 0 ? Math.ceil : Math.floor; + steps = Math.abs(steps); + do { + newScale = round((newScale * delta).toFixed(2) * 10) / 10; + } while (--steps > 0); + } + newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); + this.#setScale(newScale, { + noScroll: false, + drawingDelay, + origin + }); + } + increaseScale(options = {}) { + this.updateScale({ + ...options, + steps: options.steps ?? 1 + }); + } + decreaseScale(options = {}) { + this.updateScale({ + ...options, + steps: -(options.steps ?? 1) + }); + } + #updateContainerHeightCss(height = this.container.clientHeight) { + if (height !== this.#previousContainerHeight) { + this.#previousContainerHeight = height; + docStyle.setProperty("--viewer-container-height", `${height}px`); + } + } + #resizeObserverCallback(entries) { + for (const entry of entries) { + if (entry.target === this.container) { + this.#updateContainerHeightCss(Math.floor(entry.borderBoxSize[0].blockSize)); + this.#containerTopLeft = null; + break; + } + } + } + get containerTopLeft() { + return this.#containerTopLeft ||= [this.container.offsetTop, this.container.offsetLeft]; + } + #cleanupSwitchAnnotationEditorMode() { + this.#switchAnnotationEditorModeAC?.abort(); + this.#switchAnnotationEditorModeAC = null; + if (this.#switchAnnotationEditorModeTimeoutId !== null) { + clearTimeout(this.#switchAnnotationEditorModeTimeoutId); + this.#switchAnnotationEditorModeTimeoutId = null; + } + } + get annotationEditorMode() { + return this.#annotationEditorUIManager ? this.#annotationEditorMode : AnnotationEditorType.DISABLE; + } + set annotationEditorMode({ + mode, + editId = null, + isFromKeyboard = false + }) { + if (!this.#annotationEditorUIManager) { + throw new Error(`The AnnotationEditor is not enabled.`); + } + if (this.#annotationEditorMode === mode) { + return; + } + if (!isValidAnnotationEditorMode(mode)) { + throw new Error(`Invalid AnnotationEditor mode: ${mode}`); + } + if (!this.pdfDocument) { + return; + } + if (mode === AnnotationEditorType.STAMP) { + this.#mlManager?.loadModel("altText"); + } + const { + eventBus + } = this; + const updater = () => { + this.#cleanupSwitchAnnotationEditorMode(); + this.#annotationEditorMode = mode; + this.#annotationEditorUIManager.updateMode(mode, editId, isFromKeyboard); + eventBus.dispatch("annotationeditormodechanged", { + source: this, + mode + }); + }; + if (mode === AnnotationEditorType.NONE || this.#annotationEditorMode === AnnotationEditorType.NONE) { + const isEditing = mode !== AnnotationEditorType.NONE; + if (!isEditing) { + this.pdfDocument.annotationStorage.resetModifiedIds(); + } + for (const pageView of this._pages) { + pageView.toggleEditingMode(isEditing); + } + const idsToRefresh = this.#switchToEditAnnotationMode(); + if (isEditing && idsToRefresh) { + this.#cleanupSwitchAnnotationEditorMode(); + this.#switchAnnotationEditorModeAC = new AbortController(); + const signal = AbortSignal.any([this.#eventAbortController.signal, this.#switchAnnotationEditorModeAC.signal]); + eventBus._on("pagerendered", ({ + pageNumber + }) => { + idsToRefresh.delete(pageNumber); + if (idsToRefresh.size === 0) { + this.#switchAnnotationEditorModeTimeoutId = setTimeout(updater, 0); + } + }, { + signal + }); + return; + } + } + updater(); + } + refresh(noUpdate = false, updateArgs = Object.create(null)) { + if (!this.pdfDocument) { + return; + } + for (const pageView of this._pages) { + pageView.update(updateArgs); + } + if (this.#scaleTimeoutId !== null) { + clearTimeout(this.#scaleTimeoutId); + this.#scaleTimeoutId = null; + } + if (!noUpdate) { + this.update(); + } + } +} + +;// ./web/pdf_single_page_viewer.js + + +class PDFSinglePageViewer extends PDFViewer { + _resetView() { + super._resetView(); + this._scrollMode = ScrollMode.PAGE; + this._spreadMode = SpreadMode.NONE; + } + set scrollMode(mode) {} + _updateScrollMode() {} + set spreadMode(mode) {} + _updateSpreadMode() {} +} + +;// ./web/pdf_viewer.component.js + + + + + + + + + + + + + + + +const pdfjsVersion = "4.10.38"; +const pdfjsBuild = "f9bea397f"; + +var __webpack_exports__AnnotationLayerBuilder = __webpack_exports__.AnnotationLayerBuilder; +var __webpack_exports__DownloadManager = __webpack_exports__.DownloadManager; +var __webpack_exports__EventBus = __webpack_exports__.EventBus; +var __webpack_exports__FindState = __webpack_exports__.FindState; +var __webpack_exports__GenericL10n = __webpack_exports__.GenericL10n; +var __webpack_exports__LinkTarget = __webpack_exports__.LinkTarget; +var __webpack_exports__PDFFindController = __webpack_exports__.PDFFindController; +var __webpack_exports__PDFHistory = __webpack_exports__.PDFHistory; +var __webpack_exports__PDFLinkService = __webpack_exports__.PDFLinkService; +var __webpack_exports__PDFPageView = __webpack_exports__.PDFPageView; +var __webpack_exports__PDFScriptingManager = __webpack_exports__.PDFScriptingManager; +var __webpack_exports__PDFSinglePageViewer = __webpack_exports__.PDFSinglePageViewer; +var __webpack_exports__PDFViewer = __webpack_exports__.PDFViewer; +var __webpack_exports__ProgressBar = __webpack_exports__.ProgressBar; +var __webpack_exports__RenderingStates = __webpack_exports__.RenderingStates; +var __webpack_exports__ScrollMode = __webpack_exports__.ScrollMode; +var __webpack_exports__SimpleLinkService = __webpack_exports__.SimpleLinkService; +var __webpack_exports__SpreadMode = __webpack_exports__.SpreadMode; +var __webpack_exports__StructTreeLayerBuilder = __webpack_exports__.StructTreeLayerBuilder; +var __webpack_exports__TextLayerBuilder = __webpack_exports__.TextLayerBuilder; +var __webpack_exports__XfaLayerBuilder = __webpack_exports__.XfaLayerBuilder; +var __webpack_exports__parseQueryString = __webpack_exports__.parseQueryString; +export { __webpack_exports__AnnotationLayerBuilder as AnnotationLayerBuilder, __webpack_exports__DownloadManager as DownloadManager, __webpack_exports__EventBus as EventBus, __webpack_exports__FindState as FindState, __webpack_exports__GenericL10n as GenericL10n, __webpack_exports__LinkTarget as LinkTarget, __webpack_exports__PDFFindController as PDFFindController, __webpack_exports__PDFHistory as PDFHistory, __webpack_exports__PDFLinkService as PDFLinkService, __webpack_exports__PDFPageView as PDFPageView, __webpack_exports__PDFScriptingManager as PDFScriptingManager, __webpack_exports__PDFSinglePageViewer as PDFSinglePageViewer, __webpack_exports__PDFViewer as PDFViewer, __webpack_exports__ProgressBar as ProgressBar, __webpack_exports__RenderingStates as RenderingStates, __webpack_exports__ScrollMode as ScrollMode, __webpack_exports__SimpleLinkService as SimpleLinkService, __webpack_exports__SpreadMode as SpreadMode, __webpack_exports__StructTreeLayerBuilder as StructTreeLayerBuilder, __webpack_exports__TextLayerBuilder as TextLayerBuilder, __webpack_exports__XfaLayerBuilder as XfaLayerBuilder, __webpack_exports__parseQueryString as parseQueryString }; + +//# sourceMappingURL=pdf_viewer.mjs.map \ No newline at end of file diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/README.md new file mode 100644 index 00000000000..69fb53bf322 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/README.md @@ -0,0 +1,7 @@ +tailwindcss version 4.0.3 +https://github.com/tailwindlabs/tailwindcss +License: MIT + +This template uses only `preflight.css`, the CSS reset stylesheet from Tailwind. For simplicity, this template doesn't use a complete deployment of Tailwind. If you want to use the full Tailwind library, remove `preflight.css` and reference the full version of Tailwind. + +To update, replace the `preflight.css` file with the one in an updated build from https://www.npmjs.com/package/tailwindcss diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/dist/preflight.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/dist/preflight.css new file mode 100644 index 00000000000..178c881dd23 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/wwwroot/lib/tailwindcss/dist/preflight.css @@ -0,0 +1,383 @@ +/* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. +*/ + +*, +::after, +::before, +::backdrop, +::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ +} + +/* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured `sans` font-family by default. + 5. Use the user's configured `sans` font-feature-settings by default. + 6. Use the user's configured `sans` font-variation-settings by default. + 7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: var( + --default-font-family, + ui-sans-serif, + system-ui, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ + font-feature-settings: var(--default-font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--default-font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* + Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + line-height: inherit; +} + +/* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* + Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* + Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* + Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; +} + +/* + Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* + 1. Use the user's configured `mono` font-family by default. + 2. Use the user's configured `mono` font-feature-settings by default. + 3. Use the user's configured `mono` font-variation-settings by default. + 4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: var( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 4 */ + font-feature-settings: var(--default-mono-font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--default-mono-font-variation-settings, normal); /* 6 */ + font-size: 1em; /* 4 */ +} + +/* + Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* + Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* + Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* + Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* + Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* + Make lists unstyled by default. +*/ + +ol, +ul, +menu { + list-style: none; +} + +/* + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* + 1. Inherit font styles in all browsers. + 2. Remove border radius in all browsers. + 3. Remove background color in all browsers. + 4. Ensure consistent opacity for disabled states in all browsers. +*/ + +button, +input, +select, +optgroup, +textarea, +::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + border-radius: 0; /* 2 */ + background-color: transparent; /* 3 */ + opacity: 1; /* 4 */ +} + +/* + Restore default font weight. +*/ + +:where(select:is([multiple], [size])) optgroup { + font-weight: bolder; +} + +/* + Restore indentation. +*/ + +:where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; +} + +/* + Restore space after button. +*/ + +::file-selector-button { + margin-inline-end: 4px; +} + +/* + 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) + 2. Set the default placeholder color to a semi-transparent version of the current text color. +*/ + +::placeholder { + opacity: 1; /* 1 */ + color: color-mix(in oklab, currentColor 50%, transparent); /* 2 */ +} + +/* + Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* + Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* + 1. Ensure date/time inputs have the same height when empty in iOS Safari. + 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. +*/ + +::-webkit-date-and-time-value { + min-height: 1lh; /* 1 */ + text-align: inherit; /* 2 */ +} + +/* + Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. +*/ + +::-webkit-datetime-edit { + display: inline-flex; +} + +/* + Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. +*/ + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-datetime-edit, +::-webkit-datetime-edit-year-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-minute-field, +::-webkit-datetime-edit-second-field, +::-webkit-datetime-edit-millisecond-field, +::-webkit-datetime-edit-meridiem-field { + padding-block: 0; +} + +/* + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* + Correct the inability to style the border radius in iOS Safari. +*/ + +button, +input:where([type='button'], [type='reset'], [type='submit']), +::file-selector-button { + appearance: button; +} + +/* + Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* + Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden]:where(:not([hidden='until-found'])) { + display: none !important; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.sln b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.sln new file mode 100644 index 00000000000..67d2a3cad3c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.AppHost", "aichatweb.AppHost\aichatweb.AppHost.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.ServiceDefaults", "aichatweb.ServiceDefaults\aichatweb.ServiceDefaults.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.Web", "aichatweb.Web\aichatweb.Web.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor index 5e4f8042add..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } @@ -108,7 +114,7 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + $"{result.Text}"); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs index 4c007e3be1a..434e5662d6d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs @@ -1,15 +1,10 @@ -using Microsoft.EntityFrameworkCore; +using System.ClientModel; +using Azure.Identity; using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using OpenAI; using aichatweb.Components; using aichatweb.Services; using aichatweb.Services.Ingestion; -using Azure; -using Azure.Identity; -using OpenAI; -using System.ClientModel; -using Azure.Search.Documents.Indexes; -using Microsoft.SemanticKernel.Connectors.AzureAISearch; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -18,31 +13,32 @@ // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set OpenAI:Key YOUR-API-KEY + var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -var chatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); + +#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning restore OPENAI001 + var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set AzureAISearch:Endpoint https://YOUR-DEPLOYMENT-NAME.search.windows.net -var vectorStore = new AzureAISearchVectorStore( - new SearchIndexClient( - new Uri(builder.Configuration["AzureAISearch:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Endpoint. See the README for details.")), - new DefaultAzureCredential())); +var azureAISearchEndpoint = new Uri(builder.Configuration["AzureAISearch:Endpoint"] + ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Endpoint. See the README for details.")); +var azureAISearchCredential = new DefaultAzureCredential(); +builder.Services.AddAzureAISearchCollection("data-aichatweb-chunks", azureAISearchEndpoint, azureAISearchCredential); +builder.Services.AddAzureAISearchCollection("data-aichatweb-documents", azureAISearchEndpoint, azureAISearchCredential); -builder.Services.AddSingleton(vectorStore); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); builder.Services.AddEmbeddingGenerator(embeddingGenerator); -builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=ingestioncache.db")); - var app = builder.Build(); -IngestionCacheDbContext.Initialize(app.Services); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md index 53730a18884..73375f14cfa 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md @@ -5,6 +5,9 @@ This project is an AI chat application that demonstrates how to chat with custom >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +### Prerequisites +To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). + # Configure the AI Model Provider ## Using OpenAI @@ -24,7 +27,7 @@ To use Azure AI Search, you will need an Azure account and an Azure AI Search re ### 1. Create an Azure AI Search Resource Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-aichatweb-ingested` index using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. +Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-aichatweb-chunks` and `data-aichatweb-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. ### 2. Configure Azure AI Search for Keyless Authentication This template is configured to use keyless authentication (also known as Managed Identity, with Entra ID). Before continuing, you'll need to configure your Azure AI Search resource to support this. [Learn more](https://learn.microsoft.com/azure/search/keyless-connections). After creation, ensure that you have selected Role-Based Access Control (RBAC) under Settings > Keys, as this is not the default. Assign yourself the roles called out for local development. [Learn more](https://learn.microsoft.com/azure/search/keyless-connections#roles-for-local-development). diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/IngestedChunk.cs new file mode 100644 index 00000000000..46270588cde --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/IngestedChunk.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedChunk +{ + private const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string DocumentId { get; set; } + + [VectorStoreData] + public int PageNumber { get; set; } + + [VectorStoreData] + public required string Text { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/IngestedDocument.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/IngestedDocument.cs new file mode 100644 index 00000000000..9b3da6058c9 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/IngestedDocument.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedDocument +{ + private const int VectorDimensions = 2; + private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; + + [VectorStoreKey] + public required string Key { get; set; } + + [VectorStoreData(IsIndexed = true)] + public required string SourceId { get; set; } + + [VectorStoreData] + public required string DocumentId { get; set; } + + [VectorStoreData] + public required string DocumentVersion { get; set; } + + // The vector is not used but required for some vector databases + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction)] + public ReadOnlyMemory Vector { get; set; } = new ReadOnlyMemory([0, 0]); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs index d18307ead2b..89fe287ebed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -1,14 +1,12 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace aichatweb.Services.Ingestion; public class DataIngestor( ILogger logger, - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore, - IngestionCacheDbContext ingestionCacheDb) + VectorStoreCollection chunksCollection, + VectorStoreCollection documentsCollection) { public static async Task IngestDataAsync(IServiceProvider services, IIngestionSource source) { @@ -19,45 +17,42 @@ public static async Task IngestDataAsync(IServiceProvider services, IIngestionSo public async Task IngestDataAsync(IIngestionSource source) { - var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested"); - await vectorCollection.CreateCollectionIfNotExistsAsync(); + await chunksCollection.EnsureCollectionExistsAsync(); + await documentsCollection.EnsureCollectionExistsAsync(); - var documentsForSource = ingestionCacheDb.Documents - .Where(d => d.SourceId == source.SourceId) - .Include(d => d.Records); + var sourceId = source.SourceId; + var documentsForSource = await documentsCollection.GetAsync(doc => doc.SourceId == sourceId, top: int.MaxValue).ToListAsync(); - var deletedFiles = await source.GetDeletedDocumentsAsync(documentsForSource); - foreach (var deletedFile in deletedFiles) + var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); + foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {file}", deletedFile.Id); - await vectorCollection.DeleteBatchAsync(deletedFile.Records.Select(r => r.Id)); - ingestionCacheDb.Documents.Remove(deletedFile); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); + await DeleteChunksForDocumentAsync(deletedDocument); + await documentsCollection.DeleteAsync(deletedDocument.Key); } - await ingestionCacheDb.SaveChangesAsync(); - var modifiedDocs = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); - foreach (var modifiedDoc in modifiedDocs) + var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); + foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {file}", modifiedDoc.Id); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); + await DeleteChunksForDocumentAsync(modifiedDocument); - if (modifiedDoc.Records.Count > 0) - { - await vectorCollection.DeleteBatchAsync(modifiedDoc.Records.Select(r => r.Id)); - } + await documentsCollection.UpsertAsync(modifiedDocument); - var newRecords = await source.CreateRecordsForDocumentAsync(embeddingGenerator, modifiedDoc.Id); - await foreach (var id in vectorCollection.UpsertBatchAsync(newRecords)) { } + var newRecords = await source.CreateChunksForDocumentAsync(modifiedDocument); + await chunksCollection.UpsertAsync(newRecords); + } - modifiedDoc.Records.Clear(); - modifiedDoc.Records.AddRange(newRecords.Select(r => new IngestedRecord { Id = r.Key, DocumentId = modifiedDoc.Id })); + logger.LogInformation("Ingestion is up-to-date"); - if (ingestionCacheDb.Entry(modifiedDoc).State == EntityState.Detached) + async Task DeleteChunksForDocumentAsync(IngestedDocument document) + { + var documentId = document.DocumentId; + var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); + if (chunksToDelete.Count != 0) { - ingestionCacheDb.Documents.Add(modifiedDoc); + await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } } - - await ingestionCacheDb.SaveChangesAsync(); - logger.LogInformation("Ingestion is up-to-date"); } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IIngestionSource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IIngestionSource.cs index 298f31190a3..540cac117e7 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IIngestionSource.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IIngestionSource.cs @@ -1,14 +1,12 @@ -using Microsoft.Extensions.AI; - -namespace aichatweb.Services.Ingestion; +namespace aichatweb.Services.Ingestion; public interface IIngestionSource { string SourceId { get; } - Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments); + Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments); - Task> GetDeletedDocumentsAsync(IQueryable existingDocuments); + Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments); - Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId); + Task> CreateChunksForDocumentAsync(IngestedDocument document); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IngestionCacheDbContext.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IngestionCacheDbContext.cs deleted file mode 100644 index 1d77efbb58c..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/IngestionCacheDbContext.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace aichatweb.Services.Ingestion; - -// A DbContext that keeps track of which documents have been ingested. -// This makes it possible to avoid re-ingesting documents that have not changed, -// and to delete documents that have been removed from the underlying source. -public class IngestionCacheDbContext : DbContext -{ - public IngestionCacheDbContext(DbContextOptions options) : base(options) - { - } - - public DbSet Documents { get; set; } = default!; - public DbSet Records { get; set; } = default!; - - public static void Initialize(IServiceProvider services) - { - using var scope = services.CreateScope(); - using var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.Entity().HasMany(d => d.Records).WithOne().HasForeignKey(r => r.DocumentId).OnDelete(DeleteBehavior.Cascade); - } -} - -public class IngestedDocument -{ - // TODO: Make Id+SourceId a composite key - public required string Id { get; set; } - public required string SourceId { get; set; } - public required string Version { get; set; } - public List Records { get; set; } = []; -} - -public class IngestedRecord -{ - public required string Id { get; set; } - public required string DocumentId { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs index 9072a9c2b40..0be02a9d008 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/PDFDirectorySource.cs @@ -1,68 +1,58 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.SemanticKernel.Text; -using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; -using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; +using Microsoft.SemanticKernel.Text; using UglyToad.PdfPig; -using Microsoft.Extensions.AI; using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; namespace aichatweb.Services.Ingestion; public class PDFDirectorySource(string sourceDirectory) : IIngestionSource { public static string SourceFileId(string path) => Path.GetFileName(path); + public static string SourceFileVersion(string path) => File.GetLastWriteTimeUtc(path).ToString("o"); public string SourceId => $"{nameof(PDFDirectorySource)}:{sourceDirectory}"; - public async Task> GetNewOrModifiedDocumentsAsync(IQueryable existingDocuments) + public Task> GetNewOrModifiedDocumentsAsync(IReadOnlyList existingDocuments) { var results = new List(); var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var existingDocumentsById = existingDocuments.ToDictionary(d => d.DocumentId); foreach (var sourceFile in sourceFiles) { var sourceFileId = SourceFileId(sourceFile); - var sourceFileVersion = File.GetLastWriteTimeUtc(sourceFile).ToString("o"); - - var existingDocument = await existingDocuments.Where(d => d.SourceId == SourceId && d.Id == sourceFileId).FirstOrDefaultAsync(); - if (existingDocument is null) + var sourceFileVersion = SourceFileVersion(sourceFile); + var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; + if (existingDocumentVersion != sourceFileVersion) { - results.Add(new() { Id = sourceFileId, Version = sourceFileVersion, SourceId = SourceId }); - } - else if (existingDocument.Version != sourceFileVersion) - { - existingDocument.Version = sourceFileVersion; - results.Add(existingDocument); + results.Add(new() { Key = Guid.CreateVersion7().ToString(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); } } - return results; + return Task.FromResult((IEnumerable)results); } - public async Task> GetDeletedDocumentsAsync(IQueryable existingDocuments) + public Task> GetDeletedDocumentsAsync(IReadOnlyList existingDocuments) { - var sourceFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); - var sourceFileIds = sourceFiles.Select(SourceFileId).ToList(); - return await existingDocuments - .Where(d => !sourceFileIds.Contains(d.Id)) - .ToListAsync(); + var currentFiles = Directory.GetFiles(sourceDirectory, "*.pdf"); + var currentFileIds = currentFiles.ToLookup(SourceFileId); + var deletedDocuments = existingDocuments.Where(d => !currentFileIds.Contains(d.DocumentId)); + return Task.FromResult(deletedDocuments); } - public async Task> CreateRecordsForDocumentAsync(IEmbeddingGenerator> embeddingGenerator, string documentId) + public Task> CreateChunksForDocumentAsync(IngestedDocument document) { - using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, documentId)); + using var pdf = PdfDocument.Open(Path.Combine(sourceDirectory, document.DocumentId)); var paragraphs = pdf.GetPages().SelectMany(GetPageParagraphs).ToList(); - var embeddings = await embeddingGenerator.GenerateAsync(paragraphs.Select(c => c.Text)); - - return paragraphs.Zip(embeddings).Select((pair, index) => new SemanticSearchRecord + return Task.FromResult(paragraphs.Select(p => new IngestedChunk { - Key = $"{Path.GetFileNameWithoutExtension(documentId)}_{pair.First.PageNumber}_{pair.First.IndexOnPage}", - FileName = documentId, - PageNumber = pair.First.PageNumber, - Text = pair.First.Text, - Vector = pair.Second.Vector, - }); + Key = Guid.CreateVersion7().ToString(), + DocumentId = document.DocumentId, + PageNumber = p.PageNumber, + Text = p.Text, + })); } private static IEnumerable<(int PageNumber, int IndexOnPage, string Text)> GetPageParagraphs(Page pdfPage) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearch.cs index 1ac3977d014..291c6c4b4a9 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearch.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearch.cs @@ -1,28 +1,17 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData; namespace aichatweb.Services; public class SemanticSearch( - IEmbeddingGenerator> embeddingGenerator, - IVectorStore vectorStore) + VectorStoreCollection vectorCollection) { - public async Task> SearchAsync(string text, string? filenameFilter, int maxResults) + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) { - var queryEmbedding = await embeddingGenerator.GenerateVectorAsync(text); - var vectorCollection = vectorStore.GetCollection("data-aichatweb-ingested"); - - var nearest = await vectorCollection.VectorizedSearchAsync(queryEmbedding, new VectorSearchOptions + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions { - Top = maxResults, - Filter = filenameFilter is { Length: > 0 } ? record => record.FileName == filenameFilter : null, + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, }); - var results = new List(); - await foreach (var item in nearest.Results) - { - results.Add(item.Record); - } - return results; + return await nearest.Select(result => result.Record).ToListAsync(); } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearchRecord.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearchRecord.cs deleted file mode 100644 index eb37cef61c8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/SemanticSearchRecord.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Services; - -public class SemanticSearchRecord -{ - [VectorStoreRecordKey] - public required string Key { get; set; } - - [VectorStoreRecordData(IsFilterable = true)] - public required string FileName { get; set; } - - [VectorStoreRecordData] - public int PageNumber { get; set; } - - [VectorStoreRecordData] - public required string Text { get; set; } - - [VectorStoreRecordVector(1536, DistanceFunction.CosineSimilarity)] // 1536 is the default vector size for the OpenAI text-embedding-3-small model - public ReadOnlyMemory Vector { get; set; } -} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj index fd7131e492a..da39bf3a4b0 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj @@ -8,15 +8,14 @@ - - - - - - - - - + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/README.md new file mode 100644 index 00000000000..31035a6370e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/README.md @@ -0,0 +1,98 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..1b2dd939947 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,44 @@ + + + + net9.0 + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + Exe + enable + enable + + + true + McpServer + + + true + true + + + true + + + true + true + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md new file mode 100644 index 00000000000..31035a6370e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -0,0 +1,98 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..f6da2d9485e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + Exe + enable + enable + + + true + McpServer + + + true + true + + + true + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/README.md new file mode 100644 index 00000000000..1702211733a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/README.md @@ -0,0 +1,91 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a framework-dependent application and requires the .NET runtime to be installed on the target machine. +The application is configured to roll-forward to the next highest major version of the runtime if one is available on the target machine. +If an applicable .NET runtime is not available, the MCP server will not start. +Consider building the MCP server as a self-contained application if you want to avoid this dependency. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..a25caa73486 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + Major + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/README.md new file mode 100644 index 00000000000..31035a6370e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/README.md @@ -0,0 +1,98 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..393d0558d5e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + Exe + enable + enable + + + true + McpServer + + + true + true + + + true + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs deleted file mode 100644 index 1d2b6caa74e..00000000000 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Schema; -using Xunit; - -namespace Microsoft.Extensions.AI.JsonSchemaExporter; - -public static class JsonSchemaExporterConfigurationTests -{ - [Theory] - [InlineData(false)] - [InlineData(true)] - public static void JsonSchemaExporterOptions_DefaultValues(bool useSingleton) - { - JsonSchemaExporterOptions configuration = useSingleton ? JsonSchemaExporterOptions.Default : new(); - Assert.False(configuration.TreatNullObliviousAsNonNullable); - Assert.Null(configuration.TransformSchemaNode); - } - - [Fact] - public static void JsonSchemaExporterOptions_Singleton_ReturnsSameInstance() - { - Assert.Same(JsonSchemaExporterOptions.Default, JsonSchemaExporterOptions.Default); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public static void JsonSchemaExporterOptions_TreatNullObliviousAsNonNullable(bool treatNullObliviousAsNonNullable) - { - JsonSchemaExporterOptions configuration = new() { TreatNullObliviousAsNonNullable = treatNullObliviousAsNonNullable }; - Assert.Equal(treatNullObliviousAsNonNullable, configuration.TreatNullObliviousAsNonNullable); - } -} diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs deleted file mode 100644 index 70babf81334..00000000000 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Schema; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -#if !NET9_0_OR_GREATER -using System.Xml.Linq; -#endif -using Xunit; -using static Microsoft.Extensions.AI.JsonSchemaExporter.TestTypes; - -#pragma warning disable SA1402 // File may only contain a single type - -namespace Microsoft.Extensions.AI.JsonSchemaExporter; - -public abstract class JsonSchemaExporterTests -{ - protected abstract JsonSerializerOptions Options { get; } - - [Theory] - [MemberData(nameof(TestTypes.GetTestData), MemberType = typeof(TestTypes))] - public void TestTypes_GeneratesExpectedJsonSchema(ITestData testData) - { - JsonSerializerOptions options = testData.Options is { } opts - ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } - : Options; - - JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); - SchemaTestHelpers.AssertEqualJsonSchema(testData.ExpectedJsonSchema, schema); - } - - [Theory] - [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] - public void TestTypes_SerializedValueMatchesGeneratedSchema(ITestData testData) - { - JsonSerializerOptions options = testData.Options is { } opts - ? new(opts) { TypeInfoResolver = Options.TypeInfoResolver } - : Options; - - JsonNode schema = options.GetJsonSchemaAsNode(testData.Type, (JsonSchemaExporterOptions?)testData.ExporterOptions); - JsonNode? instance = JsonSerializer.SerializeToNode(testData.Value, testData.Type, options); - SchemaTestHelpers.AssertDocumentMatchesSchema(schema, instance); - } - - [Theory] - [InlineData(typeof(string), "string")] - [InlineData(typeof(int[]), "array")] - [InlineData(typeof(Dictionary), "object")] - [InlineData(typeof(TestTypes.SimplePoco), "object")] - public void TreatNullObliviousAsNonNullable_True_MarksAllReferenceTypesAsNonNullable(Type referenceType, string expectedType) - { - Assert.True(!referenceType.IsValueType); - var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; - JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); - JsonValue type = Assert.IsAssignableFrom(schema["type"]); - Assert.Equal(expectedType, (string)type!); - } - - [Theory] - [InlineData(typeof(int), "integer")] - [InlineData(typeof(double), "number")] - [InlineData(typeof(bool), "boolean")] - [InlineData(typeof(ImmutableArray), "array")] - [InlineData(typeof(TestTypes.StructDictionary), "object")] - [InlineData(typeof(TestTypes.SimpleRecordStruct), "object")] - public void TreatNullObliviousAsNonNullable_True_DoesNotImpactNonReferenceTypes(Type referenceType, string expectedType) - { - Assert.True(referenceType.IsValueType); - var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; - JsonNode schema = Options.GetJsonSchemaAsNode(referenceType, config); - JsonValue value = Assert.IsAssignableFrom(schema["type"]); - Assert.Equal(expectedType, (string)value!); - } - -#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/108764 gets backported - [Fact] - public void CanGenerateXElementSchema() - { - JsonNode schema = Options.GetJsonSchemaAsNode(typeof(XElement)); - Assert.True(schema.ToJsonString().Length < 100_000); - } -#endif - -#if !NET9_0 // Disable until https://github.com/dotnet/runtime/pull/109954 gets backported - [Fact] - public void TransformSchemaNode_PropertiesWithCustomConverters() - { - // Regression test for https://github.com/dotnet/runtime/issues/109868 - List<(Type? parentType, string? propertyName, Type type)> visitedNodes = new(); - JsonSchemaExporterOptions exporterOptions = new() - { - TransformSchemaNode = (ctx, schema) => - { -#if NET9_0_OR_GREATER - visitedNodes.Add((ctx.PropertyInfo?.DeclaringType, ctx.PropertyInfo?.Name, ctx.TypeInfo.Type)); -#else - visitedNodes.Add((ctx.DeclaringType, ctx.PropertyInfo?.Name, ctx.TypeInfo.Type)); -#endif - return schema; - } - }; - - List<(Type? parentType, string? propertyName, Type type)> expectedNodes = - [ - (typeof(ClassWithPropertiesUsingCustomConverters), "Prop1", typeof(ClassWithPropertiesUsingCustomConverters.ClassWithCustomConverter1)), - (typeof(ClassWithPropertiesUsingCustomConverters), "Prop2", typeof(ClassWithPropertiesUsingCustomConverters.ClassWithCustomConverter2)), - (null, null, typeof(ClassWithPropertiesUsingCustomConverters)), - ]; - - Options.GetJsonSchemaAsNode(typeof(ClassWithPropertiesUsingCustomConverters), exporterOptions); - - Assert.Equal(expectedNodes, visitedNodes); - } -#endif - - [Fact] - public void TreatNullObliviousAsNonNullable_True_DoesNotImpactObjectType() - { - var config = new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true }; - JsonNode schema = Options.GetJsonSchemaAsNode(typeof(object), config); - Assert.False(schema is JsonObject jObj && jObj.ContainsKey("type")); - } - - [Fact] - public void TypeWithDisallowUnmappedMembers_AdditionalPropertiesFailValidation() - { - JsonNode schema = Options.GetJsonSchemaAsNode(typeof(TestTypes.PocoDisallowingUnmappedMembers)); - JsonNode? jsonWithUnmappedProperties = JsonNode.Parse("""{ "UnmappedProperty" : {} }"""); - SchemaTestHelpers.AssertDoesNotMatchSchema(schema, jsonWithUnmappedProperties); - } - - [Fact] - public void GetJsonSchema_NullInputs_ThrowsArgumentNullException() - { - Assert.Throws(() => ((JsonSerializerOptions)null!).GetJsonSchemaAsNode(typeof(int))); - Assert.Throws(() => Options.GetJsonSchemaAsNode(type: null!)); - Assert.Throws(() => ((JsonTypeInfo)null!).GetJsonSchemaAsNode()); - } - - [Fact] - public void GetJsonSchema_NoResolver_ThrowInvalidOperationException() - { - var options = new JsonSerializerOptions(); - Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(int))); - } - - [Fact] - public void MaxDepth_SetToZero_NonTrivialSchema_ThrowsInvalidOperationException() - { - JsonSerializerOptions options = new(Options) { MaxDepth = 1 }; - var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); - Assert.Contains("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting.", ex.Message); - } - - [Fact] - public void ReferenceHandlePreserve_Enabled_ThrowsNotSupportedException() - { - var options = new JsonSerializerOptions(Options) { ReferenceHandler = ReferenceHandler.Preserve }; - options.MakeReadOnly(); - - var ex = Assert.Throws(() => options.GetJsonSchemaAsNode(typeof(TestTypes.SimplePoco))); - Assert.Contains("ReferenceHandler.Preserve", ex.Message); - } -} - -public sealed class ReflectionJsonSchemaExporterTests : JsonSchemaExporterTests -{ - protected override JsonSerializerOptions Options => JsonSerializerOptions.Default; -} - -public sealed class SourceGenJsonSchemaExporterTests : JsonSchemaExporterTests -{ - protected override JsonSerializerOptions Options => TestTypes.TestTypesContext.Default.Options; -} diff --git a/test/Shared/JsonSchemaExporter/TestData.cs b/test/Shared/JsonSchemaExporter/TestData.cs index 26902bfe0db..7c7cc7fc9a7 100644 --- a/test/Shared/JsonSchemaExporter/TestData.cs +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -13,7 +13,9 @@ internal sealed record TestData( T? Value, [StringSyntax(StringSyntaxAttribute.Json)] string ExpectedJsonSchema, IEnumerable? AdditionalValues = null, - object? ExporterOptions = null, +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + System.Text.Json.Schema.JsonSchemaExporterOptions? ExporterOptions = null, +#endif JsonSerializerOptions? Options = null, bool WritesNumbersAsStrings = false) : ITestData @@ -22,7 +24,9 @@ internal sealed record TestData( public Type Type => typeof(T); object? ITestData.Value => Value; +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ITestData.ExporterOptions => ExporterOptions; +#endif JsonNode ITestData.ExpectedJsonSchema { get; } = JsonNode.Parse(ExpectedJsonSchema, documentOptions: _schemaParseOptions) ?? throw new ArgumentNullException("schema must not be null"); @@ -32,7 +36,7 @@ IEnumerable ITestData.GetTestDataForAllValues() yield return this; if (default(T) is null && -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL ExporterOptions is System.Text.Json.Schema.JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable: false } && #endif Value is not null) @@ -58,7 +62,9 @@ public interface ITestData JsonNode ExpectedJsonSchema { get; } +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ExporterOptions { get; } +#endif JsonSerializerOptions? Options { get; } diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 7cfd0ce45be..794e58fa2b8 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -9,12 +9,9 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -#if NET9_0_OR_GREATER -using System.Reflection; -#endif using System.Text.Json; using System.Text.Json.Nodes; -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL using System.Text.Json.Schema; #endif using System.Text.Json.Serialization; @@ -135,6 +132,21 @@ public static IEnumerable GetTestDataCore() } """); +#if !NET9_0 && TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + // Regression test for https://github.com/dotnet/runtime/issues/117493 + yield return new TestData( + Value: 42, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["integer","null"]}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: DateTimeOffset.MinValue, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["string","null"],"format":"date-time"}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); +#endif + // User-defined POCOs yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -152,7 +164,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with nullable types set to non-nullable yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -311,7 +323,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with non-nullable reference types by default. yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, @@ -761,7 +773,7 @@ of the type which points to the first occurrence. */ } """); -#if NET9_0_OR_GREATER +#if TEST yield return new TestData( Value: new("string", -1), ExpectedJsonSchema: """ @@ -1164,7 +1176,7 @@ public readonly struct StructDictionary(IEnumerable _dictionary.Count; public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); -#if NETCOREAPP +#if NET public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetValue(key, out value); #else public bool TryGetValue(TKey key, out TValue value) => _dictionary.TryGetValue(key, out value); @@ -1249,6 +1261,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(IntEnum?))] [JsonSerializable(typeof(StringEnum?))] [JsonSerializable(typeof(SimpleRecordStruct?))] + [JsonSerializable(typeof(DateTimeOffset?))] // User-defined POCOs [JsonSerializable(typeof(SimplePoco))] [JsonSerializable(typeof(SimpleRecord))] @@ -1299,22 +1312,4 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(StructDictionary))] [JsonSerializable(typeof(XElement))] public partial class TestTypesContext : JsonSerializerContext; - -#if NET9_0_OR_GREATER - private static TAttribute? ResolveAttribute(this JsonSchemaExporterContext ctx) - where TAttribute : Attribute - { - // Resolve attributes from locations in the following order: - // 1. Property-level attributes - // 2. Parameter-level attributes and - // 3. Type-level attributes. - return - GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? - GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? - GetAttrs(ctx.TypeInfo.Type); - - static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => - (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); - } -#endif } diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index b7e27306f2a..2764d5f5d5d 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -2,7 +2,6 @@ Microsoft.Shared.Test Unit tests for Microsoft.Shared - $(DefineConstants);TESTS_JSON_SCHEMA_EXPORTER_POLYFILL diff --git a/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs b/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs index b1e53b8ed77..e30b5206c8c 100644 --- a/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs +++ b/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs @@ -63,9 +63,21 @@ protected override IEnumerable CreateTestCasesForDataRow(ITestFr } } - return skipReason != null ? - base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason) - : base.CreateTestCasesForDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow); + if (skipReason != null) + { + return base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason); + } + + // Create test cases that can handle runtime SkipTestException + return new[] + { + new SkippedTheoryTestCase( + DiagnosticMessageSink, + discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), + testMethod, + dataRow) + }; } protected override IEnumerable CreateTestCasesForSkippedDataRow( diff --git a/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs b/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs new file mode 100644 index 00000000000..45a54409047 --- /dev/null +++ b/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; + +namespace Microsoft.TestUtilities; + +/// +/// Skips a test based on the value of an environment variable. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class EnvironmentVariableConditionAttribute : Attribute, ITestCondition +{ + private string? _currentValue; + + /// + /// Initializes a new instance of the class. + /// + /// Name of the environment variable. + /// Value(s) of the environment variable to match for the condition. + /// + /// By default, the test will be run if the value of the variable matches any of the supplied values. + /// Set to False to run the test only if the value does not match. + /// + public EnvironmentVariableConditionAttribute(string variableName, params string[] values) + { + if (string.IsNullOrEmpty(variableName)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(variableName)); + } + + if (values == null || values.Length == 0) + { + throw new ArgumentException("You must supply at least one value to match.", nameof(values)); + } + + VariableName = variableName; + Values = values; + } + + /// + /// Gets or sets a value indicating whether the test should run if the value of the variable matches any + /// of the supplied values. If False, the test runs only if the value does not match any of the + /// supplied values. Default is True. + /// + public bool RunOnMatch { get; set; } = true; + + /// + /// Gets the name of the environment variable. + /// + public string VariableName { get; } + + /// + /// Gets the value(s) of the environment variable to match for the condition. + /// + public string[] Values { get; } + + /// + /// Gets a value indicating whether the condition is met for the configured environment variable and values. + /// + public bool IsMet + { + get + { + _currentValue ??= Environment.GetEnvironmentVariable(VariableName); + var hasMatched = Values.Any(value => string.Equals(value, _currentValue, StringComparison.OrdinalIgnoreCase)); + + return RunOnMatch ? hasMatched : !hasMatched; + } + } + + /// + /// Gets a value indicating the reason the test was skipped. + /// + public string SkipReason + { + get + { + var value = _currentValue ?? "(null)"; + + return $"Test skipped on environment variable with name '{VariableName}' and value '{value}' " + + $"for the '{nameof(RunOnMatch)}' value of '{RunOnMatch}'."; + } + } +} diff --git a/test/TestUtilities/XUnit/OSSkipConditionAttribute.cs b/test/TestUtilities/XUnit/OSSkipConditionAttribute.cs index 143cd7005ad..586b53d3fcb 100644 --- a/test/TestUtilities/XUnit/OSSkipConditionAttribute.cs +++ b/test/TestUtilities/XUnit/OSSkipConditionAttribute.cs @@ -60,7 +60,7 @@ private static OperatingSystems GetCurrentOS() throw new PlatformNotSupportedException(); #else - // RuntimeInformation API is only avaialble in .NET Framework 4.7.1+ + // RuntimeInformation API is only available in .NET Framework 4.7.1+ // .NET Framework 4.7 and below can only run on Windows. return OperatingSystems.Windows; #endif diff --git a/test/TestUtilities/XUnit/SkippedTheoryTestCase.cs b/test/TestUtilities/XUnit/SkippedTheoryTestCase.cs new file mode 100644 index 00000000000..e91a8f762d5 --- /dev/null +++ b/test/TestUtilities/XUnit/SkippedTheoryTestCase.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +/// +/// A test case for ConditionalTheory that can handle runtime SkipTestException +/// by wrapping the message bus with SkippedTestMessageBus. +/// +public class SkippedTheoryTestCase : XunitTestCase +{ + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes", error: true)] + public SkippedTheoryTestCase() + { + } + + public SkippedTheoryTestCase( + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + object[]? testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + } + + public override async Task RunAsync(IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + { + using SkippedTestMessageBus skipMessageBus = new(messageBus); + var result = await base.RunAsync(diagnosticMessageSink, skipMessageBus, constructorArguments, aggregator, cancellationTokenSource); + if (skipMessageBus.SkippedTestCount > 0) + { + result.Failed -= skipMessageBus.SkippedTestCount; + result.Skipped += skipMessageBus.SkippedTestCount; + } + + return result; + } +} \ No newline at end of file