diff --git a/.editorconfig b/.editorconfig index a2aa681..973ef9f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,21 +1,178 @@ +root = true + +############################################################ +# Basic file-type formatting +############################################################ + +[*.{json,yaml,yml}] +indent_size = 2 + +[*.md] +indent_size = 2 + +[*.{resx,ruleset,stylecop,xml,xsd,xsl}] +indent_size = 2 + +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.tsv] +indent_style = tab + +############################################################ +# Standard formatting for C# and project files +############################################################ + +[*.{cs,csproj}] +indent_style = space +indent_size = 4 +charset = utf-8 +end_of_line = crlf +insert_final_newline = true + +############################################################ +# C# / .NET defaults - consolidated for clarity +############################################################ + +[*.cs] + # Set severity for all analyzers that are enabled by default (https://docs.microsoft.com/en-us/visualstudio/code-quality/use-roslyn-analyzers?view=vs-2022#set-rule-severity-of-multiple-analyzer-rules-at-once-in-an-editorconfig-file) dotnet_analyzer_diagnostic.category-roslynator.severity = default|none|silent|suggestion|warning|error # Enable/disable all analyzers by default # NOTE: This option can be used only in .roslynatorconfig file -roslynator_analyzers.enabled_by_default = true #|false +roslynator_analyzers.enabled_by_default = true # Set severity for a specific analyzer # dotnet_diagnostic..severity = default|none|silent|suggestion|warning|error # Enable/disable all refactorings -roslynator_refactorings.enabled = true #|false +roslynator_refactorings.enabled = true # Enable/disable specific refactoring # roslynator_refactoring..enabled = true|false # Enable/disable all compiler diagnostic fixes -roslynator_compiler_diagnostic_fixes.enabled = true #|false +roslynator_compiler_diagnostic_fixes.enabled = true # Enable/disable specific compiler diagnostic fix # roslynator_compiler_diagnostic_fix..enabled = true|false + +# Sort using directives with System.* first +dotnet_sort_system_directives_first = true + +# Default analyzer severity for most rules: suggestion (non-breaking for local dev) +dotnet_analyzer_diagnostic.severity = suggestion + +# categories: sensible defaults for repository quality +# Style: formatting and stylistic suggestions +dotnet_analyzer_diagnostic.category-Style.severity = warning + +# Performance: high-value performance rules treated as errors (e.g. hot-path logging) +dotnet_analyzer_diagnostic.category-Performance.severity = error + +# Naming: recommendations; keep as suggestions to avoid developer friction +dotnet_analyzer_diagnostic.category-Naming.severity = suggestion + +# Maintainability: suggestions for cleaner, maintainable code +dotnet_analyzer_diagnostic.category-Maintainability.severity = suggestion + +# Interoperability: warn about platform/interop issues +dotnet_analyzer_diagnostic.category-Interoperability.severity = warning + +# Design: API design and usage guidance +dotnet_analyzer_diagnostic.category-Design.severity = warning + +# Documentation: XML/docs guidance as suggestions +dotnet_analyzer_diagnostic.category-Documentation.severity = suggestion + +# Globalization: treat important globalization issues as errors (e.g. culture-sensitive formatting) +dotnet_analyzer_diagnostic.category-Globalization.severity = error + +# Reliability: lifecycle/dispose and other reliability concerns - warn by default +dotnet_analyzer_diagnostic.category-Reliability.severity = warning + +# Security: escalate security findings to errors +dotnet_analyzer_diagnostic.category-Security.severity = error + +# Usage: general API usage guidance +dotnet_analyzer_diagnostic.category-Usage.severity = warning + +# Modern C# style preferences (good defaults for .NET 10) +csharp_style_namespace_declarations = file_scoped +csharp_style_implicit_object_creation = true +csharp_style_target_typed_new_expression = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_not_pattern = true + +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_inlined_variable_declaration = true +csharp_style_throw_expression = true +csharp_style_prefer_switch_expression = true +csharp_prefer_simple_using_statement = true +csharp_style_prefer_pattern_matching = true +dotnet_style_operator_placement_when_wrapping = end_of_line + +# # Prefer pattern-matching null checks (`is null` / `is not null`) over `== null` or ReferenceEquals +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_diagnostic.IDE0041.severity = warning +# Also enable related null-check simplification rules +dotnet_diagnostic.IDE0029.severity = warning +dotnet_diagnostic.IDE0030.severity = warning +dotnet_diagnostic.IDE0270.severity = warning +dotnet_diagnostic.IDE0019.severity = warning + +# Prefer var when the type is apparent (modern and concise) +# how does this work with IDE0007? +# var and explicit typing preferences +# csharp_style_var_for_built_in_types = false:none +# csharp_style_var_when_type_is_apparent = true:suggestion +# csharp_style_var_elsewhere = false:suggestion +csharp_style_var_when_type_is_apparent = true + +# # Expression-bodied members where concise +csharp_style_expression_bodied_methods = when_on_single_line +csharp_style_expression_bodied_properties = when_on_single_line + +# # Naming conventions (kept as suggestions) +# dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion +# dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +# dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore +# dotnet_naming_symbols.private_fields.applicable_kinds = field +# dotnet_naming_symbols.private_fields.applicable_accessibilities = private +# dotnet_naming_style.camel_case_underscore.capitalization = camel_case +# dotnet_naming_style.camel_case_underscore.required_prefix = _ +# csharp_style_unused_value_expression_statement_preference = discard_variable + + +# Helpful IDE rules as suggestions so dotnet-format can apply fixes +dotnet_diagnostic.IDE0005.severity = suggestion # Remove unnecessary usings +dotnet_diagnostic.IDE0059.severity = suggestion # Unused assignment +dotnet_diagnostic.IDE0051.severity = suggestion # Unused private members +dotnet_diagnostic.IDE0060.severity = suggestion # Unused parameters +dotnet_diagnostic.IDE0058.severity = suggestion # Expression value is never used +dotnet_diagnostic.IDE0130.severity = suggestion # Use 'new' expression where possible (target-typed new) +csharp_style_unused_value_expression_statement_preference = discard_variable +# Nullable reference types - enabled as suggestions; project opt-in controls runtime enforcement +nullable = enable +csharp_style_prefer_primary_constructors = false + +# Formatting / newline preferences +# prefer Stroustrup +csharp_new_line_before_open_brace = false +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_prefer_braces = when_multiline +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + + + +# Using directive placement +csharp_using_directive_placement = outside_namespace diff --git a/.gitignore b/.gitignore index f5ba8f5..bfbb2fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ -src/*/bin/* -src/*/obj/* -src/.vs/* +**/bin/** +**/obj/** +**/.vs/** output/* output -.vs/* !assets/*.png -test_alc.ps1 -testimages.ps1 testimages/* -testdata/* +refdocs/* +*.md +!docs/en-US/*.md +!README.md diff --git a/Sixel.build.ps1 b/Sixel.build.ps1 new file mode 100644 index 0000000..13b0099 --- /dev/null +++ b/Sixel.build.ps1 @@ -0,0 +1,126 @@ +#! /usr/bin/pwsh +#Requires -Version 7.4 -Module InvokeBuild +param( + [string]$Configuration = 'Release', + [switch]$SkipHelp, + [switch]$SkipTests +) +Write-Host "$($PSBoundParameters.GetEnumerator())" -ForegroundColor Cyan + +$modulename = [System.IO.Path]::GetFileName($PSCommandPath) -replace '\.build\.ps1$' + +$script:folders = @{ + ModuleName = $modulename + ProjectRoot = $PSScriptRoot + SourcePath = Join-Path $PSScriptRoot 'src' $modulename + OutputPath = Join-Path $PSScriptRoot 'output' + DestinationPath = Join-Path $PSScriptRoot 'output' 'lib' + ModuleSourcePath = Join-Path $PSScriptRoot 'module' + DocsPath = Join-Path $PSScriptRoot 'docs' 'en-US' + TestPath = Join-Path $PSScriptRoot 'tests' + CsprojPath = Join-Path $PSScriptRoot 'src' $modulename "$modulename.csproj" +} + +task Reset { + if (Test-Path $folders.OutputPath) { + Remove-Item -Path $folders.OutputPath -Recurse -Force -ErrorAction 'Ignore' + } + New-Item -Path $folders.OutputPath -ItemType Directory -Force | Out-Null +} + +task Build { + if (-not (Test-Path $folders.CsprojPath)) { + Write-Warning 'C# project not found, skipping Build' + return + } + $ModuleFile = Import-PowerShellDataFile -Path (Join-Path $script:folders.ProjectRoot 'Module' "$($script:folders.ModuleName).psd1") + [xml]$csproj = Get-Content -Path $folders.CsprojPath -Raw + $dotnetArgs += '-p:Version={0}' -f $ModuleFile.ModuleVersion.ToString() + $frameworks = $csproj. + SelectNodes('//TargetFramework | //TargetFrameworks'). + '#text'. + Split(';', [StringSplitOptions]::RemoveEmptyEntries) + + $dotnetArgs = @( + 'publish' + $folders.CsprojPath + '--configuration', $Configuration + '--nologo' + '--verbosity', 'minimal' + ) + foreach ($fwork in $frameworks) { + exec { dotnet @dotnetArgs --framework $fwork --output (Join-Path $folders.DestinationPath $fwork) } + } +} + +task ModuleFiles { + if (Test-Path $folders.ModuleSourcePath) { + Get-ChildItem -Path $folders.ModuleSourcePath -File | Copy-Item -Destination $folders.OutputPath -Force + } + else { + Write-Warning "Module directory not found at: $($folders.ModuleSourcePath)" + } +} + +task GenerateHelp -if (-not $SkipHelp) { + if (-not (Test-Path $folders.DocsPath)) { + Write-Warning "Documentation path not found at: $($folders.DocsPath)" + return + } + if (-not (Get-Module -ListAvailable -Name Microsoft.PowerShell.PlatyPS)) { + Write-Host ' Installing Microsoft.PowerShell.PlatyPS...' -ForegroundColor Yellow + Install-Module -Name Microsoft.PowerShell.PlatyPS -Scope CurrentUser -Force -AllowClobber + } + + Import-Module Microsoft.PowerShell.PlatyPS -ErrorAction Stop + + $modulePath = Join-Path $folders.OutputPath ($folders.ModuleName + '.psd1') + if (-not (Test-Path $modulePath)) { + Write-Warning "Module manifest not found at: $modulePath. Skipping help generation." + return + } + + Import-Module $modulePath -Force + + $helpOutputPath = Join-Path $folders.OutputPath 'en-US' + New-Item -Path $helpOutputPath -ItemType Directory -Force | Out-Null + + $allCommandHelp = Get-ChildItem -Path $folders.DocsPath -Filter '*.md' -Recurse -File | + Where-Object { $_.Name -ne "$($folders.ModuleName).md" } | + Import-MarkdownCommandHelp + + if ($allCommandHelp.Count -gt 0) { + $tempOutputPath = Join-Path $helpOutputPath 'temp' + Export-MamlCommandHelp -CommandHelp $allCommandHelp -OutputFolder $tempOutputPath -Force | Out-Null + + $generatedFile = Get-ChildItem -Path $tempOutputPath -Filter '*.xml' -Recurse -File | Select-Object -First 1 + if ($generatedFile) { + Move-Item -Path $generatedFile.FullName -Destination $helpOutputPath -Force + } + Remove-Item -Path $tempOutputPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +task Test -if (-not $SkipTests) { + if (-not (Test-Path $folders.TestPath)) { + Write-Warning "Test directory not found at: $($folders.TestPath)" + return + } + $pesterConfig = New-PesterConfiguration + # $pesterConfig.Output.Verbosity = 'Detailed' + $pesterConfig.Run.Path = $folders.TestPath + $pesterConfig.Run.Throw = $true + $pesterConfig.Debug.WriteDebugMessages = $false + Invoke-Pester -Configuration $pesterConfig +} + +task CleanAfter { + if ($script:folders.DestinationPath -and (Test-Path $script:folders.DestinationPath)) { + Get-ChildItem -Path $script:folders.DestinationPath -File -Recurse | + Where-Object { $_.Extension -in @('.pdb', '.json') } | + Remove-Item -Force -ErrorAction Ignore + } +} + + +task All -Jobs Reset, Build, ModuleFiles, GenerateHelp, CleanAfter, Test diff --git a/Sixel.sln b/Sixel.sln deleted file mode 100644 index c4ca858..0000000 --- a/Sixel.sln +++ /dev/null @@ -1,37 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{343B7D25-9E8D-49CD-8CE2-5AEFB55599AA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sixel", "src\Sixel\Sixel.csproj", "{6530F44D-2FF4-4BE5-ACD7-BC7BB16A0036}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sixel.Shared", "src\Sixel.Shared\Sixel.Shared.csproj", "{CEA8ABE2-8256-4318-9C2C-20ED7483F7A7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6530F44D-2FF4-4BE5-ACD7-BC7BB16A0036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6530F44D-2FF4-4BE5-ACD7-BC7BB16A0036}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6530F44D-2FF4-4BE5-ACD7-BC7BB16A0036}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6530F44D-2FF4-4BE5-ACD7-BC7BB16A0036}.Release|Any CPU.Build.0 = Release|Any CPU - {CEA8ABE2-8256-4318-9C2C-20ED7483F7A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CEA8ABE2-8256-4318-9C2C-20ED7483F7A7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CEA8ABE2-8256-4318-9C2C-20ED7483F7A7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CEA8ABE2-8256-4318-9C2C-20ED7483F7A7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {6530F44D-2FF4-4BE5-ACD7-BC7BB16A0036} = {343B7D25-9E8D-49CD-8CE2-5AEFB55599AA} - {CEA8ABE2-8256-4318-9C2C-20ED7483F7A7} = {343B7D25-9E8D-49CD-8CE2-5AEFB55599AA} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E8BEDC54-6143-4441-AB0F-E25E9C81A141} - EndGlobalSection -EndGlobal diff --git a/Sixel.slnx b/Sixel.slnx new file mode 100644 index 0000000..fee3a79 --- /dev/null +++ b/Sixel.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..6f5a299 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,40 @@ +#! /usr/bin/pwsh +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + [switch] $SkipHelp, + [switch] $SkipTests, + [string] $Task +) + +$ErrorActionPreference = 'Stop' +# Helper function to get paths +$buildparams = @{ + Configuration = $Configuration + SkipHelp = $SkipHelp.IsPresent + SkipTests = $SkipTests.IsPresent + File = (Get-Item (Join-Path $PSScriptRoot '*.build.ps1')).FullName + Task = 'All' + Result = 'Result' + Safe = $true +} +if (-not (Get-Module -ListAvailable -Name InvokeBuild)) { + Install-Module -Name InvokeBuild -Scope CurrentUser -Force -AllowClobber +} +Import-Module InvokeBuild -ErrorAction Stop + +if ($task) { + $buildparams.Task = $task +} + +if (-not $env:CI) { + # this is just so the dll doesn't get locked on and i can rebuild without restarting terminal + $sb = { + param($bp) + Invoke-Build @bp + } + pwsh -NoProfile -Command $sb -args $buildparams +} +else { + Invoke-Build @buildparams +} diff --git a/module/Sixel.psd1 b/module/Sixel.psd1 index 7d0345c..d98384f 100644 --- a/module/Sixel.psd1 +++ b/module/Sixel.psd1 @@ -4,7 +4,7 @@ @{ RootModule = 'Sixel.psm1' - ModuleVersion = '0.6.1' + ModuleVersion = '0.7.0' CompatiblePSEditions = @('Desktop', 'Core') PowerShellVersion = '5.1' DotNetFrameworkVersion = '4.7.2' @@ -46,6 +46,7 @@ ProjectUri = 'https://github.com/trackd/Sixel' # Prerelease = 'prerelease01' ReleaseNotes = @' + 0.7.0 - update libraries, bugfixes. 0.6.1 - bugfix resizing image sometimes cuts off the left outer edge. 0.6.0 - update libraries, tweak sizing algorithm. 0.5.0 - Refactor, cleanup, bugfixes with terminal detection and stream. diff --git a/module/Sixel.psm1 b/module/Sixel.psm1 index 770f2e7..37be88b 100644 --- a/module/Sixel.psm1 +++ b/module/Sixel.psm1 @@ -9,7 +9,7 @@ $isReload = $true if ($IsCoreClr) { if (-not ('Sixel.Shared.LoadContext' -as [type])) { $isReload = $false - Add-Type -Path ([Path]::Combine($PSScriptRoot, 'bin', 'net8.0', "$moduleName.Shared.dll")) + Add-Type -Path ([Path]::Combine($PSScriptRoot, 'lib', 'net8.0', "$moduleName.Shared.dll")) } $mainModule = [Sixel.Shared.LoadContext]::Initialize() @@ -19,44 +19,31 @@ else { # PowerShell 5.1 has no concept of an Assembly Load Context so it will # just load the module assembly directly. - # The type can be any type within our ALCLoader project - if (-not ('Sixel.Shared.AssemblyResolver' -as [type])) { - Add-Type -Path ([Path]::Combine($PSScriptRoot, 'bin', 'net472', "$moduleName.Shared.dll")) - } - - $appDomain = [AppDomain]::CurrentDomain - $resolver = [Sixel.Shared.AssemblyResolver]::ResolveHandler - $appDomain.add_AssemblyResolve($resolver) - $innerMod = if ('Sixel.Terminal.SizeHelper' -as [type]) { - $modAssembly = [Sixel.Terminal.SizeHelper].Assembly + $innerMod = if ('Sixel.Protocols.Sixel' -as [type]) { + $modAssembly = [Sixel.Protocols.Sixel].Assembly &$importModule -Assembly $modAssembly -Force -PassThru } else { $isReload = $false - $modPath = [Path]::Combine($PSScriptRoot, 'bin', 'net472', "$moduleName.dll") + $modPath = [Path]::Combine($PSScriptRoot, 'lib', 'net472', "$moduleName.dll") &$importModule -Name $modPath -ErrorAction Stop -PassThru } - $registerEngineEventSplat = @{ - SourceIdentifier = ([System.Management.Automation.PsEngineEvent]::Exiting) - Action = { - $appDomain.remove_AssemblyResolve($resolver) - } - } - Register-EngineEvent @registerEngineEventSplat } if ($isReload) { - # Bug in pwsh, Import-Module in an assembly will pick up a cached instance - # and not call the same path to set the nested module's cmdlets to the - # current module scope. This is only technically needed if someone is - # calling 'Import-Module -Name $module -Force' a second time. The first - # import is still fine. # https://github.com/PowerShell/PowerShell/issues/20710 $addExportedCmdlet = [PSModuleInfo].GetMethod( 'AddExportedCmdlet', [BindingFlags]'Instance, NonPublic' ) + $addExportedAlias = [PSModuleInfo].GetMethod( + 'AddExportedAlias', + [BindingFlags]'Instance, NonPublic' + ) foreach ($cmd in $innerMod.ExportedCmdlets.Values) { $addExportedCmdlet.Invoke($ExecutionContext.SessionState.Module, @(, $cmd)) } + foreach ($alias in $innerMod.ExportedAliases.Values) { + $addExportedAlias.Invoke($ExecutionContext.SessionState.Module, @(, $alias)) + } } diff --git a/src/Sixel.Shared/AssemblyResolver.cs b/src/Sixel.Shared/AssemblyResolver.cs deleted file mode 100644 index 5f8f845..0000000 --- a/src/Sixel.Shared/AssemblyResolver.cs +++ /dev/null @@ -1,28 +0,0 @@ -#if NET472 -using System; -using System.IO; -using System.Reflection; - -namespace Sixel.Shared; - -/// -/// Provides assembly resolution logic for .NET Framework 4.7.2, enabling dynamic loading of dependencies from the module directory. -/// -public static class AssemblyResolver -{ - public static ResolveEventHandler ResolveHandler = new(Resolve); - - public static Assembly? Resolve(object? sender, ResolveEventArgs args) - { - AssemblyName asName = new(args.Name); - string asPath = Path.Combine( - Path.GetDirectoryName(typeof(AssemblyResolver).Assembly.Location), - $"{asName.Name}.dll"); - if (File.Exists(asPath)) - { - return Assembly.LoadFile(asPath); - } - return null; - } -} -#endif diff --git a/src/Sixel.Shared/LoadContext.cs b/src/Sixel.Shared/LoadContext.cs index 36201fc..6f4adcb 100644 --- a/src/Sixel.Shared/LoadContext.cs +++ b/src/Sixel.Shared/LoadContext.cs @@ -1,5 +1,6 @@ // AssemblyLoadContext won't work in net472 so we conditionally compile this // for net5.0 or greater. +// 5.1 uses => src/Sixel/Helpers/ModuleAssemblyInitializer.cs #if NET5_0_OR_GREATER using System.IO; using System.Reflection; @@ -11,75 +12,50 @@ namespace Sixel.Shared; /// /// Custom AssemblyLoadContext for isolating and resolving assemblies in .NET 5.0 or greater environments. /// -public class LoadContext : AssemblyLoadContext -{ +public class LoadContext : AssemblyLoadContext { private static LoadContext? _instance; - private static object _sync = new object(); + private static readonly object _sync = new(); - private Assembly _thisAssembly; - private AssemblyName _thisAssemblyName; - private Assembly _moduleAssembly; - private string _assemblyDir; + private readonly Assembly _thisAssembly; + private readonly AssemblyName _thisAssemblyName; + private readonly Assembly _moduleAssembly; + private readonly string _assemblyDir; private LoadContext(string mainModulePathAssemblyPath) - : base (name: "Sixel", isCollectible: false) - { + : base(name: "Sixel", isCollectible: false) { _assemblyDir = Path.GetDirectoryName(mainModulePathAssemblyPath) ?? ""; _thisAssembly = typeof(LoadContext).Assembly; _thisAssemblyName = _thisAssembly.GetName(); _moduleAssembly = LoadFromAssemblyPath(mainModulePathAssemblyPath); } - protected override Assembly? Load(AssemblyName assemblyName) - { - // Checks to see if we are trying to access our current assembly - // (ALCLoader.Shared). If so return the already loaded assembly object - // as it provides a common interface between Pwsh and the ALC. - if (AssemblyName.ReferenceMatchesDefinition(_thisAssemblyName, assemblyName)) - { + protected override Assembly? Load(AssemblyName assemblyName) { + if (AssemblyName.ReferenceMatchesDefinition(_thisAssemblyName, assemblyName)) { return _thisAssembly; } - - // Checks to see if the assembly exists in our path, if so load it in - // the ALC. Otherwise fallback to the default loading behaviour. string asmPath = Path.Join(_assemblyDir, $"{assemblyName.Name}.dll"); - if (File.Exists(asmPath)) - { - return LoadFromAssemblyPath(asmPath); - } - else - { - return null; - } + return File.Exists(asmPath) ? LoadFromAssemblyPath(asmPath) : null; } - public static Assembly Initialize() - { + public static Assembly Initialize() { LoadContext? instance = _instance; - if (instance is not null) - { + if (instance is not null) { return instance._moduleAssembly; } - lock (_sync) - { - if (_instance is not null) - { + lock (_sync) { + if (_instance is not null) { return _instance._moduleAssembly; } string assemblyPath = typeof(LoadContext).Assembly.Location; string assemblyName = Path.GetFileNameWithoutExtension(assemblyPath); - // Removes the '.Shared' from the assembly name to refer to our main module. - string moduleName = assemblyName.Substring(0, assemblyName.Length - 7); + string moduleName = assemblyName[..^7]; string modulePath = Path.Combine( Path.GetDirectoryName(assemblyPath)!, $"{moduleName}.dll" ); - - // Creates the ALC which loads our module in the ALC and returns - // the loaded Assembly object for the psm1 to load. _instance = new LoadContext(modulePath); return _instance._moduleAssembly; } diff --git a/src/Sixel.Shared/SharedUtil.cs b/src/Sixel.Shared/SharedUtil.cs deleted file mode 100644 index 9760437..0000000 --- a/src/Sixel.Shared/SharedUtil.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -#if NET5_0_OR_GREATER -using System.Runtime.Loader; -#endif - -namespace Sixel.Shared; - -/// -/// Utility methods for shared assembly and type information operations. -/// -internal class SharedUtil -{ - public static void AddAssemblyInfo(Type type, Dictionary data) - { - Assembly asm = type.Assembly; - - data["Assembly"] = new Dictionary() - { - { "Name", asm.GetName().FullName }, -#if NET5_0_OR_GREATER - { "ALC", AssemblyLoadContext.GetLoadContext(asm)?.Name }, -#endif - { "Location", asm.Location } - }; - } -} diff --git a/src/Sixel.Shared/Sixel.Shared.csproj b/src/Sixel.Shared/Sixel.Shared.csproj index 696d4eb..bc76926 100644 --- a/src/Sixel.Shared/Sixel.Shared.csproj +++ b/src/Sixel.Shared/Sixel.Shared.csproj @@ -1,17 +1,18 @@ - - - net472;net8.0 - true - latest - enable - 0.6.1 - - - - - + + + net8.0 + true + latest + enable + + + + + + + + - - - diff --git a/src/Sixel/Cmdlet/ConvertToSixel.cs b/src/Sixel/Cmdlet/ConvertToSixel.cs index b03ef34..e074bd7 100644 --- a/src/Sixel/Cmdlet/ConvertToSixel.cs +++ b/src/Sixel/Cmdlet/ConvertToSixel.cs @@ -1,8 +1,7 @@ -using Sixel.Terminal; -using Sixel.Terminal.Models; using System.Management.Automation; using System.Net.Http; -using System.Text.RegularExpressions; +using Sixel.Terminal; +using Sixel.Terminal.Models; namespace Sixel.Cmdlet; @@ -10,167 +9,144 @@ namespace Sixel.Cmdlet; [Cmdlet(VerbsData.ConvertTo, "Sixel", DefaultParameterSetName = "Path")] [Alias("cts")] [OutputType(typeof(string))] -public sealed class ConvertSixelCmdlet : PSCmdlet -{ - [Parameter( - HelpMessage = "InputObject from Pipeline, can be filepath or base64 encoded image.", - Mandatory = true, - ValueFromPipeline = true, - ParameterSetName = "InputObject" - )] - [ValidateNotNullOrEmpty] - public string? InputObject { get; set; } - [Parameter( +public sealed class ConvertSixelCmdlet : PSCmdlet { + private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + [Parameter( + HelpMessage = "InputObject from Pipeline, can be filepath or base64 encoded image.", + Mandatory = true, + ValueFromPipeline = true, + ParameterSetName = "InputObject" + )] + [ValidateNotNullOrEmpty] + public string? InputObject { get; set; } + [Parameter( HelpMessage = "A path to a local image to convert to sixel.", Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Path" - )] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string? Path { get; set; } + )] + [ValidateNotNullOrEmpty] + [Alias("FullName")] + public string? Path { get; set; } - [Parameter( + [Parameter( HelpMessage = "A URL of the image to download and convert to sixel.", Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Url" - )] - [ValidateNotNullOrEmpty] - [Alias("Uri")] - public Uri? Url { get; set; } + )] + [ValidateNotNullOrEmpty] + [Alias("Uri")] + public Uri? Url { get; set; } - [Parameter( + [Parameter( HelpMessage = "A stream of the image to convert to sixel.", Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Stream" - )] - [ValidateNotNullOrEmpty] - [Alias("RawContentStream", "FileStream", "InputStream", "ContentStream")] - public Stream? Stream { get; set; } + )] + [ValidateNotNullOrEmpty] + [Alias("RawContentStream", "FileStream", "InputStream", "ContentStream")] + public Stream? Stream { get; set; } - [Parameter( + [Parameter( HelpMessage = "The maximum number of colors to use in the image." - )] - [ValidateRange(1, 256)] - public int MaxColors { get; set; } = 256; + )] + [ValidateRange(1, 256)] + public int MaxColors { get; set; } = 256; - [Parameter( + [Parameter( HelpMessage = "Width of the image in character cells, the height will be scaled to maintain aspect ratio." - )] - [ValidateTerminalWidth()] - public int Width { get; set; } + )] + [ValidateTerminalWidth()] + public int Width { get; set; } - [Parameter( + [Parameter( HelpMessage = "Height of the image in character cells, the width will be scaled to maintain aspect ratio." - )] - [ValidateTerminalHeight()] - public int Height { get; set; } + )] + [ValidateTerminalHeight()] + public int Height { get; set; } - [Parameter( + [Parameter( HelpMessage = "Force the command to attempt to output image data even if the terminal does not support the protocol selected." - )] - public SwitchParameter Force { get; set; } + )] + public SwitchParameter Force { get; set; } - [Parameter( + [Parameter( HelpMessage = "Choose ImageProtocol to output." - )] - public ImageProtocol Protocol { get; set; } = ImageProtocol.Auto; + )] + public ImageProtocol Protocol { get; set; } = ImageProtocol.Auto; - protected override void ProcessRecord() - { - Stream? imageStream = null; - try - { - switch (ParameterSetName) - { - case "InputObject": - { - if (InputObject is not null && InputObject.Length > 512) - { - // assume it's a base64 encoded image - InputObject = Regex.Replace( - InputObject, - @"^data:image/\w+;base64,", - string.Empty, - RegexOptions.IgnoreCase, - TimeSpan.FromSeconds(1) - ); - imageStream = new MemoryStream(Convert.FromBase64String(InputObject)); - } - else - { - /// assume it's a path to a file - var resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(InputObject); - imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); + protected override void ProcessRecord() { + Stream? imageStream = null; + try { + switch (ParameterSetName) { + case "InputObject": { + if (InputObject?.Length > 512) { + // assume it's a base64 encoded image + InputObject = Compatibility.TrimBase64(InputObject); + imageStream = new MemoryStream(Convert.FromBase64String(InputObject)); + } + else { + /// assume it's a path to a file + string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(InputObject); + imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); + } + break; + } + case "Path": { + string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); + imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); + } + break; + case "Url": { + HttpResponseMessage response = _httpClient.GetAsync(Url).GetAwaiter().GetResult(); + _ = response.EnsureSuccessStatusCode(); + imageStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); + break; + } + case "Stream": { + if (Stream?.CanSeek is true && Stream.Position is not 0) { + Stream.Position = 0; + } + imageStream = Stream; + break; + } + default: + break; } - break; - } - case "Path": - { - var resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); - imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); - } - break; - case "Url": - { - // Use static HttpClient for better performance - avoids socket exhaustion - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(30); // Set reasonable timeout - var response = client.GetAsync(Url).GetAwaiter().GetResult(); // Synchronous for PowerShell compatibility - response.EnsureSuccessStatusCode(); - imageStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); - break; - } - case "Stream": - { - if (Stream is not null && Stream.Position != 0) - { - // if something has already read the stream, reset it. - // maybe this should be a parameter? - Stream.Position = 0; + if (imageStream is null) { + return; } - imageStream = Stream; - break; - } - } - if (imageStream is null) - { - return; - } - (ImageSize size, string image) = ConvertTo.ConsoleImage( - Protocol, - imageStream, - MaxColors, - Width, - Height, - Force.IsPresent - ); - var wrappedImage = PSObject.AsPSObject(image); - wrappedImage.Properties.Add(new PSNoteProperty("Width", size.Width)); - wrappedImage.Properties.Add(new PSNoteProperty("Height", size.Height)); - WriteObject(wrappedImage); - } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "ConvertSixelCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); - } - finally - { - // if someone passes a Stream object, we should not dispose it. - // that breaks Invoke-Webrequest etc. trackd/sixel#23 - if (ParameterSetName != "Stream" && imageStream is not null) - { - imageStream.Dispose(); - } + (ImageSize size, string image) = ConvertTo.ConsoleImage( + Protocol, + imageStream, + MaxColors, + Width, + Height, + Force.IsPresent + ); + var wrappedImage = PSObject.AsPSObject(image); + wrappedImage.Properties.Add(new PSNoteProperty("Width", size.Width)); + wrappedImage.Properties.Add(new PSNoteProperty("Height", size.Height)); + WriteObject(wrappedImage); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "ConvertSixelCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); + } + finally { + // if someone passes a Stream object, we should not dispose it. + // that breaks Invoke-Webrequest etc. trackd/sixel#23 + if (ParameterSetName != "Stream" && imageStream is not null) { + imageStream.Dispose(); + } + } } - } } diff --git a/src/Sixel/Cmdlet/ConvertToSixelGif.cs b/src/Sixel/Cmdlet/ConvertToSixelGif.cs index cf24634..5e08ac1 100644 --- a/src/Sixel/Cmdlet/ConvertToSixelGif.cs +++ b/src/Sixel/Cmdlet/ConvertToSixelGif.cs @@ -1,9 +1,8 @@ -using Sixel.Terminal; -using Sixel.Terminal.Models; -using Sixel.Protocols; -using System.Management.Automation; +using System.Management.Automation; using System.Net.Http; -using System.Text.RegularExpressions; +using Sixel.Protocols; +using Sixel.Terminal; +using Sixel.Terminal.Models; namespace Sixel.Cmdlet; @@ -11,145 +10,125 @@ namespace Sixel.Cmdlet; [Cmdlet(VerbsData.ConvertTo, "SixelGif", DefaultParameterSetName = "Path")] [Alias("gif")] [OutputType(typeof(SixelGif))] -public sealed class ConvertSixelGifCmdlet : PSCmdlet -{ - [Parameter( - HelpMessage = "InputObject from Pipeline, can be filepath or base64 encoded image.", - Mandatory = true, - ValueFromPipeline = true, - ParameterSetName = "InputObject" - )] - [ValidateNotNullOrEmpty] - public string? InputObject { get; set; } - [Parameter( +public sealed class ConvertSixelGifCmdlet : PSCmdlet { + private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + [Parameter( + HelpMessage = "InputObject from Pipeline, can be filepath or base64 encoded image.", + Mandatory = true, + ValueFromPipeline = true, + ParameterSetName = "InputObject" + )] + [ValidateNotNullOrEmpty] + public string? InputObject { get; set; } + [Parameter( HelpMessage = "A path to a local gif to convert to sixelgif.", Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Path" - )] - [ValidateNotNullOrEmpty] - [Alias("FullName")] - public string? Path { get; set; } + )] + [ValidateNotNullOrEmpty] + [Alias("FullName")] + public string? Path { get; set; } - [Parameter( + [Parameter( HelpMessage = "A URL of the gif to download and convert to sixelgif.", Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Url" - )] - [ValidateNotNullOrEmpty] - [Alias("Uri")] - public Uri? Url { get; set; } + )] + [ValidateNotNullOrEmpty] + [Alias("Uri")] + public Uri? Url { get; set; } - [Parameter( + [Parameter( HelpMessage = "A stream of the gif to convert to sixelgif.", Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Stream" - )] - [ValidateNotNullOrEmpty] - [Alias("RawContentStream", "FileStream", "InputStream", "ContentStream")] - public Stream? Stream { get; set; } + )] + [ValidateNotNullOrEmpty] + [Alias("RawContentStream", "FileStream", "InputStream", "ContentStream")] + public Stream? Stream { get; set; } - [Parameter( + [Parameter( HelpMessage = "The maximum number of colors to use in the image." - )] - [ValidateRange(1, 256)] - public int MaxColors { get; set; } = 256; + )] + [ValidateRange(1, 256)] + public int MaxColors { get; set; } = 256; - [Parameter( + [Parameter( HelpMessage = "Width of the image in character cells, the height will be scaled to maintain aspect ratio." - )] - [ValidateTerminalWidth()] - public int Width { get; set; } + )] + [ValidateTerminalWidth()] + public int Width { get; set; } - [Parameter( + [Parameter( HelpMessage = "Force the command to attempt to output sixel data even if the terminal does not support sixel." - )] - public SwitchParameter Force { get; set; } + )] + public SwitchParameter Force { get; set; } + + [Parameter( + HelpMessage = "The number of times to loop the gif. Use 0 for infinite loop." + )] + public int LoopCount { get; set; } = 3; + protected override void ProcessRecord() { + Stream? imageStream = null; + try { + switch (ParameterSetName) { + case "InputObject": { + if (InputObject?.Length > 512) { + // assume it's a base64 encoded image + InputObject = Compatibility.TrimBase64(InputObject); - [Parameter( - HelpMessage = "The number of times to loop the gif." - )] - [ValidateRange(1, 256)] - public int LoopCount { get; set; } = 3; - protected override void ProcessRecord() - { - Stream? imageStream = null; - try - { - switch (ParameterSetName) - { - case "InputObject": - { - if (InputObject is not null && InputObject.Length > 512) - { - // assume it's a base64 encoded image - InputObject = Regex.Replace( - InputObject, - @"^data:image/\w+;base64,", - string.Empty, - RegexOptions.IgnoreCase, - TimeSpan.FromSeconds(1) - ); - imageStream = new MemoryStream(Convert.FromBase64String(InputObject)); + imageStream = new MemoryStream(Convert.FromBase64String(InputObject)); + } + else { + // assume it's a path to a file + string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(InputObject); + imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); + } + break; + } + case "Path": { + string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); + imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); + } + break; + case "Url": { + HttpResponseMessage response = _httpClient.GetAsync(Url).GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + imageStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); + break; + } + case "Stream": { + if (Stream?.CanSeek is true && Stream.Position is not 0) { + Stream.Position = 0; + } + imageStream = Stream; + break; + } + default: + break; } - else - { - /// assume it's a path to a file - var resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(InputObject); - imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); + if (imageStream is null) { + return; } - break; - } - case "Path": - { - var resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); - imageStream = new FileStream(resolvedPath, FileMode.Open, FileAccess.Read); - } - break; - case "Url": - { - // Use consistent HTTP client pattern with timeout - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(30); - var response = client.GetAsync(Url).GetAwaiter().GetResult(); - response.EnsureSuccessStatusCode(); - imageStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); - break; - } - case "Stream": - { - if (Stream is not null && Stream.Position != 0) - { - // if something has already read the stream, reset it.. maybe risky - Stream.Position = 0; + WriteObject(GifToSixel.ConvertGif(imageStream, MaxColors, Width, LoopCount)); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "ConvertSixelGifCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); + } + finally { + // if someone passes a Stream object, we should not dispose it. + // that breaks Invoke-Webrequest etc. trackd/sixel#23 + if (ParameterSetName != "Stream" && imageStream is not null) { + imageStream.Dispose(); } - imageStream = Stream; - break; - } - } - if (imageStream is null) - { - return; - } - WriteObject(GifToSixel.ConvertGif(imageStream, MaxColors, Width, LoopCount)); - } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "ConvertSixelGifCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); - } - finally - { - if (ParameterSetName != "Stream" && imageStream is not null) - { - imageStream.Dispose(); - } + } } - } } diff --git a/src/Sixel/Cmdlet/ShowSixelGif.cs b/src/Sixel/Cmdlet/ShowSixelGif.cs index ba21e6a..319f655 100644 --- a/src/Sixel/Cmdlet/ShowSixelGif.cs +++ b/src/Sixel/Cmdlet/ShowSixelGif.cs @@ -1,8 +1,8 @@ -using Sixel.Terminal; -using Sixel.Terminal.Models; -using Sixel.Protocols; -using System.Management.Automation; +using System.Management.Automation; using System.Threading; +using Sixel.Protocols; +using Sixel.Terminal; +using Sixel.Terminal.Models; namespace Sixel.Cmdlet; @@ -14,32 +14,28 @@ namespace Sixel.Cmdlet; [Cmdlet(VerbsCommon.Show, "SixelGif")] [OutputType(typeof(void))] -public sealed class ShowSixelGifCmdlet : PSCmdlet -{ - [Parameter( - HelpMessage = "SixelGif object to play.", - Mandatory = true, - ValueFromPipeline = true, - Position = 0 - )] - [ValidateNotNullOrEmpty] - public SixelGif? Gif { get; set; } - protected override void ProcessRecord() - { - try - { - if (Gif is null) return; - CancellationTokenSource CancellationToken = new(); - // Handle Ctrl+C - Console.CancelKeyPress += (sender, args) => { - args.Cancel = true; - CancellationToken.Cancel(); - }; - GifToSixel.PlaySixelGif(Gif, CancellationToken.Token); - } - catch (Exception ex) - { - WriteError(new ErrorRecord(ex, "ShowSixelGifCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); +public sealed class ShowSixelGifCmdlet : PSCmdlet { + [Parameter( + HelpMessage = "SixelGif object to play.", + Mandatory = true, + ValueFromPipeline = true, + Position = 0 + )] + [ValidateNotNullOrEmpty] + public SixelGif? Gif { get; set; } + protected override void ProcessRecord() { + try { + if (Gif is null) return; + CancellationTokenSource CancellationToken = new(); + // Handle Ctrl+C + Console.CancelKeyPress += (sender, args) => { + args.Cancel = true; + CancellationToken.Cancel(); + }; + GifToSixel.PlaySixelGif(Gif, CancellationToken.Token); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "ShowSixelGifCmdlet", ErrorCategory.NotSpecified, MyInvocation.BoundParameters)); + } } - } } diff --git a/src/Sixel/Helpers/ModuleAssemblyInitializer.cs b/src/Sixel/Helpers/ModuleAssemblyInitializer.cs new file mode 100644 index 0000000..bd9fbdd --- /dev/null +++ b/src/Sixel/Helpers/ModuleAssemblyInitializer.cs @@ -0,0 +1,40 @@ +// 5.1 (net472) can't do ALC so we can just have the resolver here with conditional compilation. +// > net8.0 will use => src/Sixel.Shared/LoadContext.cs +#if NET472 +using System; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Reflection; + +namespace Sixel; + +public sealed class ModuleAssemblyInitializer : IModuleAssemblyInitializer, IModuleAssemblyCleanup { + private static readonly ResolveEventHandler s_resolveHandler = Resolve; + + public void OnImport() => AppDomain.CurrentDomain.AssemblyResolve += s_resolveHandler; + + public void OnRemove(PSModuleInfo psModuleInfo) => AppDomain.CurrentDomain.AssemblyResolve -= s_resolveHandler; + + private static Assembly? Resolve(object? sender, ResolveEventArgs args) { + AssemblyName assemblyName = new(args.Name); + if (assemblyName.Name is null) { + return null; + } + + Assembly? loadedAssembly = AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(existing => AssemblyName.ReferenceMatchesDefinition(existing.GetName(), assemblyName)); + if (loadedAssembly is not null) { + return loadedAssembly; + } + + string moduleAssemblyDir = Path.GetDirectoryName(typeof(ModuleAssemblyInitializer).Assembly.Location) ?? string.Empty; + string candidatePath = Path.Combine(moduleAssemblyDir, $"{assemblyName.Name}.dll"); + + return File.Exists(candidatePath) + ? Assembly.LoadFrom(candidatePath) + : null; + } +} +#endif diff --git a/src/Sixel/Helpers/ResizerDev.cs b/src/Sixel/Helpers/ResizerDev.cs new file mode 100644 index 0000000..9d7d092 --- /dev/null +++ b/src/Sixel/Helpers/ResizerDev.cs @@ -0,0 +1,174 @@ +using Sixel.Terminal.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Quantization; + +namespace Sixel.Terminal; + +/// +/// testing math.. +/// +public static class ResizerDev { + public static Image ResizeForSixel( + Image image, + ImageSize imageSize, + int maxColors + ) { + CellSize cellSize = Compatibility.GetCellSize(); + int targetPixelWidth = imageSize.Width * cellSize.PixelWidth; + int targetPixelHeight = imageSize.Height * cellSize.PixelHeight; + int sixelAlignedHeight = (targetPixelHeight + 5) / 6 * 6; + return ResizeExact(image, targetPixelWidth, sixelAlignedHeight, maxColors); + } + + public static Image ResizeForKitty( + Image image, + ImageSize imageSize + ) { + CellSize cellSize = Compatibility.GetCellSize(); + int targetPixelWidth = imageSize.Width * cellSize.PixelWidth; + int targetPixelHeight = imageSize.Height * cellSize.PixelHeight; + return ResizeExact(image, targetPixelWidth, targetPixelHeight, maxColors: 0); + } + + public static Image ResizeForBlocks( + Image image, + ImageSize imageSize + ) { + int targetPixelWidth = imageSize.Width; + int targetPixelHeight = imageSize.Height * 2; + return ResizeExact(image, targetPixelWidth, targetPixelHeight, maxColors: 0); + } + + public static Image ResizeForBraille( + Image image, + ImageSize imageSize + ) { + int targetPixelWidth = imageSize.Width * 2; + int targetPixelHeight = imageSize.Height * 4; + return ResizeExact(image, targetPixelWidth, targetPixelHeight, maxColors: 0); + } + + private static Image ResizeExact( + Image image, + int targetPixelWidth, + int targetPixelHeight, + int maxColors + ) { + bool needsResize = image.Width != targetPixelWidth || image.Height != targetPixelHeight; + bool needsQuantize = maxColors > 0; + + if (!needsResize && !needsQuantize) { + return image; + } + + image.Mutate(ctx => { + if (needsResize) { + ctx.Resize(new ResizeOptions() { + Mode = ResizeMode.Stretch, + Sampler = KnownResamplers.Bicubic, + Size = new(targetPixelWidth, targetPixelHeight), + PremultiplyAlpha = false, + }); + } + + if (needsQuantize) { + ctx.Quantize(new OctreeQuantizer(new() { + MaxColors = maxColors, + })); + } + }); + + return image; + } + + /// + /// Resizes an image to fit within the specified terminal character cell dimensions. + /// + /// The image to resize. + /// The maximum number of colors to use (for quantization). + /// The target width in terminal character cells. + /// The target height in terminal character cells (optional). + /// Whether to quantize the image to reduce colors. + /// tuple of ImageSize and resized Image stream. + [Obsolete("Use ResizeToCharacterCells instead")] + internal static (ImageSize Size, Image ConsoleImage) OldResizeToCharacterCells( + Image image, + int maxColors, + int? RequestedWidth, + int? RequestedHeight, + bool quantize = false + ) { + CellSize cellSize = Compatibility.GetCellSize(); + int reqWidth = (RequestedWidth > 0) ? RequestedWidth.Value : 0; + int reqHeight = (RequestedHeight > 0) ? RequestedHeight.Value : 0; + + // If both are zero, do not resize or quantize, just return the current size in cells + // if (reqWidth == 0 && reqHeight == 0) + // { + // var currentSize = SizeHelper.ConvertToCharacterCells(image.Width, image.Height); + // return (currentSize, image); // } + + ImageSize newSize = SizeHelper.GetResizedCharacterCellSize(image.Width, image.Height, reqWidth, reqHeight); + + // Calculate pixel dimensions from cell dimensions + int targetPixelWidth = newSize.Width * cellSize.PixelWidth; + int targetPixelHeight = newSize.Height * cellSize.PixelHeight; + + // Only resize if the target size is different + if (image.Width != targetPixelWidth || image.Height != targetPixelHeight) { + image.Mutate(ctx => { + ctx.Resize(new ResizeOptions() { + // Pads the image to fit the bound of the container without resizing the original source. + // When downscaling, performs the same functionality as Pad + Mode = ResizeMode.BoxPad, + Position = AnchorPositionMode.TopLeft, + PadColor = Color.Transparent, + // https://en.wikipedia.org/wiki/Bicubic_interpolation + // quality goes Bicubic > Bilinear > NearestNeighbor + Sampler = KnownResamplers.Bicubic, + Size = new(targetPixelWidth, targetPixelHeight), + PremultiplyAlpha = false, + }); + if (quantize) { + ctx.Quantize(new OctreeQuantizer(new() { + MaxColors = maxColors, + })); + } + }); + } + else if (quantize) { + image.Mutate(ctx => { + ctx.Quantize(new OctreeQuantizer(new() { + MaxColors = maxColors, + })); + }); + } + return (newSize, image); + } + /// + /// Resizes an image to fit within the specified terminal character cell dimensions. + /// This method is used when the image size is already known and does not need to be calculated. + /// + /// The image to resize. + /// The target size in terminal character cells. + /// The maximum number of colors to use (for quantization). + /// When true, pad the final height to the next multiple of 6 pixels for sixel encoding without stretching the content. + /// The resized image. + public static Image ResizeToCharacterCells( + Image image, + ImageSize imageSize, + int maxColors, + bool padHeightToMultipleOf6 = false + ) { + return padHeightToMultipleOf6 + ? ResizeForSixel(image, imageSize, maxColors) + : ResizeExact( + image, + imageSize.Width * Compatibility.GetCellSize().PixelWidth, + imageSize.Height * Compatibility.GetCellSize().PixelHeight, + maxColors + ); + } +} diff --git a/src/Sixel/Helpers/SizeHelperDev.cs b/src/Sixel/Helpers/SizeHelperDev.cs new file mode 100644 index 0000000..306873b --- /dev/null +++ b/src/Sixel/Helpers/SizeHelperDev.cs @@ -0,0 +1,204 @@ +using System; +using Sixel.Terminal.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Sixel.Terminal; + +/// +/// testing math.. +/// +public static class SizeHelperDev { + public static ImageSize GetSixelTargetSize(Image image, int maxCellWidth, int maxCellHeight) + => GetRequestedOrDefaultCellSize(image, maxCellWidth, maxCellHeight); + + public static ImageSize GetKittyTargetSize(Image image, int maxCellWidth, int maxCellHeight) + => GetRequestedOrDefaultCellSize(image, maxCellWidth, maxCellHeight); + + public static ImageSize GetBlocksTargetSize(Image image, int maxCellWidth, int maxCellHeight) + => GetRequestedOrDefaultCellSize(image, maxCellWidth, maxCellHeight); + + public static ImageSize GetBrailleTargetSize(Image image, int maxCellWidth, int maxCellHeight) + => GetRequestedOrDefaultCellSize(image, maxCellWidth, maxCellHeight); + + private static ImageSize GetRequestedOrDefaultCellSize(Image image, int maxCellWidth, int maxCellHeight) { + bool hasConstraints = maxCellWidth > 0 || maxCellHeight > 0; + return hasConstraints + ? GetResizedCharacterCellSize(image, maxCellWidth, maxCellHeight) + : GetDefaultTerminalImageSize(image); + } + + /// + /// Converts image dimensions from pixels to terminal character cells. + /// + /// The image to convert. + /// The image protocol being used (affects alignment) + /// Image size in terminal character cells. + public static ImageSize ConvertToCharacterCells(Image image) + => GetCharacterCellSize(image.Width, image.Height); + + /// + /// Converts image dimensions from pixels to terminal character cells. + /// + /// The image stream to convert. + /// The image protocol being used (affects alignment) + /// Image size in terminal character cells. + public static ImageSize ConvertToCharacterCells(Stream imageStream) { + using var image = Image.Load(imageStream); + return GetCharacterCellSize(image.Width, image.Height); + } + + /// + /// Gets the current size of an image in terminal character cells (no resizing, just analysis). + /// + /// + /// + /// The image protocol being used (affects alignment) + public static ImageSize GetCharacterCellSize(int pixelWidth, int pixelHeight) { + CellSize cellSize = Compatibility.GetCellSize(); + + int widthCells = Math.Max(1, (int)Math.Ceiling((double)pixelWidth / cellSize.PixelWidth)); + int heightCells = Math.Max(1, (int)Math.Ceiling((double)pixelHeight / cellSize.PixelHeight)); + + return new ImageSize(widthCells, heightCells); + } + + /// + /// Gets the current size of an image in terminal character cells (no resizing, just analysis). + /// + /// + /// The image protocol being used (affects alignment) + public static ImageSize GetCharacterCellSize(Image image) + => GetCharacterCellSize(image.Width, image.Height); + + /// + /// Gets the resized size in terminal character cells for an image, given max width/height constraints. + /// Maintains aspect ratio and uses pixel-space math to avoid clipping. + /// + /// + /// + /// + /// + /// The image protocol being used (affects alignment) + public static ImageSize GetResizedCharacterCellSize(int pixelWidth, int pixelHeight, int maxCellWidth, int maxCellHeight) { + CellSize cellSize = Compatibility.GetCellSize(); + + if (pixelWidth <= 0 || pixelHeight <= 0) { + return new ImageSize(1, 1); + } + + // Treat 0 as "no constraint" instead of clamping to the current window size. + bool constrainW = maxCellWidth > 0; + bool constrainH = maxCellHeight > 0; + + // Convert constraints to pixel budgets; Infinity for unconstrained. + double maxPixelsW = constrainW ? (double)maxCellWidth * cellSize.PixelWidth : double.PositiveInfinity; + double maxPixelsH = constrainH ? (double)maxCellHeight * cellSize.PixelHeight : double.PositiveInfinity; + + // Compute scale in pixel space to preserve aspect ratio + double scaleW = double.IsInfinity(maxPixelsW) ? double.PositiveInfinity : maxPixelsW / pixelWidth; + double scaleH = double.IsInfinity(maxPixelsH) ? double.PositiveInfinity : maxPixelsH / pixelHeight; + double scale = Math.Min(scaleW, scaleH); + if (double.IsInfinity(scale) || scale <= 0) { + scale = 1.0; // No constraints provided + } + + // Scaled pixel size (no intermediate rounding to avoid double-rounding) + double scaledPixelW = Math.Max(1.0, pixelWidth * scale); + double scaledPixelH = Math.Max(1.0, pixelHeight * scale); + + // Convert scaled pixels to cells. Use Ceil for width to avoid right-edge clipping. + int cellW = Math.Max(1, (int)Math.Ceiling(scaledPixelW / cellSize.PixelWidth)); + int cellH = Math.Max(1, (int)Math.Ceiling(scaledPixelH / cellSize.PixelHeight)); + + // Clamp to explicit constraints only + if (constrainW) { + cellW = Math.Min(cellW, maxCellWidth); + } + + if (constrainH) { + cellH = Math.Min(cellH, maxCellHeight); + } + + return new ImageSize(cellW, cellH); + } + + /// + /// Gets the resized size in terminal character cells for an image, given max width/height constraints. + /// Maintains aspect ratio and ensures proper sixel alignment (multiples of 6 pixels). + /// + /// + /// + /// + /// The image protocol being used (affects alignment) + public static ImageSize GetResizedCharacterCellSize(Image image, int maxCellWidth, int maxCellHeight) + => GetResizedCharacterCellSize(image.Width, image.Height, maxCellWidth, maxCellHeight); + + /// + /// Gets the constrained terminal image size for the image, applying width/height constraints. + /// + /// + /// + /// + public static ImageSize GetTerminalImageSize(Image image, int maxWidth, int maxHeight) + => GetResizedCharacterCellSize(image.Width, image.Height, maxWidth, maxHeight); + + /// + /// Gets the constrained terminal image size for the image, applying width/height constraints. + /// + /// + /// + /// + public static ImageSize GetTerminalImageSize(Stream imageStream, int maxWidth, int maxHeight) { + using var image = Image.Load(imageStream); + return GetResizedCharacterCellSize(image.Width, image.Height, maxWidth, maxHeight); + } + + /// + /// Gets the constrained terminal image size, applying width/height constraints. + /// + /// + /// + /// + /// + public static ImageSize GetTerminalImageSize(int pixelWidth, int pixelHeight, int maxWidth, int maxHeight) + => GetResizedCharacterCellSize(pixelWidth, pixelHeight, maxWidth, maxHeight); + + internal static ImageSize GetTerminalImageSize(this Image image) + => ConvertToCharacterCells(image); + + /// + /// Computes a default terminal image size relative to the current window, using true cell size. + /// When the console is unavailable or redirected, falls back to the natural image size in cells. + /// + /// Loaded image. + /// Proportion of window to target (e.g., 0.6 for 60%). + public static ImageSize GetDefaultTerminalImageSize(Image image, double windowScaleFactor = 0.6) { + ImageSize natural = ConvertToCharacterCells(image); + + // If console isn't interactive, return natural size + bool hasConsole = !Console.IsOutputRedirected && !Console.IsInputRedirected; + if (!hasConsole) { + return natural; + } +#if NET6_0_OR_GREATER + if (OperatingSystem.IsMacOS()) { + // this is an attempt to get better sizing for mac retina displays.. testing.. + // is only used when width is not specified. + + // Determine window target in character cells + int winCols = Math.Max(1, Console.WindowWidth); + int winRows = Math.Max(1, Console.WindowHeight); + int targetCols = Math.Max(1, (int)Math.Round(winCols * windowScaleFactor)); + int targetRows = Math.Max(1, (int)Math.Round(winRows * windowScaleFactor)); + + // Upscale to meet window-relative targets when natural is smaller + int applyW = natural.Width < targetCols ? targetCols : natural.Width; + int applyH = natural.Height < targetRows ? targetRows : natural.Height; + return GetResizedCharacterCellSize(image.Width, image.Height, applyW, applyH); + } +#endif + return natural; + + } +} diff --git a/src/Sixel/Protocols/Blocks.cs b/src/Sixel/Protocols/Blocks.cs index 38c0199..ec651c7 100644 --- a/src/Sixel/Protocols/Blocks.cs +++ b/src/Sixel/Protocols/Blocks.cs @@ -1,143 +1,112 @@ -using Sixel.Terminal; +using System.Text; +using Sixel.Terminal; using Sixel.Terminal.Models; -using System.Text; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Sixel.Protocols; -public static class Blocks -{ - public static string ImageToBlocks(Image image, ImageSize imageSize) - { - // Resize the image directly to character cell dimensions (not pixel dimensions) - image.Mutate(ctx => - { - ctx.Resize(new ResizeOptions - { - Mode = ResizeMode.BoxPad, - Position = AnchorPositionMode.TopLeft, - PadColor = Color.Transparent, - // *2 because each cell is 2 pixels high for blocks - Size = new Size(imageSize.Width, imageSize.Height * 2), - Sampler = KnownResamplers.Bicubic, // Better for preserving sharp transparency edges - PremultiplyAlpha = false +public static class Blocks { + private static readonly Rgba32 s_transparent = new(0, 0, 0, 0); + public static string ImageToBlocks(Image image, ImageSize imageSize) { + int targetWidth = imageSize.Width; + int targetHeight = imageSize.Height * 2; + + if (targetWidth <= 0 || targetHeight <= 0) { + return string.Empty; + } + + if (image.Width != targetWidth || image.Height != targetHeight) { + // Resize directly to the block sampling grid. + image.Mutate(ctx => { + ctx.Resize(new ResizeOptions { + Mode = ResizeMode.BoxPad, + Position = AnchorPositionMode.TopLeft, + PadColor = Color.Transparent, + // *2 because each cell is 2 pixels high for blocks. + Size = new Size(targetWidth, targetHeight), + Sampler = KnownResamplers.Bicubic, + PremultiplyAlpha = false + }); }); - }); - var targetFrame = image.Frames[0]; - return ProcessFrame(targetFrame); - } + } - internal static string ProcessFrame(ImageFrame frame) - { - var _buffer = new StringBuilder(); - var _backgroundColor = GetConsoleBackgroundColor(); + ImageFrame targetFrame = image.Frames[0]; + return ProcessFrameBlocks(targetFrame); + } - for (int y = 0; y < frame.Height; y += 2) - { - if (y + 1 >= frame.Height) - { - _buffer.AppendLine(); - break; - } + internal static string ProcessFrameBlocks(ImageFrame frame) { + var _buffer = new StringBuilder(frame.Width * frame.Height * 6); - for (int x = 0; x < frame.Width; x++) - { - var topPixel = frame[x, y]; - var bottomPixel = frame[x, y + 1]; + for (int y = 0; y < frame.Height; y += 2) { + for (int x = 0; x < frame.Width; x++) { + Rgba32 topPixel = frame[x, y]; + Rgba32 bottomPixel = y + 1 < frame.Height ? frame[x, y + 1] : s_transparent; - _buffer.ProcessPixelPairs(topPixel, bottomPixel, _backgroundColor); + _buffer.ProcessPixelPairs(topPixel, bottomPixel); } - _buffer.AppendLine(); + _ = _buffer.AppendLine(); } return _buffer.ToString(); } - private static void ProcessPixelPairs(this StringBuilder _buffer, Rgba32 top, Rgba32 bottom, Rgba32 _backgroundColor) - { + private static void ProcessPixelPairs(this StringBuilder _buffer, Rgba32 top, Rgba32 bottom) { bool topTransparent = IsTransparent(top); bool bottomTransparent = IsTransparent(bottom); - if (topTransparent && bottomTransparent) - { - // Both pixels are transparent - _buffer.Append(' '); + if (topTransparent && bottomTransparent) { + _buffer.AppendSpace(); } - else if (topTransparent) - { - // Only bottom pixel is opaque, use lower half block - var bottomRgb = BlendPixels(bottom, _backgroundColor); - _buffer.Append($"{Constants.ESC}{Constants.VTFG}{bottomRgb.R};{bottomRgb.G};{bottomRgb.B}m{Constants.LowerHalfBlock}{Constants.ESC}[0m".AsSpan()); + else if (topTransparent) { + _buffer.AppendTopTransparent(bottom.R, bottom.G, bottom.B); } - else if (bottomTransparent) - { - // Only top pixel is opaque, use upper half block - var topRgb = BlendPixels(top, _backgroundColor); - _buffer.Append($"{Constants.ESC}{Constants.VTFG}{topRgb.R};{topRgb.G};{topRgb.B}m{Constants.UpperHalfBlock}{Constants.ESC}[0m".AsSpan()); + else if (bottomTransparent) { + _buffer.AppendBottomTransparent(top.R, top.G, top.B); } - else - { - // Both pixels are opaque, set foreground and background colors, use upper half block - var topRgb = BlendPixels(top, _backgroundColor); - var bottomRgb = BlendPixels(bottom, _backgroundColor); - _buffer.Append($"{Constants.ESC}{Constants.VTFG}{topRgb.R};{topRgb.G};{topRgb.B}m".AsSpan()); - _buffer.Append($"{Constants.ESC}{Constants.VTBG}{bottomRgb.R};{bottomRgb.G};{bottomRgb.B}m{Constants.UpperHalfBlock}{Constants.ESC}[0m".AsSpan()); + else { + _buffer.AppendBlock(top.R, top.G, top.B, bottom.R, bottom.G, bottom.B); } } - private static (byte R, byte G, byte B) BlendPixels(Rgba32 pixel, Rgba32 _backgroundColor) - { - // If pixel is fully transparent, return the background color - if (IsTransparent(pixel)) - { - return (_backgroundColor.R, _backgroundColor.G, _backgroundColor.B); - } - - float amount = pixel.A / 255f; + private static void AppendSpace(this StringBuilder Builder) => Builder.Append(' '); - byte r = (byte)(pixel.R * amount + (_backgroundColor.R * (1 - amount))); - byte g = (byte)(pixel.G * amount + (_backgroundColor.G * (1 - amount))); - byte b = (byte)(pixel.B * amount + (_backgroundColor.B * (1 - amount))); - - return (r, g, b); - } private static bool IsTransparent(Rgba32 pixel) - { - // Calculate luminance for better edge artifact detection - float luminance = (0.299f * pixel.R + 0.587f * pixel.G + 0.114f * pixel.B) / 255f; - - // Consider pixels transparent if: - // 1. Alpha is very low (traditional transparency) - // 2. Alpha is low and pixel is very dark (common resizing artifacts) - // 3. Alpha is moderate and luminance is extremely low (aggressive edge artifact removal) - // 4. Alpha is low and color is close to pure black (black edge artifacts) - // 5. Very aggressive: moderately transparent with low luminance (catches most edge cases) - return pixel.A < 8 || - (pixel.A < 32 && luminance < 0.15f) || - (pixel.A < 64 && pixel.R < 12 && pixel.G < 12 && pixel.B < 12) || - (pixel.A < 128 && luminance < 0.05f) || - (pixel.A < 240 && luminance < 0.01f); + private static void AppendTopTransparent(this StringBuilder Builder, byte r, byte g, byte b) { + // "`e[38;2;{r};{g};{b}m▄`e[0m" + _ = Builder. + Append(Constants.ESC). + Append(Constants.VTFG). + Append(r).Append(';'). + Append(g).Append(';'). + Append(b).Append('m'). + Append(Constants.LowerHalfBlock). + Append(Constants.Reset); + } + private static void AppendBottomTransparent(this StringBuilder Builder, byte r, byte g, byte b) { + // "`e[38;2;{r};{g};{b}m▀`e[0m" + _ = Builder. + Append(Constants.ESC). + Append(Constants.VTFG). + Append(r).Append(';'). + Append(g).Append(';'). + Append(b).Append('m'). + Append(Constants.UpperHalfBlock). + Append(Constants.Reset); } - private static Rgba32 GetConsoleBackgroundColor() - { - var color = Console.BackgroundColor switch { - ConsoleColor.Black => Color.FromRgb(0, 0, 0), - ConsoleColor.Blue => Color.FromRgb(0, 0, 170), - ConsoleColor.Cyan => Color.FromRgb(0, 170, 170), - ConsoleColor.DarkBlue => Color.FromRgb(0, 0, 85), - ConsoleColor.DarkCyan => Color.FromRgb(0, 85, 85), - ConsoleColor.DarkGray => Color.FromRgb(85, 85, 85), - ConsoleColor.DarkGreen => Color.FromRgb(0, 85, 0), - ConsoleColor.DarkMagenta => Color.FromRgb(85, 0, 85), - ConsoleColor.DarkRed => Color.FromRgb(85, 0, 0), - ConsoleColor.DarkYellow => Color.FromRgb(85, 85, 0), - ConsoleColor.Gray => Color.FromRgb(170, 170, 170), - ConsoleColor.Green => Color.FromRgb(0, 170, 0), - ConsoleColor.Magenta => Color.FromRgb(170, 0, 170), - ConsoleColor.Red => Color.FromRgb(170, 0, 0), - ConsoleColor.White => Color.FromRgb(255, 255, 255), - ConsoleColor.Yellow => Color.FromRgb(170, 170, 0), - _ => Color.Transparent, - }; - return color.ToPixel(); + private static void AppendBlock(this StringBuilder Builder, byte tr, byte tg, byte tb, byte br, byte bg, byte bb) { + // "`e[38;2;{tr};{tg};{tb};48;2;{br};{bg};{bb}m▀`e[0m" + _ = Builder. + Append(Constants.ESC). + Append(Constants.VTFG). + Append(tr).Append(';'). + Append(tg).Append(';'). + Append(tb).Append(';'). + Append(48).Append(';'). + Append(2).Append(';'). + Append(br).Append(';'). + Append(bg).Append(';'). + Append(bb).Append('m'). + Append(Constants.UpperHalfBlock). + Append(Constants.Reset); } + private static bool IsTransparent(Rgba32 pixel) => pixel.A == 0; } diff --git a/src/Sixel/Protocols/Braille.cs b/src/Sixel/Protocols/Braille.cs new file mode 100644 index 0000000..ce10ab4 --- /dev/null +++ b/src/Sixel/Protocols/Braille.cs @@ -0,0 +1,93 @@ + +using System.Text; +using Sixel.Terminal; +using Sixel.Terminal.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Sixel.Protocols; + +public static class Braille { + public static (ImageSize Size, string Data) ImageToBraille(Image image, int maxCellWidth, int maxCellHeight) { + ImageSize imageSize = SizeHelperDev.GetBrailleTargetSize(image, maxCellWidth, maxCellHeight); + Image resizedImage = ResizerDev.ResizeForBraille(image, imageSize); + ImageFrame targetFrame = resizedImage.Frames[0]; + return (imageSize, ProcessFrameBraille(targetFrame)); + } + private static string ProcessFrameBraille(ImageFrame frame) { + var _buffer = new StringBuilder(); + int width = frame.Width; + int height = frame.Height; + + for (int y = 0; y < height; y += 4) { + for (int x = 0; x < width; x += 2) { + int dotBits = 0; + int colorWeightSum = 0; + int rSum = 0, gSum = 0, bSum = 0; + + for (int dx = 0; dx < 2; dx++) { + for (int dy = 0; dy < 4; dy++) { + int sampleX = x + dx; + int sampleY = y + dy; + + if (sampleX >= width || sampleY >= height) { + continue; + } + + Rgba32 px = frame[sampleX, sampleY]; + bool on = !IsTransparent(px); + if (on) { + int dotIndex = dx == 0 ? (dy == 0 ? 0 : dy == 1 ? 1 : dy == 2 ? 2 : 6) : (dy == 0 ? 3 : dy == 1 ? 4 : dy == 2 ? 5 : 7); + dotBits |= 1 << dotIndex; + int alphaWeight = px.A; + rSum += px.R * alphaWeight; + gSum += px.G * alphaWeight; + bSum += px.B * alphaWeight; + colorWeightSum += alphaWeight; + } + } + } + + if (dotBits == 0) { + _ = _buffer.Append(' '); + } + else { + int safeWeight = Math.Max(1, colorWeightSum); + byte R = (byte)(rSum / safeWeight); + byte G = (byte)(gSum / safeWeight); + byte B = (byte)(bSum / safeWeight); + int codepoint = 0x2800 + dotBits; + _buffer.AppendCodepoint(codepoint, R, G, B); + } + } + + _ = _buffer.AppendLine(); + } + + return _buffer.ToString(); + } + private static void AppendCodepoint(this StringBuilder Builder, int codepoint, byte r, byte g, byte b) { + // "`e[38;2;{r};{g};{b}m{codepoint}`e[0m" + _ = Builder. + Append(Constants.ESC). + Append(Constants.VTFG). + Append(r).Append(';'). + Append(g).Append(';'). + Append(b).Append('m'). + Append((char)codepoint). + Append(Constants.Reset); + } + private static bool IsTransparent(Rgba32 pixel) => pixel.A == 0; + private static bool IsTransparentAdv(Rgba32 pixel) { + if (pixel.A == 0) { + return true; + } + + float luminance = ((0.299f * pixel.R) + (0.587f * pixel.G) + (0.114f * pixel.B)) / 255f; + return pixel.A < 8 || + (pixel.A < 32 && luminance < 0.15f) || + (pixel.A < 64 && pixel.R < 12 && pixel.G < 12 && pixel.B < 12) || + (pixel.A < 128 && luminance < 0.05f) || + (pixel.A < 240 && luminance < 0.01f); + } +} diff --git a/src/Sixel/Protocols/InlineImageProtocol.cs b/src/Sixel/Protocols/InlineImageProtocol.cs index 15e0e77..e36245e 100644 --- a/src/Sixel/Protocols/InlineImageProtocol.cs +++ b/src/Sixel/Protocols/InlineImageProtocol.cs @@ -5,61 +5,55 @@ namespace Sixel.Protocols; -public static class InlineImage -{ - /// - /// Converts an image to an inline image protocol string. - /// - internal static string ImageToInline(Stream image, int width = 0, int height = 0) - { - byte[] imageBytes; - if (image.CanSeek) - { - // If the stream supports seeking, read it directly - image.Seek(0, SeekOrigin.Begin); - imageBytes = new byte[image.Length]; +public static class InlineImage { + /// + /// Converts an image to an inline image protocol string. + /// + internal static string ImageToInline(Stream image, int width = 0, int height = 0) { + byte[] imageBytes; + if (image.CanSeek) { + // If the stream supports seeking, read it directly + image.Seek(0, SeekOrigin.Begin); + imageBytes = new byte[image.Length]; #if NET472 - int bytesRead = 0; - int totalBytesRead = 0; - while (totalBytesRead < imageBytes.Length && - (bytesRead = image.Read(imageBytes, totalBytesRead, imageBytes.Length - totalBytesRead)) > 0) - { - totalBytesRead += bytesRead; - } - // Only resize if we couldn't read the full stream (very rare if Length is accurate) - if (totalBytesRead != imageBytes.Length) - { - Array.Resize(ref imageBytes, totalBytesRead); - } + int bytesRead = 0; + int totalBytesRead = 0; + while (totalBytesRead < imageBytes.Length && + (bytesRead = image.Read(imageBytes, totalBytesRead, imageBytes.Length - totalBytesRead)) > 0) { + totalBytesRead += bytesRead; + } + // Only resize if we couldn't read the full stream (very rare if Length is accurate) + if (totalBytesRead != imageBytes.Length) { + Array.Resize(ref imageBytes, totalBytesRead); + } #else - // Use ReadExactly in .NET 6+ or handle partial reads in older versions - image.ReadExactly(imageBytes, 0, imageBytes.Length); + // Use ReadExactly in .NET 6+ or handle partial reads in older versions + image.ReadExactly(imageBytes, 0, imageBytes.Length); #endif + } + else { + // For non-seekable streams, using CopyTo is already efficient + using MemoryStream ms = new(); + image.CopyTo(ms); + imageBytes = ms.ToArray(); + } + ReadOnlySpan base64Image = Convert.ToBase64String(imageBytes).AsSpan(); + string size = imageBytes.Length.ToString(CultureInfo.InvariantCulture); + string widthString = width > 0 ? $"width={width};" : "width=auto;"; + string heightString = height > 0 ? $"height={height};" : "height=auto;"; + StringBuilder iip = new(); + _ = iip.Append(Constants.HideCursor) + .Append(Constants.InlineImageStart) + .Append("1337;File=inline=1;") + .Append("size=" + size + ";") + .Append(widthString) + .Append(heightString) + .Append("preserveAspectRatio=1:") + // .Append("preserveAspectRatio=1;") + // .Append("doNotMoveCursor=1:") + .Append(base64Image) + .Append(Constants.InlineImageEnd) + .Append(Constants.ShowCursor); + return iip.ToString(); } - else - { - // For non-seekable streams, using CopyTo is already efficient - using MemoryStream ms = new(); - image.CopyTo(ms); - imageBytes = ms.ToArray(); - } - var base64Image = Convert.ToBase64String(imageBytes).AsSpan(); - string size = imageBytes.Length.ToString(CultureInfo.InvariantCulture); - string widthString = width > 0 ? $"width={width};" : "width=auto;"; - string heightString = height > 0 ? $"height={height};" : "height=auto;"; - StringBuilder iip = new(); - iip.Append(Constants.HideCursor) - .Append(Constants.InlineImageStart) - .Append("1337;File=inline=1;") - .Append("size=" + size + ";") - .Append(widthString) - .Append(heightString) - .Append("preserveAspectRatio=1:") - // .Append("preserveAspectRatio=1;") - // .Append("doNotMoveCursor=1:") - .Append(base64Image) - .Append(Constants.InlineImageEnd) - .Append(Constants.ShowCursor); - return iip.ToString(); - } } diff --git a/src/Sixel/Protocols/KittyGraphics.cs b/src/Sixel/Protocols/KittyGraphics.cs index c0b562d..9bcfb51 100644 --- a/src/Sixel/Protocols/KittyGraphics.cs +++ b/src/Sixel/Protocols/KittyGraphics.cs @@ -1,66 +1,53 @@ -using Sixel.Terminal; +using System.Text; +using Sixel.Terminal; using Sixel.Terminal.Models; -using System.Text; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Sixel.Protocols; -public static class KittyGraphics -{ - /// - /// Converts an image stream to Kitty Graphics Protocol format. - /// - /// The Kitty Graphics Protocol formatted string. - public static string ImageToKitty(Stream imageStream) - { - // Read the raw image data from the stream - using var ms = new MemoryStream(); - imageStream.CopyTo(ms); - var imageBytes = ms.ToArray(); - var base64Image = Convert.ToBase64String(imageBytes); - return ConvertToKittyGraphics(base64Image); - } - public static string ImageToKitty(Image image, ImageSize imageSize) - { - // Use Resizer to handle resizing - var resizedImage = Resizer.ResizeToCharacterCells(image, imageSize, 0); - // convert the resized image to base64 - using MemoryStream? ms = new(); - resizedImage.SaveAsPng(ms); - var imageBytes = ms.ToArray(); - var base64Image = Convert.ToBase64String(imageBytes); - return ConvertToKittyGraphics(base64Image); - } - private static string ConvertToKittyGraphics(string base64Image) - { - // basic implementation of kitty graphics protocol - StringBuilder sb = new(); - int pos = 0; - while (pos < base64Image.Length) - { - sb.Append(Constants.KittyStart); - if (pos == 0) - { - sb.Append(Constants.KittyPos); - } - int remaining = base64Image.Length - pos; - string chunk = base64Image.Substring(pos, Math.Min(Constants.KittychunkSize, remaining)); - pos += chunk.Length; - if (pos < base64Image.Length) - { - sb.Append(Constants.KittyMore); - } - else - { - sb.Append(Constants.KittyFinish); - } - sb.Append(Constants.Divider) - .Append(chunk) - .Append(Constants.ST); +public static class KittyGraphics { + /// + /// Converts an image stream to Kitty Graphics Protocol format. + /// + /// The Kitty Graphics Protocol formatted string. + public static string ImageToKitty(Stream imageStream) { + // Read the raw image data from the stream + using var ms = new MemoryStream(); + imageStream.CopyTo(ms); + byte[] imageBytes = ms.ToArray(); + string base64Image = Convert.ToBase64String(imageBytes); + return ConvertToKittyGraphics(base64Image); } + public static string ImageToKitty(Image image, ImageSize imageSize) { + // Use Resizer to handle resizing + Image resizedImage = Resizer.ResizeToCharacterCells(image, imageSize, 0); + // convert the resized image to base64 + using MemoryStream? ms = new(); + resizedImage.SaveAsPng(ms); + byte[] imageBytes = ms.ToArray(); + string base64Image = Convert.ToBase64String(imageBytes); + return ConvertToKittyGraphics(base64Image); + } + private static string ConvertToKittyGraphics(string base64Image) { + // basic implementation of kitty graphics protocol + StringBuilder sb = new(); + int pos = 0; + while (pos < base64Image.Length) { + _ = sb.Append(Constants.KittyStart); + if (pos == 0) { + _ = sb.Append(Constants.KittyPos); + } + int remaining = base64Image.Length - pos; + string chunk = base64Image.Substring(pos, Math.Min(Constants.KittychunkSize, remaining)); + pos += chunk.Length; + _ = pos < base64Image.Length ? sb.Append(Constants.KittyMore) : sb.Append(Constants.KittyFinish); + _ = sb.Append(Constants.Divider) + .Append(chunk) + .Append(Constants.ST); + } - return sb.ToString(); - } + return sb.ToString(); + } } diff --git a/src/Sixel/Protocols/Sixel.cs b/src/Sixel/Protocols/Sixel.cs index e187ba6..ee132c5 100644 --- a/src/Sixel/Protocols/Sixel.cs +++ b/src/Sixel/Protocols/Sixel.cs @@ -1,167 +1,149 @@ -using Sixel.Terminal; +using System.Text; +using Sixel.Terminal; using Sixel.Terminal.Models; -using System.Text; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; namespace Sixel.Protocols; -public static class Sixel -{ - /// - /// Converts an image to a Sixel string. - /// - /// The image to convert. - /// The size of the image in character cells. - /// The Max colors of the image. - public static string ImageToSixel(Image image, ImageSize imageSize, int maxColors) - { - // Use Resizer to handle resizing and quantization - var resizedImage = Resizer.ResizeToCharacterCells(image, imageSize, maxColors); - var targetFrame = resizedImage.Frames[0]; - return FrameToSixelString(targetFrame); - } - internal static string FrameToSixelString(ImageFrame frame) - { - // Pre-allocate StringBuilder with estimated capacity for better performance - var estimatedSize = frame.Width * frame.Height / 4; // Rough estimate based on compression - var sixelBuilder = new StringBuilder(estimatedSize); - var sixel = new StringBuilder(estimatedSize / 2); - var palette = new Dictionary(256); // Pre-size for typical max colors - var colorCounter = 1; - sixel.StartSixel(frame.Width, frame.Height); - frame.ProcessPixelRows(accessor => - { - for (var y = 0; y < accessor.Height; y++) - { - var pixelRow = accessor.GetRowSpan(y); - // The value of 1 left-shifted by the remainder of the current row divided by 6 gives the correct sixel character offset from the empty sixel char for each row. - // See the description of s...s for more detail on the sixel format https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1 +public static class Sixel { + /// + /// Converts an image to a Sixel string. + /// + /// The image to convert. + /// The size of the image in character cells. + /// The Max colors of the image. + public static string ImageToSixel(Image image, ImageSize imageSize, int maxColors) { + // Use Resizer to handle resizing and quantization + Image resizedImage = Resizer.ResizeToCharacterCells(image, imageSize, maxColors); + ImageFrame targetFrame = resizedImage.Frames[0]; + return FrameToSixelString(targetFrame); + } + internal static string FrameToSixelString(ImageFrame frame) { + // Pre-allocate StringBuilder with estimated capacity for better performance + int estimatedSize = frame.Width * frame.Height / 4; // Rough estimate based on compression + var sixelBuilder = new StringBuilder(estimatedSize); + var sixel = new StringBuilder(estimatedSize / 2); + var palette = new Dictionary(256); // Pre-size for typical max colors + int colorCounter = 1; + sixel.StartSixel(frame.Width, frame.Height); + frame.ProcessPixelRows(accessor => { + for (int y = 0; y < accessor.Height; y++) { + Span pixelRow = accessor.GetRowSpan(y); + // The value of 1 left-shifted by the remainder of the current row divided by 6 gives the correct sixel character offset from the empty sixel char for each row. + // See the description of s...s for more detail on the sixel format https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1 - // modulus trick from https://github.com/sxyazi/yazi/blob/main/yazi-adapter/src/sixel.rs (MIT) - var c = (char)('?' + (1 << (y % 6))); - var lastColor = -1; - var repeatCounter = 0; - foreach (ref var pixel in pixelRow) - { - if (!palette.TryGetValue(pixel, out var colorIndex)) - { - // The colors can be added to the palette and interleaved with the sixel data so long as the color is defined before it is used. - // for compatibility testing im not doing this at the moment. - colorIndex = colorCounter++; - palette[pixel] = colorIndex; - sixel.AddColorToPalette(pixel, colorIndex); - } + // modulus trick from https://github.com/sxyazi/yazi/blob/main/yazi-adapter/src/sixel.rs (MIT) + char c = (char)('?' + (1 << (y % 6))); + int lastColor = -1; + int repeatCounter = 0; + foreach (ref Rgba32 pixel in pixelRow) { + if (!palette.TryGetValue(pixel, out int colorIndex)) { + // The colors can be added to the palette and interleaved with the sixel data so long as the color is defined before it is used. + // for compatibility testing im not doing this at the moment. + colorIndex = colorCounter++; + palette[pixel] = colorIndex; + sixel.AddColorToPalette(pixel, colorIndex); + } - // Transparency is a special color index of 0 that exists in our sixel palette. - var colorId = pixel.A == 0 ? 0 : colorIndex; + // Transparency is a special color index of 0 that exists in our sixel palette. + int colorId = pixel.A == 0 ? 0 : colorIndex; - // Sixel data will use a repeat entry if the color is the same as the last one. - // https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1 - if (colorId == lastColor || repeatCounter == 0) - { - // If the color was repeated go to the next loop iteration to check the next pixel. - lastColor = colorId; - repeatCounter++; - continue; - } + // Sixel data will use a repeat entry if the color is the same as the last one. + // https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1 + if (colorId == lastColor || repeatCounter == 0) { + // If the color was repeated go to the next loop iteration to check the next pixel. + lastColor = colorId; + repeatCounter++; + continue; + } - // Every time the color is not repeated the previous color is written to the string. - sixelBuilder.AppendSixel(lastColor, repeatCounter, c); + // Every time the color is not repeated the previous color is written to the string. + sixelBuilder.AppendSixel(lastColor, repeatCounter, c); - // Remember the current color and reset the repeat counter. - lastColor = colorId; - repeatCounter = 1; - } + // Remember the current color and reset the repeat counter. + lastColor = colorId; + repeatCounter = 1; + } - // Write the last color and repeat counter to the string for the current row. - sixelBuilder.AppendSixel(lastColor, repeatCounter, c); + // Write the last color and repeat counter to the string for the current row. + sixelBuilder.AppendSixel(lastColor, repeatCounter, c); - // Add a carriage return at the end of each row and a new line every 6 pixel rows. - sixelBuilder.AppendCarriageReturn(); - if (y % 6 == 5) - { - sixelBuilder.AppendNextLine(); - } - } - }); - sixelBuilder.AppendNextLine(); - sixelBuilder.AppendExitSixel(); + // Add a carriage return at the end of each row and a new line every 6 pixel rows. + sixelBuilder.AppendCarriageReturn(); + if (y % 6 == 5) { + sixelBuilder.AppendNextLine(); + } + } + }); + sixelBuilder.AppendNextLine(); + sixelBuilder.AppendExitSixel(); - return sixel.Append(sixelBuilder).ToString(); - } + return sixel.Append(sixelBuilder).ToString(); + } - private static void AddColorToPalette(this StringBuilder sixelBuilder, Rgba32 pixel, int colorIndex) - { - // rgb 0-255 needs to be translated to 0-100 for sixel. - var (r, g, b) = ( - pixel.R * 100 / 255, - pixel.G * 100 / 255, - pixel.B * 100 / 255 - ); + private static void AddColorToPalette(this StringBuilder sixelBuilder, Rgba32 pixel, int colorIndex) { + // rgb 0-255 needs to be translated to 0-100 for sixel. + (int r, int g, int b) = ( + pixel.R * 100 / 255, + pixel.G * 100 / 255, + pixel.B * 100 / 255 + ); - sixelBuilder - .Append(Constants.SixelColorStart) - .Append(colorIndex) - .Append(Constants.SixelColorParam) - .Append(r) - .Append(Constants.Divider) - .Append(g) - .Append(Constants.Divider) - .Append(b); - } - private static void AppendSixel(this StringBuilder sixelBuilder, int colorIndex, int repeatCounter, char sixel) - { - if (colorIndex == 0) - { - // Transparent pixels are a special case and are always 0 in the palette. - sixel = Constants.SixelTransparent; + _ = sixelBuilder + .Append(Constants.SixelColorStart) + .Append(colorIndex) + .Append(Constants.SixelColorParam) + .Append(r) + .Append(Constants.Divider) + .Append(g) + .Append(Constants.Divider) + .Append(b); } - if (repeatCounter <= 1) - { - // single entry - sixelBuilder - .Append(Constants.SixelColorStart) - .Append(colorIndex) - .Append(sixel); + private static void AppendSixel(this StringBuilder sixelBuilder, int colorIndex, int repeatCounter, char sixel) { + if (colorIndex == 0) { + // Transparent pixels are a special case and are always 0 in the palette. + sixel = Constants.SixelTransparent; + } + if (repeatCounter <= 1) { + // single entry + _ = sixelBuilder + .Append(Constants.SixelColorStart) + .Append(colorIndex) + .Append(sixel); + } + else { + // add repeats + _ = sixelBuilder + .Append(Constants.SixelColorStart) + .Append(colorIndex) + .Append(Constants.SixelRepeat) + .Append(repeatCounter) + .Append(sixel); + } } - else - { - // add repeats - sixelBuilder - .Append(Constants.SixelColorStart) - .Append(colorIndex) - .Append(Constants.SixelRepeat) - .Append(repeatCounter) - .Append(sixel); + private static void AppendCarriageReturn(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(Constants.SixelDECGCR); } - } - private static void AppendCarriageReturn(this StringBuilder sixelBuilder) - { - sixelBuilder - .Append(Constants.SixelDECGCR); - } - private static void AppendNextLine(this StringBuilder sixelBuilder) - { - sixelBuilder - .Append(Constants.SixelDECGNL); - } + private static void AppendNextLine(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(Constants.SixelDECGNL); + } - private static void AppendExitSixel(this StringBuilder sixelBuilder) - { - sixelBuilder - .Append(Constants.ST); - } + private static void AppendExitSixel(this StringBuilder sixelBuilder) { + _ = sixelBuilder + .Append(Constants.ST); + } - private static void StartSixel(this StringBuilder sixelBuilder, int width, int height) - { - sixelBuilder - .Append(Constants.SixelStart) - .Append(Constants.SixelRaster) - .Append(width) - .Append(Constants.Divider) - .Append(height) - .Append(Constants.SixelTransparentColor); - } + private static void StartSixel(this StringBuilder sixelBuilder, int width, int height) { + _ = sixelBuilder + .Append(Constants.SixelStart) + .Append(Constants.SixelRaster) + .Append(width) + .Append(Constants.Divider) + .Append(height) + .Append(Constants.SixelTransparentColor); + } } diff --git a/src/Sixel/Protocols/gif.cs b/src/Sixel/Protocols/gif.cs index 1fa3acf..b8a032b 100644 --- a/src/Sixel/Protocols/gif.cs +++ b/src/Sixel/Protocols/gif.cs @@ -1,6 +1,7 @@ using Sixel.Terminal; using Sixel.Terminal.Models; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.PixelFormats; @@ -9,94 +10,73 @@ namespace Sixel.Protocols; /// /// Provides methods to convert GIF images to Sixel format for terminal display, including resizing and optional audio support. /// -public static class GifToSixel -{ +public static class GifToSixel { + public static SixelGif ConvertGif(Stream imageStream, int maxColors, int cellWidth, int LoopCount) { + using var image = Image.Load(imageStream); - public static SixelGif ConvertGif(Stream imageStream, int maxColors, int cellWidth, int LoopCount) - { - using var image = Image.Load(imageStream); - - ImageSize imageSize; - if (cellWidth > 0) - { - imageSize = SizeHelper.GetResizedCharacterCellSize(image, cellWidth, 0); - } - else - { - imageSize = SizeHelper.ConvertToCharacterCells(image); + ImageSize imageSize = cellWidth > 0 ? SizeHelper.GetResizedCharacterCellSize(image, cellWidth, 0) : SizeHelper.ConvertToCharacterCells(image); + return ConvertGifToSixel(image, imageSize, maxColors, LoopCount); } - return ConvertGifToSixel(image, imageSize, maxColors, LoopCount); - } - - private static SixelGif ConvertGifToSixel(Image image, ImageSize imageSize, int maxColors, int LoopCount) - { - // Use Resizer to handle resizing and quantization - var resizedImage = Resizer.ResizeToCharacterCells(image, imageSize, maxColors); - var metadata = resizedImage.Frames.RootFrame.Metadata.GetGifMetadata(); - int frameCount = resizedImage.Frames.Count; + private static SixelGif ConvertGifToSixel(Image image, ImageSize imageSize, int maxColors, int LoopCount) { + // Use Resizer to handle resizing and quantization + Image resizedImage = Resizer.ResizeToCharacterCells(image, imageSize, maxColors); + GifFrameMetadata metadata = resizedImage.Frames.RootFrame.Metadata.GetGifMetadata(); + int frameCount = resizedImage.Frames.Count; - // Derive final cell size from actual resized pixels to avoid drift vs. requested imageSize - var finalSize = SizeHelper.GetCharacterCellSize(resizedImage); + // Derive final cell size from actual resized pixels to avoid drift vs. requested imageSize + ImageSize finalSize = SizeHelper.GetCharacterCellSize(resizedImage); - var gif = new SixelGif() { - Sixel = new List(frameCount), // Pre-allocate capacity for better performance - Delay = metadata?.FrameDelay * 10 ?? 1000, - LoopCount = LoopCount, - Height = finalSize.Height, - Width = finalSize.Width, - }; + var gif = new SixelGif() { + Sixel = new List(frameCount), // Pre-allocate capacity for better performance + Delay = (metadata?.FrameDelay * 10) ?? 1000, + LoopCount = LoopCount, + Height = finalSize.Height, + Width = finalSize.Width, + }; - // Pre-allocate and process frames efficiently - for (int i = 0; i < frameCount; i++) - { - var targetFrame = resizedImage.Frames[i]; - gif.Sixel.Add(Sixel.FrameToSixelString(targetFrame)); + // Pre-allocate and process frames efficiently + for (int i = 0; i < frameCount; i++) { + ImageFrame targetFrame = resizedImage.Frames[i]; + gif.Sixel.Add(Sixel.FrameToSixelString(targetFrame)); + } + return gif; } - return gif; - } - public static void PlaySixelGif(SixelGif gif, CancellationToken CT = default) - { - Console.CursorVisible = false; - var writer = new VTWriter(); + public static void PlaySixelGif(SixelGif gif, CancellationToken CT = default) { + Console.CursorVisible = false; + var writer = new VTWriter(); - try - { - // create space in the buffer for the image, so it doesn't scroll the terminal. - for (int i = 0; i < gif.Height + 1; i++) - { - writer.Write(Environment.NewLine); - } + try { + // create space in the buffer for the image, so it doesn't scroll the terminal. + for (int i = 0; i < gif.Height + 1; i++) { + writer.Write(Environment.NewLine); + } - // Move cursor back up to starting position - writer.Write($"{Constants.ESC}[{gif.Height}A"); + // Move cursor back up to starting position + writer.Write($"{Constants.ESC}[{gif.Height}A"); - // DECSC - Save cursor position - writer.Write($"{Constants.ESC}7"); + // DECSC - Save cursor position + writer.Write($"{Constants.ESC}7"); - for (int i = 0; i < gif.LoopCount; i++) - { - foreach (var sixel in gif.Sixel) - { - if (CT.IsCancellationRequested) - { - return; - } - // DECRC - Restore cursor position - writer.Write($"{Constants.ESC}8"); - writer.Write(sixel); - Thread.Sleep(gif.Delay); + for (int i = 0; i < gif.LoopCount; i++) { + foreach (string sixel in gif.Sixel) { + if (CT.IsCancellationRequested) { + return; + } + // DECRC - Restore cursor position + writer.Write($"{Constants.ESC}8"); + writer.Write(sixel); + Thread.Sleep(gif.Delay); + } + } + } + finally { + // DECRC - Restore to image start + writer.Write($"{Constants.ESC}8"); + // Move down below image, subtract 1 line to compensate for powershell format engine. + writer.Write($"{Constants.ESC}[{gif.Height - 1}B"); + writer?.Dispose(); + Console.CursorVisible = true; } - } - } - finally - { - // DECRC - Restore to image start - writer.Write($"{Constants.ESC}8"); - // Move down below image, subtract 1 line to compensate for powershell format engine. - writer.Write($"{Constants.ESC}[{gif.Height - 1}B"); - writer?.Dispose(); - Console.CursorVisible = true; } - } } diff --git a/src/Sixel/Sixel.csproj b/src/Sixel/Sixel.csproj index deaaf21..7261352 100644 --- a/src/Sixel/Sixel.csproj +++ b/src/Sixel/Sixel.csproj @@ -1,40 +1,38 @@ - - Sixel - net472;net8.0 - enable - enable - - 13.0 - 0.6.1 - Recommended - true - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - + + Sixel + net472;net8.0 + enable + enable + 13.0 + + + true + true + latest-Recommended + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + - diff --git a/src/Sixel/Terminal/Compatibility.cs b/src/Sixel/Terminal/Compatibility.cs index b04012b..d9cc09b 100644 --- a/src/Sixel/Terminal/Compatibility.cs +++ b/src/Sixel/Terminal/Compatibility.cs @@ -1,248 +1,320 @@ -using System.Diagnostics; -using System.Globalization; -using System.Text; -using System.Threading; -using Sixel.Terminal.Models; - -namespace Sixel.Terminal; - -/// -/// Provides methods and cached properties for detecting terminal compatibility, supported protocols, and cell/window sizes. -/// -public static class Compatibility -{ - /// - /// Memory-caches the result of the terminal supporting sixel graphics. - /// - internal static bool? _terminalSupportsSixel; - - /// - /// Check if the terminal supports kitty graphics - /// - internal static bool? _terminalSupportsKitty; - - /// - /// Memory-caches the result of the terminal cell size. - /// - private static CellSize? _cellSize; - - /// - /// get the terminal info - /// - private static TerminalInfo? _terminalInfo; - - /// - /// Get the response to a control sequence. - /// Only queries when it's safe to do so (no pending input, not redirected). - /// - public static string GetControlSequenceResponse(string controlSequence) - { - if (Console.IsOutputRedirected || Console.IsInputRedirected) - { - return string.Empty; - } - - try - { - var response = new StringBuilder(); - const int timeoutMs = 1000; - - // Send the control sequence - Console.Write($"{Constants.ESC}{controlSequence}"); - var stopwatch = Stopwatch.StartNew(); - - while (stopwatch.ElapsedMilliseconds < timeoutMs) - { - if (!Console.KeyAvailable) - { - Thread.Sleep(1); // Small sleep instead of Yield for more predictable timing - continue; - } - - var keyInfo = Console.ReadKey(true); - char key = keyInfo.KeyChar; - response.Append(key); - - // Check if we have a complete response - if (IsCompleteResponse(response)) - { - break; - } - } - - return response.ToString(); - } - catch (Exception) - { - return string.Empty; - } - } - - - /// - /// Check for complete terminal responses - /// - private static bool IsCompleteResponse(StringBuilder response) - { - int length = response.Length; - if (length < 2) return false; - - // Look for common terminal response endings - char lastChar = response[length - 1]; - - // Most VT terminal responses end with specific letters - switch (lastChar) - { - case 'c': // Device Attributes (ESC[...c) - case 'R': // Cursor Position Report (ESC[row;columnR) - case 't': // Window manipulation (ESC[...t) - case 'n': // Device Status Report (ESC[...n) - case 'y': // DECRPM response (ESC[?...y) - // Make sure it's actually a CSI sequence (ESC[) - return length >= 3 && response[0] == '\x1b' && response[1] == '['; - - case '\\': // String Terminator (ESC\) - return length >= 2 && response[length - 2] == '\x1b'; - - case (char)7: // BEL character - return true; - - default: - // Check for Kitty graphics protocol: ends with ";OK" followed by ST and then another response - if (length >= 7) // Minimum for ";OK" + ESC\ + ESC[...c - { - // Look for ";OK" pattern - bool hasOK = false; - for (int i = 0; i <= length - 3; i++) - { - if (response[i] == ';' && i + 2 < length && - response[i + 1] == 'O' && response[i + 2] == 'K') - { - hasOK = true; - break; - } - } - - if (hasOK) - { - // Look for ESC\ (String Terminator) - int stIndex = -1; - for (int i = 0; i < length - 1; i++) - { - if (response[i] == '\x1b' && response[i + 1] == '\\') - { - stIndex = i; - break; - } - } - - if (stIndex >= 0 && stIndex + 2 < length) - { - // Check if there's a complete response after the ST - int afterSTStart = stIndex + 2; - int afterSTLength = length - afterSTStart; - if (afterSTLength >= 3 && - response[afterSTStart] == '\x1b' && - response[afterSTStart + 1] == '[') - { - char afterSTLast = response[length - 1]; - return afterSTLast == 'c' || - afterSTLast == 'R' || - afterSTLast == 't' || - afterSTLast == 'n' || - afterSTLast == 'y'; - } - } - } - } - return false; - } - } - - /// - /// Get the cell size of the terminal in pixel-sixel size. - /// The response to the command will look like [6;20;10t where the 20 is height and 10 is width. - /// I think the 6 is the terminal class, which is not used here. - /// - /// The number of pixel sixels that will fit in a single character cell. - public static CellSize GetCellSize() - { - if (_cellSize is not null) - { - return _cellSize; - } - - var response = GetControlSequenceResponse("[16t"); - - try - { - var parts = response.Split(';', 't'); - _cellSize = new CellSize { - PixelWidth = int.Parse(parts[2], NumberStyles.Number, - CultureInfo.InvariantCulture), - PixelHeight = int.Parse(parts[1], NumberStyles.Number, - CultureInfo.InvariantCulture) - }; - } - catch - { - // Return the default Windows Terminal size if we can't get the size from the terminal. - _cellSize = new CellSize { - PixelWidth = 10, - PixelHeight = 20 - }; - } - return _cellSize; - } - - /// - /// Check if the terminal supports sixel graphics. - /// This is done by sending the terminal a Device Attributes request. - /// If the terminal responds with a response that contains ";4;" then it supports sixel graphics. - /// https://vt100.net/docs/vt510-rm/DA1.html - /// - /// True if the terminal supports sixel graphics, false otherwise. - public static bool TerminalSupportsSixel() - { - if (_terminalSupportsSixel.HasValue) - { - return _terminalSupportsSixel.Value; - } - var response = GetControlSequenceResponse("[c"); - _terminalSupportsSixel = response.Contains(";4;") || response.Contains(";4c"); - return _terminalSupportsSixel.Value; - } - - /// - /// Check if the terminal supports kitty graphics. - /// https://sw.kovidgoyal.net/kitty/graphics-protocol/ - /// response: ␛_Gi=31;OK␛\␛[?62;c - /// - /// True if the terminal supports kitty graphics, false otherwise. - public static bool TerminalSupportsKitty() - { - if (_terminalSupportsKitty.HasValue) - { - return _terminalSupportsKitty.Value; - } - string kittyTest = $"_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA{Constants.ST}{Constants.ESC}[c"; - _terminalSupportsKitty = GetControlSequenceResponse(kittyTest).Contains(";OK"); - return _terminalSupportsKitty.Value; - } - - /// - /// Get the terminal info - /// - /// The terminal protocol - public static TerminalInfo GetTerminalInfo() - { - if (_terminalInfo != null) - { - return _terminalInfo; - } - - _terminalInfo = TerminalChecker.CheckTerminal(); - return _terminalInfo; - } - -} +using System; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Sixel.Terminal.Models; + + +namespace Sixel.Terminal; + +/// +/// Provides methods and cached properties for detecting terminal compatibility, supported protocols, and cell/window sizes. +/// +public static partial class Compatibility { + /// + /// Memory-caches the result of the terminal supporting sixel graphics. + /// + internal static bool? _terminalSupportsSixel; + + /// + /// Check if the terminal supports kitty graphics + /// + internal static bool? _terminalSupportsKitty; + + /// + /// Memory-caches the result of the terminal cell size. + /// + private static CellSize? _cellSize; + + private static int? _lastWindowWidth; + private static int? _lastWindowHeight; + + /// + /// get the terminal info + /// + private static TerminalInfo? _terminalInfo; + + /// + /// Get the response to a control sequence. + /// Only queries when it's safe to do so (no pending input, not redirected). + /// Retries up to 2 times with 500ms timeout each. + /// + public static string GetControlSequenceResponse(string controlSequence) { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return string.Empty; + } + + const int timeoutMs = 500; + const int maxRetries = 2; + + for (int retry = 0; retry < maxRetries; retry++) { + try { + var response = new StringBuilder(); + bool capturing = false; + + // Send the control sequence + Console.Write($"{Constants.ESC}{controlSequence}"); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < timeoutMs) { + if (!Console.KeyAvailable) { + Thread.Sleep(1); + continue; + } + + ConsoleKeyInfo keyInfo = Console.ReadKey(true); + char key = keyInfo.KeyChar; + + if (!capturing) { + if (key != '\x1b') { + continue; + } + capturing = true; + } + + response.Append(key); + + // Check if we have a complete response + if (IsCompleteResponse(response)) { + return response.ToString(); + } + } + + // If we got a partial response, return it + if (response.Length > 0) { + return response.ToString(); + } + } + catch (Exception) { + if (retry == maxRetries - 1) { + return string.Empty; + } + } + } + + return string.Empty; + } + + + /// + /// Check for complete terminal responses + /// + private static bool IsCompleteResponse(StringBuilder response) { + int length = response.Length; + if (length < 2) return false; + + + // Most VT terminal responses end with specific letters + switch (response[length - 1]) { + case 'c': // Device Attributes (ESC[...c) + case 'R': // Cursor Position Report (ESC[row;columnR) + case 't': // Window manipulation (ESC[...t) + case 'n': // Device Status Report (ESC[...n) + case 'y': // DECRPM response (ESC[?...y) + // Make sure it's actually a CSI sequence (ESC[) + return length >= 3 && response[0] == '\x1b' && response[1] == '['; + + case '\\': // String Terminator (ESC\) + return length >= 2 && response[length - 2] == '\x1b'; + + case (char)7: // BEL character + return true; + + default: + // Check for Kitty graphics protocol: ends with ";OK" followed by ST and then another response + if (length >= 7) // Minimum for ";OK" + ESC\ + ESC[...c + { + // Look for ";OK" pattern + bool hasOK = false; + for (int i = 0; i <= length - 3; i++) { + if (response[i] == ';' && i + 2 < length && + response[i + 1] == 'O' && response[i + 2] == 'K') { + hasOK = true; + break; + } + } + + if (hasOK) { + // Look for ESC\ (String Terminator) + int stIndex = -1; + for (int i = 0; i < length - 1; i++) { + if (response[i] == '\x1b' && response[i + 1] == '\\') { + stIndex = i; + break; + } + } + + if (stIndex >= 0 && stIndex + 2 < length) { + // Check if there's a complete response after the ST + int afterSTStart = stIndex + 2; + int afterSTLength = length - afterSTStart; + if (afterSTLength >= 3 && + response[afterSTStart] == '\x1b' && + response[afterSTStart + 1] == '[') { + char afterSTLast = response[length - 1]; + return afterSTLast is 'c' or + 'R' or + 't' or + 'n' or + 'y'; + } + } + } + } + return false; + } + } + + /// + /// Get the cell size of the terminal in pixel-sixel size. + /// The response to the command will look like [6;20;10t where the 20 is height and 10 is width. + /// I think the 6 is the terminal class, which is not used here. + /// + /// The number of pixel sixels that will fit in a single character cell. + public static CellSize GetCellSize() { + if (_cellSize is not null && !HasWindowSizeChanged()) { + return _cellSize; + } + + _cellSize = null; + string response = GetControlSequenceResponse("[16t"); + + try { + string[] parts = response.Split(';', 't'); + if (parts.Length >= 3) { + int width = int.Parse(parts[2], NumberStyles.Number, CultureInfo.InvariantCulture); + int height = int.Parse(parts[1], NumberStyles.Number, CultureInfo.InvariantCulture); + + // Validate the parsed values are reasonable + if (IsValidCellSize(width, height)) { + _cellSize = new CellSize { + PixelWidth = width, + PixelHeight = height + }; + UpdateWindowSizeSnapshot(); + return _cellSize; + } + } + } + catch { + // Fall through to platform-specific fallback + } + + // Platform-specific fallback values + _cellSize = GetPlatformDefaultCellSize(); + UpdateWindowSizeSnapshot(); + return _cellSize; + } + + /// + /// Minimal validation: only ensures positive integer values. + /// Terminal-reported cell sizes are treated as ground truth. + /// + private static bool IsValidCellSize(int width, int height) + => width > 0 && height > 0; + + + /// + /// Returns platform-specific default cell size as fallback. + /// + private static CellSize GetPlatformDefaultCellSize() { + // Common terminal default sizes by platform + // macOS terminals (especially with Retina) often use 10x20 + // Windows Terminal: 10x20 + // Linux varies: 8x16 to 10x20 + + return new CellSize { + PixelWidth = 10, + PixelHeight = 20 + }; + } + + private static bool HasWindowSizeChanged() { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return false; + } + + try { + int currentWidth = Console.WindowWidth; + int currentHeight = Console.WindowHeight; + + return _lastWindowWidth.HasValue && + _lastWindowHeight.HasValue && + (_lastWindowWidth.Value != currentWidth || _lastWindowHeight.Value != currentHeight); + } + catch { + return false; + } + } + + private static void UpdateWindowSizeSnapshot() { + if (Console.IsOutputRedirected || Console.IsInputRedirected) { + return; + } + + try { + _lastWindowWidth = Console.WindowWidth; + _lastWindowHeight = Console.WindowHeight; + } + catch { + _lastWindowWidth = null; + _lastWindowHeight = null; + } + } + + /// + /// Check if the terminal supports sixel graphics. + /// This is done by sending the terminal a Device Attributes request. + /// If the terminal responds with a response that contains ";4;" then it supports sixel graphics. + /// https://vt100.net/docs/vt510-rm/DA1.html + /// + /// True if the terminal supports sixel graphics, false otherwise. + public static bool TerminalSupportsSixel() { + if (_terminalSupportsSixel.HasValue) { + return _terminalSupportsSixel.Value; + } + string response = GetControlSequenceResponse("[c"); + _terminalSupportsSixel = response.Contains(";4;") || response.Contains(";4c"); + return _terminalSupportsSixel.Value; + } + + /// + /// Check if the terminal supports kitty graphics. + /// https://sw.kovidgoyal.net/kitty/graphics-protocol/ + /// response: ␛_Gi=31;OK␛\␛[?62;c + /// + /// True if the terminal supports kitty graphics, false otherwise. + public static bool TerminalSupportsKitty() { + if (_terminalSupportsKitty.HasValue) { + return _terminalSupportsKitty.Value; + } + string kittyTest = $"_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA{Constants.ST}{Constants.ESC}[c"; + _terminalSupportsKitty = GetControlSequenceResponse(kittyTest).Contains(";OK"); + return _terminalSupportsKitty.Value; + } + + /// + /// Get the terminal info + /// + /// The terminal protocol + public static TerminalInfo GetTerminalInfo() { + if (_terminalInfo is not null) { + return _terminalInfo; + } + _terminalInfo = TerminalChecker.CheckTerminal(); + return _terminalInfo; + } + +#if NET7_0_OR_GREATER + [GeneratedRegex(@"^data:image/\w+;base64,", RegexOptions.IgnoreCase, 1000)] + internal static partial Regex Base64Image(); +#else + internal static Regex Base64Image() => + new(@"^data:image/\w+;base64,", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1)); +#endif + internal static string TrimBase64(string b64) + => Base64Image().Replace(b64, string.Empty); + +} diff --git a/src/Sixel/Terminal/Constants.cs b/src/Sixel/Terminal/Constants.cs index e21cdce..6422d82 100644 --- a/src/Sixel/Terminal/Constants.cs +++ b/src/Sixel/Terminal/Constants.cs @@ -3,8 +3,7 @@ namespace Sixel.Terminal; /// /// Contains constants, values and helpers for Sixel terminal compatibility and encoding. /// -internal static class Constants -{ +internal static class Constants { /// /// The character to use when entering a terminal escape code sequence. /// Optimized as char for better performance in StringBuilder operations. @@ -185,4 +184,6 @@ internal static class Constants /// See DECRQM (request) and DECRPM (response) for more details. /// internal static readonly string DECRQM2026 = $"{ESC}[?2026$p"; + + internal static readonly string Reset = $"{ESC}[0m"; } diff --git a/src/Sixel/Terminal/ConvertTo.cs b/src/Sixel/Terminal/ConvertTo.cs index faf9151..f599897 100644 --- a/src/Sixel/Terminal/ConvertTo.cs +++ b/src/Sixel/Terminal/ConvertTo.cs @@ -10,8 +10,7 @@ namespace Sixel.Terminal; /// /// Provides methods to load and convert images to terminal-compatible formats using various image protocols. /// -public static class ConvertTo -{ +public static class ConvertTo { /// /// Load an image and convert it to a terminal compatible format. /// @@ -22,6 +21,7 @@ public static class ConvertTo /// The target height in character cells, or 0 to maintain aspect ratio. /// Whether to force conversion even if terminal doesn't support the protocol. /// A tuple containing the image size and the converted image data. + /// public static (ImageSize Size, string Data) ConsoleImage( ImageProtocol imageProtocol, Stream imageStream, @@ -29,68 +29,57 @@ public static (ImageSize Size, string Data) ConsoleImage( int width = 0, int height = 0, bool Force = false - ) - { + ) { /// this is a guess at the protocol based on the environment variables and VT responses. /// the parameter `imageProtocol` is the chosen protocol, we need to see if that is supported. - var autoProtocol = Compatibility.GetTerminalInfo().Protocol; + ImageProtocol[] autoProtocol = Compatibility.GetTerminalInfo().Protocol; // Improved: If Auto, select the best supported protocol by priority (Kitty > Sixel > Inline > Blocks) ImageProtocol protocol = imageProtocol; - if (imageProtocol == ImageProtocol.Auto) - { - if (autoProtocol.Contains(ImageProtocol.KittyGraphicsProtocol)) - protocol = ImageProtocol.KittyGraphicsProtocol; - else if (autoProtocol.Contains(ImageProtocol.Sixel)) - protocol = ImageProtocol.Sixel; - else if (autoProtocol.Contains(ImageProtocol.InlineImageProtocol)) - protocol = ImageProtocol.InlineImageProtocol; - else - protocol = ImageProtocol.Blocks; + if (imageProtocol == ImageProtocol.Auto) { + protocol = autoProtocol.Contains(ImageProtocol.Sixel) + ? ImageProtocol.Sixel + : autoProtocol.Contains(ImageProtocol.KittyGraphicsProtocol) + ? ImageProtocol.KittyGraphicsProtocol + : autoProtocol.Contains(ImageProtocol.InlineImageProtocol) ? ImageProtocol.InlineImageProtocol : ImageProtocol.Blocks; } // Load the image once to avoid duplicate loading using var image = Image.Load(imageStream); // For Sixel and Blocks: use natural sizing if no constraints, otherwise apply constraints ImageSize constrainedSize; - if (width == 0 && height == 0) - { + if (width == 0 && height == 0) { // No constraints specified - use natural image size constrainedSize = SizeHelper.ConvertToCharacterCells(image); } - else - { + else { // Constraints specified - apply resizing logic constrainedSize = SizeHelper.GetResizedCharacterCellSize(image, width, height); } // Use the resolved protocol for all logic below - switch (protocol) - { + switch (protocol) { case ImageProtocol.Sixel: - if (!autoProtocol.Contains(ImageProtocol.Sixel) && !Compatibility.TerminalSupportsSixel() && !Force) - { + if (!autoProtocol.Contains(ImageProtocol.Sixel) && !Compatibility.TerminalSupportsSixel() && !Force) { throw new InvalidOperationException("Terminal does not support sixel, override with -Force"); } // Resize first to get actual pixel dimensions, then compute final cell size from the resized image. - var resized = Resizer.ResizeToCharacterCells(image, constrainedSize, maxColors); - var finalSize = SizeHelper.GetCharacterCellSize(resized); - var frame = resized.Frames[0]; - var data = Protocols.Sixel.FrameToSixelString(frame); + Image resized = Resizer.ResizeToCharacterCells(image, constrainedSize, maxColors); + ImageSize finalSize = SizeHelper.GetCharacterCellSize(resized); + ImageFrame frame = resized.Frames[0]; + string data = Protocols.Sixel.FrameToSixelString(frame); return (finalSize, data); case ImageProtocol.KittyGraphicsProtocol: - if (!autoProtocol.Contains(ImageProtocol.KittyGraphicsProtocol) && !Compatibility.TerminalSupportsKitty() && !Force) - { + if (!autoProtocol.Contains(ImageProtocol.KittyGraphicsProtocol) && !Compatibility.TerminalSupportsKitty() && !Force) { throw new InvalidOperationException("Terminal does not support Kitty, override with -Force"); } // Use the same sizing logic as Sixel/Blocks so we never pass 0x0 to the resizer. - var kittySize = constrainedSize; + ImageSize kittySize = constrainedSize; return (kittySize, KittyGraphics.ImageToKitty(image, kittySize)); case ImageProtocol.InlineImageProtocol: - if (!autoProtocol.Contains(ImageProtocol.InlineImageProtocol) && !Force) - { + if (!autoProtocol.Contains(ImageProtocol.InlineImageProtocol) && !Force) { throw new InvalidOperationException("Terminal does not support Inline Image, override with -Force"); } imageStream.Position = 0; @@ -101,6 +90,10 @@ public static (ImageSize Size, string Data) ConsoleImage( case ImageProtocol.Blocks: return (constrainedSize, Blocks.ImageToBlocks(image, constrainedSize)); + case ImageProtocol.Braille: + return Braille.ImageToBraille(image, constrainedSize.Width, constrainedSize.Height); + case ImageProtocol.Auto: + throw new InvalidOperationException("Auto protocol should have been resolved"); default: throw new InvalidOperationException($"Unsupported image protocol: {protocol}"); } diff --git a/src/Sixel/Terminal/Models/CellSize.cs b/src/Sixel/Terminal/Models/CellSize.cs index ac830a0..442ad37 100644 --- a/src/Sixel/Terminal/Models/CellSize.cs +++ b/src/Sixel/Terminal/Models/CellSize.cs @@ -4,16 +4,15 @@ namespace Sixel.Terminal.Models; /// /// Represents the size of a terminal cell in pixels for Sixel rendering. /// -public class CellSize -{ - /// - /// Gets the width of a cell in pixels. - /// - public int PixelWidth { get; set; } - - /// - /// Gets the height of a cell in pixels. - /// This isn't used for anything yet but this would be required for something like spectre console that needs to work around the size of the rendered sixel image. - /// - public int PixelHeight { get; set; } +public class CellSize { + /// + /// Gets the width of a cell in pixels. + /// + public int PixelWidth { get; set; } + + /// + /// Gets the height of a cell in pixels. + /// This isn't used for anything yet but this would be required for something like spectre console that needs to work around the size of the rendered sixel image. + /// + public int PixelHeight { get; set; } } diff --git a/src/Sixel/Terminal/Models/Dict.cs b/src/Sixel/Terminal/Models/Dict.cs index c37b2f2..c0056c1 100644 --- a/src/Sixel/Terminal/Models/Dict.cs +++ b/src/Sixel/Terminal/Models/Dict.cs @@ -5,15 +5,15 @@ namespace Sixel.Terminal.Models; /// /// Provides mappings and helper methods for associating terminal types with supported image protocols. /// -public sealed partial class Helpers -{ - /// - /// mapping of terminals to the image protocol they support. - /// - public static readonly Dictionary SupportedProtocol = new Dictionary() - { +public sealed partial class Helpers { + /// + /// mapping of terminals to the image protocol they support. + /// + public static readonly Dictionary SupportedProtocol = new() + { { Terminals.MicrosoftTerminal, new[] { ImageProtocol.Sixel } }, { Terminals.MicrosoftConhost, new[] { ImageProtocol.Sixel } }, + { Terminals.Contour, new[] { ImageProtocol.Sixel } }, { Terminals.Kitty, new[] { ImageProtocol.KittyGraphicsProtocol } }, { Terminals.Iterm2, new[] { ImageProtocol.InlineImageProtocol } }, { Terminals.WezTerm, new[] { ImageProtocol.InlineImageProtocol } }, diff --git a/src/Sixel/Terminal/Models/EnvVariables.cs b/src/Sixel/Terminal/Models/EnvVariables.cs index 1ca0731..42d08fc 100644 --- a/src/Sixel/Terminal/Models/EnvVariables.cs +++ b/src/Sixel/Terminal/Models/EnvVariables.cs @@ -1,16 +1,16 @@ -namespace Sixel.Terminal.Models; - + using System.Collections.Generic; -public partial class Helpers -{ + +namespace Sixel.Terminal.Models; + +public partial class Helpers { /// /// mapping of environment variables to terminal. /// used for detecting the terminal. /// private static readonly Dictionary _lookup; private static readonly Dictionary _reverseLookup; - static Helpers() - { + static Helpers() { _lookup = new Dictionary { { Terminals.MicrosoftTerminal, "WT_SESSION" }, @@ -21,46 +21,30 @@ static Helpers() { Terminals.Ghostty, "GHOSTTY_RESOURCES_DIR" }, { Terminals.VSCode, "VSCODE_GIT_ASKPASS_MAIN" }, { Terminals.Mintty, "MINTTY" }, - { Terminals.Alacritty, "ALACRITTY_LOG" } + { Terminals.Alacritty, "ALACRITTY_LOG" }, + { Terminals.Contour, "CONTOUR_PROFILE" } // { Terminals.unknown, "TERM_PROGRAM" } }; _reverseLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (terminal, envVar) in _lookup) - { - if (!_reverseLookup.ContainsKey(envVar)) - { + foreach ((Terminals terminal, string? envVar) in _lookup) { + if (!_reverseLookup.ContainsKey(envVar)) { _reverseLookup[envVar] = terminal; } } } - public static string[] GetEnvironmentVariables() - { - var envVars = new string[_lookup.Count]; + public static string[] GetEnvironmentVariables() { + string[] envVars = new string[_lookup.Count]; int i = 0; - foreach (var envVar in _lookup.Values) - { + foreach (string envVar in _lookup.Values) { envVars[i++] = envVar; } return envVars; } - public static Terminals GetTerminal(string str) - { - if (_reverseLookup.TryGetValue(str, out Terminals _terminal)) - { - return _terminal; - } - if (Enum.TryParse(str, true, out _terminal)) - { - return _terminal; - } - return Terminals.unknown; + public static Terminals GetTerminal(string str) { + return _reverseLookup.TryGetValue(str, out Terminals _terminal) + ? _terminal + : Enum.TryParse(str, true, out _terminal) ? _terminal : Terminals.unknown; } public static string GetEnvironmentVariable(Terminals terminal) - { - if (_lookup.TryGetValue(terminal, out var _envVar)) - { - return _envVar; - } - return "TERM_PROGRAM"; - } + => _lookup.TryGetValue(terminal, out string? _envVar) ? _envVar : "TERM_PROGRAM"; } diff --git a/src/Sixel/Terminal/Models/ExtensionDict.cs b/src/Sixel/Terminal/Models/ExtensionDict.cs index 19da6e8..0c10613 100644 --- a/src/Sixel/Terminal/Models/ExtensionDict.cs +++ b/src/Sixel/Terminal/Models/ExtensionDict.cs @@ -4,12 +4,10 @@ namespace Sixel.Terminal.Models; /// /// Extension methods for KeyValuePair to support deconstruction in .NET Framework 4.7.2. /// -internal static class KeyValuePairExtensions -{ - internal static void Deconstruct(this KeyValuePair kvp, out TKey key, out TValue value) - { - key = kvp.Key; - value = kvp.Value; +internal static class KeyValuePairExtensions { + internal static void Deconstruct(this KeyValuePair kvp, out TKey key, out TValue value) { + key = kvp.Key; + value = kvp.Value; } } #endif diff --git a/src/Sixel/Terminal/Models/ImageProtocol.cs b/src/Sixel/Terminal/Models/ImageProtocol.cs index 37bd3f9..9cfc6f9 100644 --- a/src/Sixel/Terminal/Models/ImageProtocol.cs +++ b/src/Sixel/Terminal/Models/ImageProtocol.cs @@ -5,11 +5,11 @@ /// not all protocols are supported. /// [Flags] -public enum ImageProtocol -{ - Auto = 0, - Blocks = 1, - InlineImageProtocol = 2, - Sixel = 4, - KittyGraphicsProtocol = 8 +public enum ImageProtocol { + Auto = 0, + Blocks = 1, + InlineImageProtocol = 2, + Sixel = 4, + KittyGraphicsProtocol = 8, + Braille = 16 } diff --git a/src/Sixel/Terminal/Models/ImageSize.cs b/src/Sixel/Terminal/Models/ImageSize.cs index 32e8711..8abfaaf 100644 --- a/src/Sixel/Terminal/Models/ImageSize.cs +++ b/src/Sixel/Terminal/Models/ImageSize.cs @@ -3,8 +3,7 @@ /// /// Represents the size of an image in character cells. /// -public readonly struct ImageSize(int Width, int Height) -{ +public readonly struct ImageSize(int Width, int Height) { /// /// Gets the width of an image in character cells. /// diff --git a/src/Sixel/Terminal/Models/SBExtensions.cs b/src/Sixel/Terminal/Models/SBExtensions.cs index 45ab2e8..8a0b0c2 100644 --- a/src/Sixel/Terminal/Models/SBExtensions.cs +++ b/src/Sixel/Terminal/Models/SBExtensions.cs @@ -2,13 +2,11 @@ using System.Text; namespace Sixel.Terminal.Models; -internal static class StringBuilderExtensions -{ - public static StringBuilder Append(this StringBuilder builder, ReadOnlySpan span) - { - // NET472 lacks the override for StringBuilder to add the span. - // We'll need to convert the span to a string for net472. - return builder.Append(span.ToString()); - } + +internal static class StringBuilderExtensions { + public static StringBuilder Append(this StringBuilder builder, ReadOnlySpan span) => + // NET472 lacks the override for StringBuilder to add the span. + // We'll need to convert the span to a string for net472. + builder.Append(span.ToString()); } #endif diff --git a/src/Sixel/Terminal/Models/SixelGif.cs b/src/Sixel/Terminal/Models/SixelGif.cs index a3ec529..6868c39 100644 --- a/src/Sixel/Terminal/Models/SixelGif.cs +++ b/src/Sixel/Terminal/Models/SixelGif.cs @@ -2,34 +2,33 @@ /// /// Gif in sixel format. /// -public class SixelGif -{ - /// - /// The delay in milliseconds between each frame. - /// - public int Delay { get; set; } - /// - /// The number of times the gif should loop. - /// - public int LoopCount { get; set; } - /// - /// The height of the gif, in characters. - /// - public int Height { get; set; } - /// - /// The width of the gif, in characters. - /// - public int Width { get; set; } - /// - /// The audio data for the gif, optional - /// - // public string? Audio { get; set; } - /// - /// The sixel data for each frame of the gif. - /// - internal List Sixel { get; set; } = []; - /// - /// The number of frames in the gif. - /// - public int FrameCount => Sixel.Count; +public class SixelGif { + /// + /// The delay in milliseconds between each frame. + /// + public int Delay { get; set; } + /// + /// The number of times the gif should loop. + /// + public int LoopCount { get; set; } + /// + /// The height of the gif, in characters. + /// + public int Height { get; set; } + /// + /// The width of the gif, in characters. + /// + public int Width { get; set; } + /// + /// The audio data for the gif, optional + /// + // public string? Audio { get; set; } + /// + /// The sixel data for each frame of the gif. + /// + internal List Sixel { get; set; } = []; + /// + /// The number of frames in the gif. + /// + public int FrameCount => Sixel.Count; } diff --git a/src/Sixel/Terminal/Models/TerminalInfo.cs b/src/Sixel/Terminal/Models/TerminalInfo.cs index 983c5e0..9c23a3a 100644 --- a/src/Sixel/Terminal/Models/TerminalInfo.cs +++ b/src/Sixel/Terminal/Models/TerminalInfo.cs @@ -1,11 +1,7 @@ namespace Sixel.Terminal.Models; -public class TerminalInfo -{ - public Terminals Terminal { get; set; } - public ImageProtocol[] Protocol { get; set; } = [ImageProtocol.Blocks]; - public override string ToString() - { - return $"{Terminal} ({string.Join(", ", Protocol)})"; - } +public class TerminalInfo { + public Terminals Terminal { get; set; } + public ImageProtocol[] Protocol { get; set; } = [ImageProtocol.Blocks]; + public override string ToString() => $"{Terminal} ({string.Join(", ", Protocol)})"; } diff --git a/src/Sixel/Terminal/Models/Terminals.cs b/src/Sixel/Terminal/Models/Terminals.cs index 4b36fb6..5171512 100644 --- a/src/Sixel/Terminal/Models/Terminals.cs +++ b/src/Sixel/Terminal/Models/Terminals.cs @@ -4,19 +4,19 @@ /// known terminals /// not all terminals are supported. /// -public enum Terminals -{ - MicrosoftTerminal, - MicrosoftConhost, - Kitty, - Iterm2, - WezTerm, - Ghostty, - VSCode, - Mintty, - Alacritty, - Rio, - xterm, - mlterm, - unknown +public enum Terminals { + MicrosoftTerminal, + MicrosoftConhost, + Kitty, + Iterm2, + WezTerm, + Ghostty, + VSCode, + Mintty, + Alacritty, + Rio, + xterm, + mlterm, + Contour, + unknown }; diff --git a/src/Sixel/Terminal/Models/WindowSizeCharacters.cs b/src/Sixel/Terminal/Models/WindowSizeCharacters.cs index de61a93..f575e13 100644 --- a/src/Sixel/Terminal/Models/WindowSizeCharacters.cs +++ b/src/Sixel/Terminal/Models/WindowSizeCharacters.cs @@ -3,15 +3,14 @@ /// /// Represents the size of the terminal window in characters. /// -public class WindowSizeCharacters -{ - /// - /// Gets the width of the terminal in characters. - /// - public int CharacterWidth { get; set; } +public class WindowSizeCharacters { + /// + /// Gets the width of the terminal in characters. + /// + public int CharacterWidth { get; set; } - /// - /// Gets the height of the terminal in characters. - /// - public int CharacterHeight { get; set; } + /// + /// Gets the height of the terminal in characters. + /// + public int CharacterHeight { get; set; } } diff --git a/src/Sixel/Terminal/Models/WindowSizePixels.cs b/src/Sixel/Terminal/Models/WindowSizePixels.cs index d97d339..3c9146b 100644 --- a/src/Sixel/Terminal/Models/WindowSizePixels.cs +++ b/src/Sixel/Terminal/Models/WindowSizePixels.cs @@ -5,15 +5,14 @@ /// not supported in all terminals. /// like WezTerm, Alacritty /// -public class WindowSizePixels -{ - /// - /// Gets the width of the terminal in pixels. - /// - public int PixelWidth { get; set; } +public class WindowSizePixels { + /// + /// Gets the width of the terminal in pixels. + /// + public int PixelWidth { get; set; } - /// - /// Gets the height of the terminal in pixels. - /// - public int PixelHeight { get; set; } + /// + /// Gets the height of the terminal in pixels. + /// + public int PixelHeight { get; set; } } diff --git a/src/Sixel/Terminal/ProtocolHelper.cs b/src/Sixel/Terminal/ProtocolHelper.cs index 8cbd423..29c9928 100644 --- a/src/Sixel/Terminal/ProtocolHelper.cs +++ b/src/Sixel/Terminal/ProtocolHelper.cs @@ -5,18 +5,16 @@ namespace Sixel.Terminal; /// Helper methods for selecting the best supported terminal image protocol. /// not in use at the moment /// -public static class ImageProtocolHelper -{ - public static ImageProtocol GetBestSupported(ImageProtocol supported) - { - if ((supported & ImageProtocol.KittyGraphicsProtocol) != 0) - return ImageProtocol.KittyGraphicsProtocol; - if ((supported & ImageProtocol.Sixel) != 0) - return ImageProtocol.Sixel; - if ((supported & ImageProtocol.InlineImageProtocol) != 0) - return ImageProtocol.InlineImageProtocol; - if ((supported & ImageProtocol.Blocks) != 0) - return ImageProtocol.Blocks; - return ImageProtocol.Blocks; // fallback - } +public static class ImageProtocolHelper { + public static ImageProtocol GetBestSupported(ImageProtocol supported) { + if ((supported & ImageProtocol.KittyGraphicsProtocol) != 0) + return ImageProtocol.KittyGraphicsProtocol; + if ((supported & ImageProtocol.Sixel) != 0) + return ImageProtocol.Sixel; + if ((supported & ImageProtocol.InlineImageProtocol) != 0) + return ImageProtocol.InlineImageProtocol; + if ((supported & ImageProtocol.Blocks) != 0) + return ImageProtocol.Blocks; + return ImageProtocol.Blocks; // fallback + } } diff --git a/src/Sixel/Terminal/Resizer.cs b/src/Sixel/Terminal/Resizer.cs index 16df141..d55245d 100644 --- a/src/Sixel/Terminal/Resizer.cs +++ b/src/Sixel/Terminal/Resizer.cs @@ -9,8 +9,7 @@ namespace Sixel.Terminal; /// /// Provides methods to resize images to fit within terminal character cell dimensions, with optional color quantization. /// -public static class Resizer -{ +public static class Resizer { /// /// Resizes an image to fit within the specified terminal character cell dimensions. /// @@ -26,9 +25,8 @@ public static (ImageSize Size, Image ConsoleImage) OldResizeToCharacterC int? RequestedWidth, int? RequestedHeight, bool quantize = false - ) - { - var cellSize = Compatibility.GetCellSize(); + ) { + CellSize cellSize = Compatibility.GetCellSize(); int reqWidth = (RequestedWidth > 0) ? RequestedWidth.Value : 0; int reqHeight = (RequestedHeight > 0) ? RequestedHeight.Value : 0; @@ -38,15 +36,14 @@ public static (ImageSize Size, Image ConsoleImage) OldResizeToCharacterC // var currentSize = SizeHelper.ConvertToCharacterCells(image.Width, image.Height); // return (currentSize, image); // } - var newSize = SizeHelper.GetResizedCharacterCellSize(image.Width, image.Height, reqWidth, reqHeight); + ImageSize newSize = SizeHelper.GetResizedCharacterCellSize(image.Width, image.Height, reqWidth, reqHeight); // Calculate pixel dimensions from cell dimensions int targetPixelWidth = newSize.Width * cellSize.PixelWidth; int targetPixelHeight = newSize.Height * cellSize.PixelHeight; // Only resize if the target size is different - if (image.Width != targetPixelWidth || image.Height != targetPixelHeight) - { + if (image.Width != targetPixelWidth || image.Height != targetPixelHeight) { image.Mutate(ctx => { ctx.Resize(new ResizeOptions() { // Pads the image to fit the bound of the container without resizing the original source. @@ -60,16 +57,14 @@ public static (ImageSize Size, Image ConsoleImage) OldResizeToCharacterC Size = new(targetPixelWidth, targetPixelHeight), PremultiplyAlpha = false, }); - if (quantize) - { + if (quantize) { ctx.Quantize(new OctreeQuantizer(new() { MaxColors = maxColors, })); } }); } - else if (quantize) - { + else if (quantize) { image.Mutate(ctx => { ctx.Quantize(new OctreeQuantizer(new() { MaxColors = maxColors, @@ -89,17 +84,15 @@ public static Image ResizeToCharacterCells( Image image, ImageSize imageSize, int maxColors - ) - { - var cellSize = Compatibility.GetCellSize(); + ) { + CellSize cellSize = Compatibility.GetCellSize(); // Calculate pixel dimensions from cell dimensions int targetPixelWidth = imageSize.Width * cellSize.PixelWidth; int targetPixelHeight = imageSize.Height * cellSize.PixelHeight; // Only resize if the target size is different - if (image.Width != targetPixelWidth || image.Height != targetPixelHeight) - { + if (image.Width != targetPixelWidth || image.Height != targetPixelHeight) { image.Mutate(ctx => { ctx.Resize(new ResizeOptions() { // Never crop; pad to requested size, anchoring content at top-left to preserve the left edge. @@ -112,16 +105,14 @@ int maxColors Size = new(targetPixelWidth, targetPixelHeight), PremultiplyAlpha = false, }); - if (maxColors > 0) - { + if (maxColors > 0) { ctx.Quantize(new OctreeQuantizer(new() { MaxColors = maxColors, })); } }); } - else if (maxColors > 0) - { + else if (maxColors > 0) { image.Mutate(ctx => { ctx.Quantize(new OctreeQuantizer(new() { MaxColors = maxColors, diff --git a/src/Sixel/Terminal/SizeHelper.cs b/src/Sixel/Terminal/SizeHelper.cs index e2ed3c1..5e985fe 100644 --- a/src/Sixel/Terminal/SizeHelper.cs +++ b/src/Sixel/Terminal/SizeHelper.cs @@ -7,8 +7,7 @@ namespace Sixel.Terminal; /// /// Provides methods for converting and resizing image dimensions to terminal character cell sizes. /// -public static class SizeHelper -{ +public static class SizeHelper { /// /// Converts image dimensions from pixels to terminal character cells. /// Accounts for sixel 6px row packing when computing the number of rows occupied. @@ -16,9 +15,7 @@ public static class SizeHelper /// The image to convert. /// Image size in terminal character cells. public static ImageSize ConvertToCharacterCells(Image image) - { - return GetCharacterCellSize(image.Width, image.Height); - } + => GetCharacterCellSize(image.Width, image.Height); /// /// Converts image dimensions from pixels to terminal character cells. @@ -26,8 +23,7 @@ public static ImageSize ConvertToCharacterCells(Image image) /// /// The image stream to convert. /// Image size in terminal character cells. - public static ImageSize ConvertToCharacterCells(Stream imageStream) - { + public static ImageSize ConvertToCharacterCells(Stream imageStream) { using var image = Image.Load(imageStream); return GetCharacterCellSize(image.Width, image.Height); } @@ -37,13 +33,12 @@ public static ImageSize ConvertToCharacterCells(Stream imageStream) /// Height is computed from the image height rounded up to the nearest multiple of 6 pixels to /// match sixel 6px row packing so the number of occupied rows is correct. /// - public static ImageSize GetCharacterCellSize(int pixelWidth, int pixelHeight) - { - var cellSize = Compatibility.GetCellSize(); + public static ImageSize GetCharacterCellSize(int pixelWidth, int pixelHeight) { + CellSize cellSize = Compatibility.GetCellSize(); // Align image height to a multiple of 6px before converting to rows // rows = ceil( ceil(h_px / 6) * 6 / cellHeight_px ) - int effectivePixelHeight = ((pixelHeight + 5) / 6) * 6; + int effectivePixelHeight = (pixelHeight + 5) / 6 * 6; int widthCells = Math.Max(1, (int)Math.Ceiling((double)pixelWidth / cellSize.PixelWidth)); int heightCells = Math.Max(1, (int)Math.Ceiling((double)effectivePixelHeight / cellSize.PixelHeight)); @@ -61,12 +56,10 @@ public static ImageSize GetCharacterCellSize(Image image) /// Gets the resized size in terminal character cells for an image, given max width/height constraints. /// Maintains aspect ratio, aligns height to sixel 6px rows, and uses pixel-space math to avoid clipping. /// - public static ImageSize GetResizedCharacterCellSize(int pixelWidth, int pixelHeight, int maxCellWidth, int maxCellHeight) - { - var cellSize = Compatibility.GetCellSize(); + public static ImageSize GetResizedCharacterCellSize(int pixelWidth, int pixelHeight, int maxCellWidth, int maxCellHeight) { + CellSize cellSize = Compatibility.GetCellSize(); - if (pixelWidth <= 0 || pixelHeight <= 0) - { + if (pixelWidth <= 0 || pixelHeight <= 0) { return new ImageSize(1, 1); } @@ -79,8 +72,7 @@ public static ImageSize GetResizedCharacterCellSize(int pixelWidth, int pixelHei double maxPixelsH = constrainH ? (double)maxCellHeight * cellSize.PixelHeight : double.PositiveInfinity; // Respect sixel: when height is constrained, align the pixel budget to a multiple of 6px. - if (constrainH) - { + if (constrainH) { maxPixelsH = Math.Max(6.0, Math.Floor(maxPixelsH / 6.0) * 6.0); } @@ -88,8 +80,7 @@ public static ImageSize GetResizedCharacterCellSize(int pixelWidth, int pixelHei double scaleW = double.IsInfinity(maxPixelsW) ? double.PositiveInfinity : maxPixelsW / pixelWidth; double scaleH = double.IsInfinity(maxPixelsH) ? double.PositiveInfinity : maxPixelsH / pixelHeight; double scale = Math.Min(scaleW, scaleH); - if (double.IsInfinity(scale) || scale <= 0) - { + if (double.IsInfinity(scale) || scale <= 0) { scale = 1.0; // No constraints provided } @@ -98,20 +89,18 @@ public static ImageSize GetResizedCharacterCellSize(int pixelWidth, int pixelHei int scaledPixelH = Math.Max(1, (int)Math.Round(pixelHeight * scale)); // Sixel consumes rows in 6px bands; account for that when converting to terminal rows - int effectiveScaledPixelH = ((scaledPixelH + 5) / 6) * 6; + int effectiveScaledPixelH = (scaledPixelH + 5) / 6 * 6; // Convert scaled pixels to cells. Use Ceil for width to avoid right-edge clipping. int cellW = Math.Max(1, (int)Math.Ceiling((double)scaledPixelW / cellSize.PixelWidth)); int cellH = Math.Max(1, (int)Math.Ceiling((double)effectiveScaledPixelH / cellSize.PixelHeight)); // Clamp to explicit constraints only - if (constrainW) - { + if (constrainW) { cellW = Math.Min(cellW, maxCellWidth); } - if (constrainH) - { + if (constrainH) { cellH = Math.Min(cellH, maxCellHeight); } @@ -129,15 +118,12 @@ public static ImageSize GetResizedCharacterCellSize(Image image, int max /// Gets the constrained terminal image size for the image, applying width/height constraints. /// public static ImageSize GetTerminalImageSize(Image image, int maxWidth, int maxHeight) - { - return GetResizedCharacterCellSize(image.Width, image.Height, maxWidth, maxHeight); - } + => GetResizedCharacterCellSize(image.Width, image.Height, maxWidth, maxHeight); /// /// Gets the constrained terminal image size for the image, applying width/height constraints. /// - public static ImageSize GetTerminalImageSize(Stream imageStream, int maxWidth, int maxHeight) - { + public static ImageSize GetTerminalImageSize(Stream imageStream, int maxWidth, int maxHeight) { using var image = Image.Load(imageStream); return GetResizedCharacterCellSize(image.Width, image.Height, maxWidth, maxHeight); } @@ -146,12 +132,8 @@ public static ImageSize GetTerminalImageSize(Stream imageStream, int maxWidth, i /// Gets the constrained terminal image size, applying width/height constraints. /// public static ImageSize GetTerminalImageSize(int pixelWidth, int pixelHeight, int maxWidth, int maxHeight) - { - return GetResizedCharacterCellSize(pixelWidth, pixelHeight, maxWidth, maxHeight); - } + => GetResizedCharacterCellSize(pixelWidth, pixelHeight, maxWidth, maxHeight); internal static ImageSize GetTerminalImageSize(this Image image) - { - return ConvertToCharacterCells(image); - } + => ConvertToCharacterCells(image); } diff --git a/src/Sixel/Terminal/TerminalChecker.cs b/src/Sixel/Terminal/TerminalChecker.cs index 1f15143..a04f612 100644 --- a/src/Sixel/Terminal/TerminalChecker.cs +++ b/src/Sixel/Terminal/TerminalChecker.cs @@ -1,72 +1,63 @@ -using Sixel.Terminal.Models; -using System.Collections; +using System.Collections; +using Sixel.Terminal.Models; namespace Sixel.Terminal; /// /// Provides methods to detect and check terminal compatibility and supported image protocols based on environment variables. /// -public static class TerminalChecker -{ +public static class TerminalChecker { /// /// Check the terminal for compatibility. /// use enviroment variables to try and figure out which terminal is being used. /// this is just a pain, order is weird and edge cases.. it's a mess. /// - internal static TerminalInfo CheckTerminal() - { - var env = Environment.GetEnvironmentVariables(); + internal static TerminalInfo CheckTerminal() { + IDictionary env = Environment.GetEnvironmentVariables(); Terminals detectedTerminal = Terminals.unknown; ImageProtocol[] detectedProtocols = [ImageProtocol.Blocks]; // 1. Explicit checks for VSCode/WezTerm with version logic - if (env["TERM_PROGRAM_VERSION"] is string termProgramVersion && env["TERM_PROGRAM"] is string termProgram) - { - var terminal = Helpers.GetTerminal(termProgram); - if (terminal == Terminals.VSCode && termProgramVersion != null) - { - var dashIdx = termProgramVersion.IndexOf('-'); - var versionPart = dashIdx > 0 ? termProgramVersion.Substring(0, dashIdx) : termProgramVersion; - if (Version.TryParse(versionPart, out var parsedVersion)) - { + if (env["TERM_PROGRAM_VERSION"] is string termProgramVersion && env["TERM_PROGRAM"] is string termProgram) { + Terminals terminal = Helpers.GetTerminal(termProgram); + if (terminal is Terminals.VSCode && termProgramVersion is not null) { + int dashIdx = termProgramVersion.IndexOf('-'); +#if NET8_0_OR_GREATER + string versionPart = dashIdx > 0 ? termProgramVersion[..dashIdx] : termProgramVersion; +#else + // net472 cant do range syntax.. + string versionPart = dashIdx > 0 ? termProgramVersion.Substring(0, dashIdx) : termProgramVersion; +#endif + if (Version.TryParse(versionPart, out Version? parsedVersion)) { var minVSCodeVersion = new Version(1, 102, 0); - if (parsedVersion >= minVSCodeVersion) - { + if (parsedVersion >= minVSCodeVersion) { detectedTerminal = terminal; detectedProtocols = [ImageProtocol.Sixel, ImageProtocol.InlineImageProtocol]; } } } - else if (terminal == Terminals.WezTerm && termProgramVersion != null) - { - var parts = termProgramVersion.Split('-'); - if (parts.Length > 0 && DateTime.TryParseExact(parts[0], "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out var buildDate)) - { + else if (terminal is Terminals.WezTerm && termProgramVersion is not null) { + string[] parts = termProgramVersion.Split('-'); + if (parts.Length > 0 && DateTime.TryParseExact(parts[0], "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out DateTime buildDate)) { var minWezTermDate = new DateTime(2025, 3, 20); - if (buildDate >= minWezTermDate) - { + if (buildDate >= minWezTermDate) { detectedTerminal = terminal; detectedProtocols = [ImageProtocol.KittyGraphicsProtocol, ImageProtocol.Sixel, ImageProtocol.InlineImageProtocol]; } } } // Fallback to supported protocol if not matched by version - if (detectedTerminal == Terminals.unknown && Helpers.SupportedProtocol.TryGetValue(terminal, out var protocol)) - { + if (detectedTerminal is Terminals.unknown && Helpers.SupportedProtocol.TryGetValue(terminal, out ImageProtocol[]? protocol)) { detectedTerminal = terminal; detectedProtocols = protocol; } } // 2. Check for other well-known env variables (e.g., WT_SESSION for Windows Terminal) - if (detectedTerminal == Terminals.unknown) - { - foreach (var known in Helpers.GetEnvironmentVariables()) - { - if (env[known] != null) - { - var terminal = Helpers.GetTerminal(known); - if (Helpers.SupportedProtocol.TryGetValue(terminal, out var protocol)) - { + if (detectedTerminal is Terminals.unknown) { + foreach (string known in Helpers.GetEnvironmentVariables()) { + if (env[known] is not null) { + Terminals terminal = Helpers.GetTerminal(known); + if (Helpers.SupportedProtocol.TryGetValue(terminal, out ImageProtocol[]? protocol)) { detectedTerminal = terminal; detectedProtocols = protocol; break; @@ -76,25 +67,19 @@ internal static TerminalInfo CheckTerminal() } // 3. Fallback: scan all env vars for known terminal signatures - if (detectedTerminal == Terminals.unknown) - { - foreach (DictionaryEntry item in env) - { - var key = item.Key?.ToString(); - var value = item.Value?.ToString(); - if (key != null && Helpers.GetTerminal(key) is Terminals _terminal && _terminal != Terminals.unknown) - { - if (Helpers.SupportedProtocol.TryGetValue(_terminal, out var protocol)) - { + if (detectedTerminal is Terminals.unknown) { + foreach (DictionaryEntry item in env) { + string? key = item.Key?.ToString(); + string? value = item.Value?.ToString(); + if (key is not null && Helpers.GetTerminal(key) is Terminals _terminal && _terminal is not Terminals.unknown) { + if (Helpers.SupportedProtocol.TryGetValue(_terminal, out ImageProtocol[]? protocol)) { detectedTerminal = _terminal; detectedProtocols = protocol; break; } } - if (value != null && Helpers.GetTerminal(value) is Terminals _terminal2 && _terminal2 != Terminals.unknown) - { - if (Helpers.SupportedProtocol.TryGetValue(_terminal2, out var protocol)) - { + if (value is not null && Helpers.GetTerminal(value) is Terminals _terminal2 && _terminal2 is not Terminals.unknown) { + if (Helpers.SupportedProtocol.TryGetValue(_terminal2, out ImageProtocol[]? protocol)) { detectedTerminal = _terminal2; detectedProtocols = protocol; break; diff --git a/src/Sixel/Terminal/VTWriter-netfx.cs b/src/Sixel/Terminal/VTWriter-netfx.cs index 0714fec..468423f 100644 --- a/src/Sixel/Terminal/VTWriter-netfx.cs +++ b/src/Sixel/Terminal/VTWriter-netfx.cs @@ -8,15 +8,14 @@ namespace Sixel.Terminal; /// Contains P/Invoke methods for accessing native Windows console handles and related operations (NET472 only). /// used for rendering gifs on Windows in net472. /// -internal static class NativeMethods -{ +internal static class NativeMethods { private const uint GENERIC_READ = 0x80000000; private const uint GENERIC_WRITE = 0x40000000; private const uint FILE_SHARE_READ = 0x00000001; private const uint FILE_SHARE_WRITE = 0x00000002; private const uint OPEN_EXISTING = 3; - [DllImport("kernel32.dll", SetLastError = true, CharSet=CharSet.Unicode, ExactSpelling = true)] + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)] private static extern SafeFileHandle CreateFileW( string lpFileName, uint dwDesiredAccess, @@ -27,11 +26,9 @@ private static extern SafeFileHandle CreateFileW( IntPtr hTemplateFile ); - internal static SafeFileHandle OpenConOut() - { + internal static SafeFileHandle OpenConOut() { SafeFileHandle handle = CreateFileW("CONOUT$", GENERIC_WRITE, FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); - if (handle.IsInvalid) - { + if (handle.IsInvalid) { int errorCode = Marshal.GetLastWin32Error(); throw new IOException("Unable to open console output handle, error code: " + errorCode); } diff --git a/src/Sixel/Terminal/VTWriter.cs b/src/Sixel/Terminal/VTWriter.cs index f2e034c..7f641e1 100644 --- a/src/Sixel/Terminal/VTWriter.cs +++ b/src/Sixel/Terminal/VTWriter.cs @@ -6,73 +6,65 @@ namespace Sixel.Terminal; /// handling platform-specific output streams for improved performance /// used for rendering gifs on Windows. /// -internal sealed class VTWriter : IDisposable -{ - private readonly TextWriter? _writer; +internal sealed class VTWriter : IDisposable { + private readonly TextWriter? _writer; private readonly FileStream? _windowsStream; private readonly bool _customwriter; private bool _disposed; - public VTWriter() - { - bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - bool isRedirected = Console.IsOutputRedirected; - if (isWindows && !isRedirected) - { + public VTWriter() { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + bool isRedirected = Console.IsOutputRedirected || Console.IsOutputRedirected; + + if (isWindows && !isRedirected) { + // Open the Windows stream to CONOUT$, for better performance.. + // Console.Write is too slow for gifs on Windows. + if (isWindows && !isRedirected) { #if NET472 - _windowsStream = new FileStream(NativeMethods.OpenConOut(), FileAccess.Write); - _writer = new StreamWriter(_windowsStream); - _customwriter = true; + _windowsStream = new FileStream(NativeMethods.OpenConOut(), FileAccess.Write); + _writer = new StreamWriter(_windowsStream); + _customwriter = true; #else - // Open the Windows stream to CONOUT$, for better performance.. - // Console.Write is too slow for gifs. - _windowsStream = File.OpenWrite("CONOUT$"); - _writer = new StreamWriter(_windowsStream); - _customwriter = true; + // Open the Windows stream to CONOUT$, for better performance.. + // Console.Write is too slow for gifs. + _windowsStream = File.OpenWrite("CONOUT$"); + _writer = new StreamWriter(_windowsStream); + _customwriter = true; #endif + } + } } - } - public void Write(string text) - { - if (_customwriter) - { - _writer?.Write(text); - } - else - { - Console.Write(text); + public void Write(string text) { + if (_customwriter) { + _writer?.Write(text); + } + else { + Console.Write(text); + } } - } - public void WriteLine(string text) - { - if (_customwriter) - { - _writer?.WriteLine(text); + public void WriteLine(string text) { + if (_customwriter) { + _writer?.WriteLine(text); + } + else { + Console.WriteLine(text); + } } - else - { - Console.WriteLine(text); - } - } - private void Dispose(bool disposing) - { - if (!_disposed && _customwriter) - { - if (disposing) - { - _writer?.Dispose(); - _windowsStream?.Dispose(); - } - _disposed = true; + private void Dispose(bool disposing) { + if (!_disposed && _customwriter) { + if (disposing) { + _writer?.Dispose(); + _windowsStream?.Dispose(); + } + _disposed = true; + } } - } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } } diff --git a/src/Sixel/Terminal/Validate.cs b/src/Sixel/Terminal/Validate.cs index a321663..d011978 100644 --- a/src/Sixel/Terminal/Validate.cs +++ b/src/Sixel/Terminal/Validate.cs @@ -1,8 +1,8 @@ using System; using System.Linq; +using System.Management.Automation; using System.Reflection; using SixLabors.ImageSharp.Processing; -using System.Management.Automation; using SixLabors.ImageSharp.Processing.Processors.Transforms; namespace Sixel.Terminal; @@ -10,36 +10,32 @@ namespace Sixel.Terminal; /// /// Argument validation attribute to ensure the requested terminal width does not exceed the actual terminal width. /// -internal sealed class ValidateTerminalWidth : ValidateArgumentsAttribute -{ - /// - /// Validates that the requested Width is not greater than the terminal width. - /// - protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics) - { - var requestedWidth = (int)arguments; - var hostWidth = Console.WindowWidth; - if (requestedWidth > hostWidth) - { - throw new ValidationMetadataException($"{requestedWidth} width is greater than terminal width ({hostWidth})."); +internal sealed class ValidateTerminalWidth : ValidateArgumentsAttribute { + /// + /// Validates that the requested Width is not greater than the terminal width. + /// + /// + protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics) { + int requestedWidth = (int)arguments; + int hostWidth = Console.WindowWidth; + if (requestedWidth > hostWidth) { + throw new ValidationMetadataException($"{requestedWidth} width is greater than terminal width ({hostWidth})."); + } } - } } /// /// Argument validation attribute to ensure the requested terminal height does not exceed the actual terminal height. /// -internal sealed class ValidateTerminalHeight : ValidateArgumentsAttribute -{ - /// - /// Validates that the requested Height is not greater than the terminal height. - /// - protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics) - { - var requestedHeight = (int)arguments; - var hostHeight = Console.WindowHeight; - if (requestedHeight > hostHeight) - { - throw new ValidationMetadataException($"{requestedHeight} height is greater than terminal height ({hostHeight})."); +internal sealed class ValidateTerminalHeight : ValidateArgumentsAttribute { + /// + /// Validates that the requested Height is not greater than the terminal height. + /// + /// + protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics) { + int requestedHeight = (int)arguments; + int hostHeight = Console.WindowHeight; + if (requestedHeight > hostHeight) { + throw new ValidationMetadataException($"{requestedHeight} height is greater than terminal height ({hostHeight})."); + } } - } } diff --git a/tools/Pester.ps1 b/tools/Pester.ps1 deleted file mode 100644 index 6a86f2e..0000000 --- a/tools/Pester.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -using namespace System.IO -using namespace System.Runtime.InteropServices - -#Requires -Module Pester - -<# -.SYNOPSIS -Run Pester test - -.PARAMETER TestPath -The path to the tests to run - -.PARAMETER OutputFile -The path to write the Pester test results to. -#> -[CmdletBinding()] -param ( - [Parameter(Mandatory)] - [String] - $TestPath, - - [Parameter()] - [String] - $OutputFile -) - -$ErrorActionPreference = 'Stop' - -[PSCustomObject]$PSVersionTable | - Select-Object -Property *, @{ - N = 'Architecture'; E = { [RuntimeInformation]::ProcessArchitecture.ToString() } - } | - Format-List | - Out-Host - -$configuration = [PesterConfiguration]::Default -$configuration.Output.Verbosity = 'Detailed' -$configuration.Run.Path = $TestPath -$configuration.Run.Throw = $true -if ($OutputFile) { - # $configuration.TestResult.Enabled = $true - # $configuration.TestResult.OutputPath = $OutputFile - # $configuration.TestResult.OutputFormat = 'NUnitXml' -} - - -Invoke-Pester -Configuration $configuration -WarningAction Ignore diff --git a/tools/build.ps1 b/tools/build.ps1 deleted file mode 100644 index 2c723ab..0000000 --- a/tools/build.ps1 +++ /dev/null @@ -1,90 +0,0 @@ -[cmdletbinding()] -param( - [Switch]$Publish -) - -. $PSScriptRoot/common.ps1 - -$reporoot = Split-Path $PSScriptRoot -@( - 'Pester' - 'PlatyPS' -) | ForEach-Object { - if (-not (Get-Module -Name $_ -ListAvailable)) { - Install-Module -Name $_ -Force -Scope CurrentUser - } - Import-Module -Name $_ -Force -} - -$output = Join-Path $reporoot 'output' -if (Test-Path $output) { - Remove-Item $output -Recurse -Force -} -$csproj = Get-ChildItem -File -Include *.csproj -Recurse -Path $reporoot -$ModuleFile = Import-PowerShellDataFile -Path (Join-Path $reporoot 'Module' 'Sixel.psd1') -$newVersion = '{0}' -f $ModuleFile.ModuleVersion.ToString() - -foreach ($project in $csproj) { - $Content = Get-Content $project.FullName -Raw - # update tag in csproj file to match Module version - if ($Content -match $newVersion) { - # Write-Host "Version already set to $newVersion in $($project.Name)" - continue - } - $Content = $Content -replace '.*', $newVersion - Write-Host "Updating version to $newVersion in $($project.Name)" - Set-Content -Path $project.FullName -Value $Content -Force -} - -Invoke-ModuleBuilder -Path $reporoot - -$docspath = Join-Path $output 'en-US' -if (Test-Path $docspath) { - Remove-Item $docspath -Recurse -Force -} -Get-ChildItem $output -Recurse -File | Where-Object { $_.Extension -in '.json', '.pdb' } | Remove-Item -Force - -$docs = Join-Path $reporoot 'docs' - -Get-ChildItem -LiteralPath $docs -Directory | ForEach-Object { - Write-Host "Building docs for $($_.Name)" - $helpParams = @{ - Path = $_.FullName - OutputPath = [System.IO.Path]::Combine($output, $_.Name) - Encoding = [System.Text.Encoding]::UTF8 - } - $null = New-ExternalHelp @helpParams -} - -$testargs = @{ - reportFile = [System.IO.Path]::Combine($reporoot, 'testdata', ('Sixel.report-{0}.xml' -f (Get-Date).ToString('yyyyMMdd-HHmmss'))) - TestPath = [System.IO.Path]::Combine($reporoot, 'tests') - tools = $PSScriptRoot -} -$sb = { - param($ht) - $tools, $TestPath, $reportFile = $ht.tools, $ht.TestPath, $ht.reportFile - & $tools/Pester.ps1 -TestPath $TestPath -OutputFile $reportFile -} - -if ((Get-Process -Id $pid).CommandLine -match '-NoExit') { - # detect when launched from vscode launch settings - Write-Host 'Running tests in current session' - . $sb $testargs - ConvertTo-Sixel .\assets\cog.png -Protocol Sixel -} -elseif ($PSVersionTable.PSEdition -eq 'Core') { - Write-Host 'Running tests in a new pwsh' - pwsh -NoProfile -Command $sb -args $testargs -} -else { - # disabled test for 5.1, sixlabor produces slightly different output. - # powershell -NoProfile -Command $sb -args $testargs -} - -if ($Publish) { - $module = Get-Module $output/Sixel.psd1 -ListAvailable - $v = 'v' + $module.Version.ToString() - Publish-PSResource -Path $output -ApiKey $env:NuGetApiKey -Repository PSGallery -ErrorAction Stop - gh release create $v --generate-notes #--prerelease --target prerelease -} diff --git a/tools/common.ps1 b/tools/common.ps1 deleted file mode 100644 index b80bd16..0000000 --- a/tools/common.ps1 +++ /dev/null @@ -1,343 +0,0 @@ -using namespace System.IO -using namespace System.Net -using namespace System.Runtime.InteropServices - -# Common code used in the build.ps1 scripts of each process - -$ErrorActionPreference = 'Stop' - -Function Get-NugetAssembly { - <# - .SYNOPSIS - Downloads the assembly. - #> - [OutputType([string])] - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] - $Name, - - [Parameter(Mandatory)] - [string] - $Version - ) - - $targetFolder = Join-Path $PSScriptRoot bin - if (-not (Test-Path -LiteralPath $targetFolder)) { - New-Item -Path $targetFolder -ItemType Directory | Out-Null - } - - $downloadUrl = "https://globalcdn.nuget.org/packages/$($Name.ToLowerInvariant()).$Version.nupkg" - $targetFile = Join-Path $targetFolder "$Name.$Version.zip" - - $assemblyFolder = Join-Path $targetFolder "$Name.$Version" - if (-not (Test-Path -LiteralPath $assemblyFolder)) { - New-Item -Path $assemblyFolder -ItemType Directory | Out-Null - } - - if (-not (Test-Path -LiteralPath $targetFile)) { - $oldSecurityProtocol = [ServicePointManager]::SecurityProtocol - try { - & { - $ProgressPreference = 'SilentlyContinue' - [ServicePointManager]::SecurityProtocol = 'Tls12' - Invoke-WebRequest -UseBasicParsing -Uri $downloadUrl -OutFile $targetFile - } - } - finally { - [ServicePointManager]::SecurityProtocol = $oldSecurityProtocol - } - } - - Add-Type -As System.IO.Compression.FileSystem - - $archive = [System.IO.Compression.ZipFile]::Open( - $targetFile, - "Read") - try { - $archive.Entries | Where-Object { - $_.FullName -like "lib/*/*.dll" - } | ForEach-Object { - $dllName = Split-Path -Path $_.FullName -Leaf - $dllFolder = (Split-Path -Path $_.FullName -Parent).Substring(4) - - $binFolder = Join-Path $assemblyFolder $dllFolder - if (-not (Test-Path -LiteralPath $binFolder)) { - New-Item -Path $binFolder -ItemType Directory | Out-Null - } - - $dllPath = Join-Path $binFolder $dllName - if (-not (Test-Path -LiteralPath $dllPath)) { - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, $dllPath) - } - } - } - finally { - $archive.Dispose() - } - - $assemblyFolder -} - -Function Get-PowerShell { - <# - .SYNOPSIS - Downloads the version of PowerShell specified. - - .PARAMETER Version - The version of PowerShell to download. - #> - [OutputType([string])] - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string]$Version - ) - - $releaseArch = switch ([RuntimeInformation]::ProcessArchitecture) { - X64 { 'x64' } - X86 { 'x86' } - ARM64 { 'arm64' } - default { - $err = [ErrorRecord]::new( - [Exception]::new("Unsupported archecture requests '$_'"), - "UnknownArch", - [ErrorCategory]::InvalidArgument, - $_ - ) - $PSCmdlet.ThrowTerminatingError($err) - } - } - - $targetFolder = Join-Path $PSScriptRoot bin - if (-not (Test-Path -LiteralPath $targetFolder)) { - New-Item -Path $targetFolder -ItemType Directory | Out-Null - } - - if (-not $IsCoreCLR -or $IsWindows) { - $downloadUrl = "https://github.com/PowerShell/PowerShell/releases/download/v$Version/PowerShell-$Version-win-$releaseArch.zip" - $fileName = "pwsh-$Version.zip" - $nativeExt = ".exe" - } - else { - $downloadUrl = "https://github.com/PowerShell/PowerShell/releases/download/v$Version/powershell-$Version-linux-$releaseArch.tar.gz" - $fileName = "pwsh-$Version.tar.gz" - $nativeExt = "" - } - - $targetFile = Join-Path $targetFolder $fileName - if (-not (Test-Path -LiteralPath $targetFile)) { - $oldSecurityProtocol = [ServicePointManager]::SecurityProtocol - try { - & { - $ProgressPreference = 'SilentlyContinue' - [ServicePointManager]::SecurityProtocol = 'Tls12' - Invoke-WebRequest -UseBasicParsing -Uri $downloadUrl -OutFile $targetFile - } - } - finally { - [ServicePointManager]::SecurityProtocol = $oldSecurityProtocol - } - } - - $pwshFolder = Join-Path $targetFolder "pwsh-$Version" - if (-not (Test-Path -LiteralPath $pwshFolder)) { - New-Item -Path $pwshFolder -ItemType Directory | Out-Null - } - - $pwshFile = Join-Path $pwshFolder "pwsh$nativeExt" - if (-not (Test-Path -LiteralPath $pwshFile)) { - if (-not $IsCoreCLR -or $IsWindows) { - $oldPreference = $global:ProgressPreference - try { - $global:ProgressPreference = 'SilentlyContinue' - Expand-Archive -LiteralPath $targetFile -DestinationPath $pwshFolder - } - finally { - $global:ProgressPreference = $oldPreference - } - } - else { - tar -xf $targetFile --directory $pwshFolder - if ($LASTEXITCODE) { - throw "Failed to extract pwsh tar for $Version" - } - - chmod +x $pwshFile - if ($LASTEXITCODE) { - throw "Failed to set pwsh as executable at $pwshFile" - } - } - } - - $pwshFile -} - -Function Get-BuildInfo { - <# - .SYNOPSIS - Gets the module build information. - - .PARAMETER Path - The module directory. - #> - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] - $Path - ) - - $moduleSrc = [Path]::Combine($Path, 'module') - $manifestItem = Get-Item -Path ([Path]::Combine($moduleSrc, '*.psd1')) - $manifest = Test-ModuleManifest -Path $manifestItem.FullName -ErrorAction Ignore -WarningAction Ignore - $moduleName = $manifest.Name - $moduleVersion = $manifest.Version - - $dotnetSrc = [Path]::Combine($Path, "src", $moduleName) - if (Test-Path -LiteralPath $dotnetSrc) { - [xml]$csharpProjectInfo = Get-Content -Path ([Path]::Combine($dotnetSrc, '*.csproj')) - $targetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0].TargetFrameworks.Split( - ';', [StringSplitOptions]::RemoveEmptyEntries)) - } - else { - $dotnetSrc = $null - $targetFrameworks = @() - } - - [Ordered]@{ - ModuleName = $moduleName - Version = $moduleVersion - PowerShellSource = $moduleSrc - DotnetSource = $dotnetSrc - Configuration = "Release" - TargetFrameworks = $targetFrameworks - BuildDir = [Path]::Combine($Path, 'output', $build.ModuleName, $build.Version) - } -} - -Function Invoke-ModuleBuilder { - <# - .SYNOPSIS - Builds the module. - - .PARAMETER Path - The module directory to build. - #> - [CmdletBinding()] - [Alias('Invoke-ModuleBuild')] - param ( - [Parameter(Mandatory)] - [string] - $Path - ) - - Write-Host "Getting build information" - $Build = Get-BuildInfo -Path $Path - - if (-not (Test-Path -LiteralPath $Build.BuildDir)) { - New-Item -Path $Build.BuildDir -ItemType Directory -Force | Out-Null - } - - Write-Host "Compiling Dotnet assemblies" - Push-Location -LiteralPath $Build.DotnetSource - try { - $dotnetArgs = @( - 'publish' - '--configuration', $Build.Configuration, - '--verbosity', 'q', - '-nologo', - "-p:Version=$($Build.Version)" - ) - - foreach ($framework in $Build.TargetFrameworks) { - dotnet @dotnetArgs --framework $framework - if ($LASTEXITCODE) { - throw "Failed to compile code for $framework" - } - } - } - finally { - Pop-Location - } - - Write-Host "Build PowerShell module result" - Copy-Item -Path ([Path]::Combine($Build.PowerShellSource, "*")) -Destination $Build.BuildDir -Recurse - - foreach ($framework in $Build.TargetFrameworks) { - $publishFolder = [Path]::Combine($Build.DotnetSource, "bin", $Build.Configuration, $framework, "publish") - $binFolder = [Path]::Combine($Build.BuildDir, "bin", $framework) - if (-not (Test-Path -LiteralPath $binFolder)) { - New-Item -Path $binFolder -ItemType Directory | Out-Null - } - Copy-Item ([Path]::Combine($publishFolder, "*")) -Destination $binFolder -Recurse - } -} - -if (-not $IsCoreCLR -or $IsWindows) { - Function Add-GacAssembly { - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] - $Path - ) - - if (-not (Test-Path -LiteralPath $Path)) { - throw "Assembly does not exist at path '$Path'" - } - - $invokeParams = @{} - if ($IsCoreCLR) { - $s = New-PSSession -UseWindowsPowerShell - $invokeParams.Session = $s - } - - try { - Invoke-Command @invokeParams -ScriptBlock { - $ErrorActionPreference = 'Stop' - - [System.Reflection.Assembly]::LoadWithPartialName("System.EnterpriseServices") | Out-Null - $publish = [System.EnterpriseServices.Internal.Publish]::new() - $publish.GacInstall($args[0]) - } -ArgumentList $Path - } - finally { - if ($s) { Remove-PSSession -Session $s } - } - } - - Function Remove-GacAssembly { - [CmdletBinding()] - param ( - [Parameter(Mandatory)] - [string] - $Path - ) - - if (-not (Test-Path -LiteralPath $Path)) { - throw "Assembly does not exist at path '$Path'" - } - - $invokeParams = @{} - if ($IsCoreCLR) { - $s = New-PSSession -UseWindowsPowerShell - $invokeParams.Session = $s - } - - try { - Invoke-Command @invokeParams -ScriptBlock { - $ErrorActionPreference = 'Stop' - - [System.Reflection.Assembly]::LoadWithPartialName("System.EnterpriseServices") | Out-Null - $publish = [System.EnterpriseServices.Internal.Publish]::new() - $publish.GacRemove($args[0]) - } -ArgumentList $Path - } - finally { - if ($s) { Remove-PSSession -Session $s } - } - } -} diff --git a/tools/docs.ps1 b/tools/docs.ps1 deleted file mode 100644 index 59e8c39..0000000 --- a/tools/docs.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -. $PSScriptRoot/common.ps1 - -$reporoot = Split-Path $PSScriptRoot -@( - 'Pester' - 'PlatyPS' -) | ForEach-Object { - if (-not (Get-Module -Name $_ -ListAvailable)) { - Install-Module -Name $_ -Force -Scope CurrentUser - } - Import-Module -Name $_ -Force -} - -$output = Join-Path $reporoot 'output' -if (Test-Path $output) { - Remove-Item $output -Recurse -Force -} - -$docspath = Join-Path $output 'en-US' -if (Test-Path $docspath) { - Remove-Item $docspath -Recurse -Force -} -Get-ChildItem $output -Recurse -File | Where-Object { $_.Extension -in '.json', '.pdb' } | Remove-Item -Force - -$docs = Join-Path $reporoot 'docs' - -Get-ChildItem -LiteralPath $docs -Directory | ForEach-Object { - Write-Host "Building docs for $($_.Name)" - $helpParams = @{ - Path = $_.FullName - OutputPath = [System.IO.Path]::Combine($output, $_.Name) - Encoding = [System.Text.Encoding]::UTF8 - } - $null = New-ExternalHelp @helpParams -} diff --git a/tools/vsbuild.ps1 b/tools/vsbuild.ps1 deleted file mode 100644 index 8801689..0000000 --- a/tools/vsbuild.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -. $PSScriptRoot/common.ps1 - -@( - 'Pester' - 'PlatyPS' -) | ForEach-Object { - if (-not (Get-Module -Name $_ -ListAvailable)) { - Install-Module -Name $_ -Force -Scope CurrentUser - } - Import-Module -Name $_ -Force -} -$reporoot = Split-Path $PSScriptRoot - -$output = Join-Path $reporoot 'output' -if (Test-Path $output) { - Remove-Item $output -Recurse -Force -} -$csproj = Get-ChildItem -File -Include *.csproj -Recurse -Path $reporoot -$ModuleFile = Import-PowerShellDataFile -Path (Join-Path $reporoot 'Module' 'Sixel.psd1') -$newVersion = '{0}' -f $ModuleFile.ModuleVersion.ToString() - -foreach ($project in $csproj) { - $Content = Get-Content $project.FullName -Raw - # update tag in csproj file to match Module version - if ($Content -match $newVersion) { - # Write-Host "Version already set to $newVersion in $($project.Name)" - continue - } - $Content = $Content -replace '.*', $newVersion - Write-Host "Updating version to $newVersion in $($project.Name)" - Set-Content -Path $project.FullName -Value $Content -Force -} - -Invoke-ModuleBuilder -Path $reporoot