lib/fonts.ps1
|
# fonts.ps1 -- the coding-font catalogue: detection, download, install, apply. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # Two halves that look alike and are not. DETECTING what is installed # (Get-InstalledFontFamily, Test-MonospaceFont, Get-MonospaceFontList) is # platform-split, because System.Drawing's glyph measurement is GDI+ and so # Windows-only; off Windows the list is the curated set plus anything whose # name says "mono". INSTALLING (Resolve-FontPackage, Install-Font) is a # SHA-256-gated download into a per-user directory that needs no administrator # rights on any platform. function Test-MonospaceFont { # True when $FamilyName renders as monospace (fixed advance width), detected # by measuring a narrow vs wide glyph. Pass a reusable $Graphics for speed # when measuring many fonts; omit it and one is created/disposed per call. # Any error (font not constructible, measurement fails) -> $false. Curated # favorites bypass this check entirely, so they're always offered. param( [Parameter(Mandatory)][string]$FamilyName, $Graphics ) $ownGraphics = $false $bmp = $null try { if (-not $Graphics) { Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue $bmp = [System.Drawing.Bitmap]::new(1, 1) $Graphics = [System.Drawing.Graphics]::FromImage($bmp) $ownGraphics = $true } $font = [System.Drawing.Font]::new($FamilyName, 12.0) try { # GenericTypographic avoids layout padding, so the widths reflect the # glyph advance. Equal narrow/wide advance (within tolerance) = mono. $fmt = [System.Drawing.StringFormat]::GenericTypographic $wi = $Graphics.MeasureString('i', $font, [int]::MaxValue, $fmt).Width $ww = $Graphics.MeasureString('W', $font, [int]::MaxValue, $fmt).Width return [Math]::Abs($wi - $ww) -lt 0.5 } finally { $font.Dispose() } } catch { return $false } finally { if ($ownGraphics) { if ($Graphics) { $Graphics.Dispose() } if ($bmp) { $bmp.Dispose() } } } } function Get-MonospaceFontList { # Ordered, de-duplicated list of monospace font families to cycle in the # tuner: current font first, then installed curated favorites (always # trusted), then every OTHER installed monospace font (alphabetical), # Consolas fallback. -Installed and -MonospaceNames are test seams; real # callers omit them and we enumerate (System.Drawing) + measure # (Test-MonospaceFont). Curated favorites never get measured. param( [string]$Current, [string[]]$Installed, [string[]]$MonospaceNames ) if (-not $Installed) { $Installed = Get-InstalledFontFamily } $platform = Get-TStylesPlatform # Curated favorites, always offered when present and never measured. # Extended off Windows with the monospace families those systems ship, so # the tuner has something to cycle on a Mac that has none of the Windows # fonts installed. $allow = @('Cascadia Mono','Cascadia Code','Consolas','JetBrains Mono', 'Fira Code','Hack','Source Code Pro','DejaVu Sans Mono', 'Lucida Console','Courier New') if ($platform -eq 'MacOS') { $allow += @('SF Mono','Menlo','Monaco','Andale Mono','PT Mono','Courier') } elseif ($platform -eq 'Linux') { $allow += @('Liberation Mono','Ubuntu Mono','Noto Sans Mono','FreeMono') } $installedKeys = @{} foreach ($i in $Installed) { if ($i) { $installedKeys[(Get-FontComparisonKey -Name $i)] = $i } } # Match on the normalized key: off Windows the installed names come from # filenames, so "PT Mono" may have been recovered as "PTMono". $favorites = @($allow | Where-Object { $installedKeys.ContainsKey((Get-FontComparisonKey -Name $_)) }) # $null means "not provided" -> work it out. An explicit empty array (tests) # means "no monospace beyond favorites". if ($null -eq $MonospaceNames) { $MonospaceNames = @() if ($platform -eq 'Windows') { Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue try { $bmp = [System.Drawing.Bitmap]::new(1, 1) $g = [System.Drawing.Graphics]::FromImage($bmp) try { # Favorites are always offered, so skip measuring them. $MonospaceNames = @($Installed | Where-Object { $_ -notin $favorites } | Where-Object { Test-MonospaceFont -FamilyName $_ -Graphics $g }) } finally { $g.Dispose(); $bmp.Dispose() } } catch { $MonospaceNames = @() } } else { # No glyph measurement off Windows: Test-MonospaceFont needs GDI+, # which System.Drawing.Common no longer provides there. Rather than # offer all 369 installed families and let the user find the fixed- # width ones by trial, offer the curated set plus anything whose # name says it is monospace -- the near-universal convention for a # coding font. A mono font named otherwise is missed, which is a # smaller cost than filling the tuner with proportional faces. $MonospaceNames = @($Installed | Where-Object { $_ -and $_ -notin $favorites -and $_ -match '(?i)\b(mono|mononoki|code)\b' }) } } $others = @($MonospaceNames | Where-Object { $_ -notin $favorites } | Sort-Object) $list = @($favorites) + @($others) if (-not $list) { # Last resort differs by platform: Consolas does not exist on a Mac. $list = switch ($platform) { 'MacOS' { @('Menlo') } 'Linux' { @('DejaVu Sans Mono') } default { @('Consolas') } } } if ($Current) { $list = @($Current) + @($list | Where-Object { $_ -ne $Current }) } # The leading comma matters. `return @(...)` still writes the array to the # output stream, and PowerShell UNROLLS an array on the way out -- so a # machine with exactly one monospace font handed the caller a [string], and # the tuner's font-face knob then indexed into it per CHARACTER: the knob # read "M", then "e", then "n", and saved a one-letter font face. `,@(...)` # emits the array as a single object, so every call site gets an array. return ,@($list | Select-Object -Unique) } function Get-FontCatalog { # Parse the bundled font catalog (fonts.json). Returns the array of font # entries, skipping any that lack a required field. Throws on missing file # or invalid JSON. # $script:TStylesModuleRoot, NOT $PSScriptRoot: this function used to live in # tstyles.ps1 at the module root, where the two were the same. From lib/ they # are not, and $PSScriptRoot would look for lib/fonts.json. param([string]$Path = (Join-Path $script:TStylesModuleRoot 'fonts.json')) if (-not (Test-Path -LiteralPath $Path)) { throw "Font catalog not found: $Path" } $json = [System.IO.File]::ReadAllText($Path, [System.Text.UTF8Encoding]::new($false)) $data = $json | ConvertFrom-Json # throws on malformed JSON # @($data.fonts) is @() in PS7 but @($null) (one null element) in WinPS 5.1 when 'fonts' is absent/null; the per-entry null check below covers both. $entries = @($data.fonts) $valid = foreach ($e in $entries) { if (-not $e) { continue } if (-not $e.name -or -not $e.family -or -not $e.url -or -not $e.sha256) { continue } $e } return @($valid) } function Get-FontSearchPath { # Directories a font can be installed into for the current user, most # specific first. -Platform / -HomeDir are test seams. param( [string]$Platform = (Get-TStylesPlatform), [string]$HomeDir = $HOME ) switch ($Platform) { 'MacOS' { @( (Join-Path (Join-Path $HomeDir 'Library') 'Fonts') '/Library/Fonts' '/System/Library/Fonts' '/System/Library/Fonts/Supplemental' ) } 'Linux' { $xdg = $env:XDG_DATA_HOME if (-not $xdg) { $xdg = Join-Path (Join-Path $HomeDir '.local') 'share' } @( (Join-Path $xdg 'fonts') (Join-Path $HomeDir '.fonts') '/usr/local/share/fonts' '/usr/share/fonts' ) } default { @((Get-TStylesFontDir -Platform $Platform -HomeDir $HomeDir)) } } } function Get-FontComparisonKey { # Normalize a family name or font filename to a comparison key: lowercase, # letters and digits only. "JetBrains Mono" and "JetBrainsMono-Regular" # both reduce to a key one can prefix-match, which is what lets the # directory scan below recognize a family without parsing the font's # internal name table. param([Parameter(Mandatory)][AllowEmptyString()][string]$Name) return ($Name -replace '[^A-Za-z0-9]', '').ToLowerInvariant() } function Get-InstalledFontFamily { # Installed font family names. # # Windows enumerates through GDI+. Everywhere else that is not available: # System.Drawing.Common is Windows-only from .NET 6 onward, and constructing # an InstalledFontCollection on macOS throws a PInvokeGdiPlus type-initializer # error. The previous code caught that and fell back to an empty list, so # every font silently reported as "not installed" -- `tstyles font` showed # the whole catalogue as installable even right after installing one. # # The fallback scans the font directories and derives family names from # filenames. That cannot recover a family whose file is named unlike its # family (Apple's SFNSMono.ttf is "SF Mono"), which is why the curated # catalogue is matched by normalized key rather than by exact display name. param( [string]$Platform = (Get-TStylesPlatform), [string[]]$SearchPath ) if ($Platform -eq 'Windows') { Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue try { return @([System.Drawing.Text.InstalledFontCollection]::new().Families.Name) } catch { return @() } } if (-not $SearchPath) { $SearchPath = Get-FontSearchPath -Platform $Platform } $names = New-Object System.Collections.Generic.List[string] foreach ($dir in $SearchPath) { if (-not (Test-Path -LiteralPath $dir)) { continue } try { $files = Get-ChildItem -LiteralPath $dir -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Extension -match '(?i)^\.(ttf|otf|ttc|otc)$' } } catch { continue } foreach ($f in $files) { $base = [System.IO.Path]::GetFileNameWithoutExtension($f.Name) # Drop a trailing style suffix: "JetBrainsMono-Regular" -> "JetBrainsMono". $base = $base -replace '-(Regular|Italic|Bold|BoldItalic|Light|Medium|SemiBold|ExtraBold|Thin|Black|Oblique)$', '' # Prefer the family's real name when we know it. A filename cannot # be split back into words unambiguously -- "JetBrainsMono" is just # as readable as "Jet Brains Mono" to a splitter -- so canonicalize # against the names we do know before falling back to guessing. $key = Get-FontComparisonKey -Name $base if ($script:TStylesKnownFontNames.ContainsKey($key)) { $names.Add($script:TStylesKnownFontNames[$key]) continue } # Unknown family: split camel case, which is right more often than # not for font filenames, and only affects how the name is displayed. $display = ($base -creplace '(?<=[a-z0-9])(?=[A-Z])', ' ').Trim() if ($display) { $names.Add($display) } } } return @($names | Sort-Object -Unique) } function Test-FontInstalled { # True when $Family is among installed font families. # -Installed is a test seam; real callers omit it and we enumerate. param( [Parameter(Mandatory)][string]$Family, [string[]]$Installed ) if (-not $PSBoundParameters.ContainsKey('Installed')) { $Installed = Get-InstalledFontFamily } # Compare on the normalized key, not the raw string: off Windows the # "installed" names come from filenames, so "JetBrains Mono" has to match # a family recovered as "JetBrains Mono" from JetBrainsMono-Regular.ttf -- # equal only once spaces and case are taken out. $want = Get-FontComparisonKey -Name $Family if (-not $want) { return $false } return @($Installed | Where-Object { $_ -and (Get-FontComparisonKey -Name $_) -eq $want }).Count -gt 0 } function Get-UserFontInstallPlan { # Pure: map font files to their per-user install destinations + HKCU registry # value names. No filesystem/registry writes happen here. param( [Parameter(Mandatory)][string[]]$FontFiles, [string]$FontsDir = (Get-TStylesFontDir) ) foreach ($f in $FontFiles) { $leaf = Split-Path -Leaf $f $ext = [System.IO.Path]::GetExtension($leaf).ToLowerInvariant() $base = [System.IO.Path]::GetFileNameWithoutExtension($leaf) $kind = if ($ext -eq '.otf') { 'OpenType' } else { 'TrueType' } $dest = [System.IO.Path]::Combine($FontsDir, $leaf) [pscustomobject]@{ Source = $f Dest = $dest ValueName = "$base ($kind)" ValueData = $dest } } } function Resolve-FontPackage { # Download (or use -DownloadPath), verify SHA-256, and extract the listed # font files into the cache. Returns the extracted file paths. Throws on a # missing/empty download, a hash mismatch, or a listed file absent from the # archive -- never leaves a partially-installed state. param( [Parameter(Mandatory)]$Font, [string]$CacheRoot = (Join-Path $script:TStylesDataRoot 'fonts'), [string]$DownloadPath ) $cacheDir = Join-Path $CacheRoot $Font.name $archive = $DownloadPath if (-not $archive) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null $archive = Join-Path $cacheDir 'download.bin' $prev = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' try { Invoke-WebRequest -Uri $Font.url -OutFile $archive -UseBasicParsing -ErrorAction Stop } finally { $ProgressPreference = $prev } } if (-not (Test-Path -LiteralPath $archive) -or (Get-Item -LiteralPath $archive).Length -eq 0) { throw "Font download for '$($Font.name)' was empty or missing." } $actual = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne ("$($Font.sha256)").ToLowerInvariant()) { throw "SHA-256 mismatch for '$($Font.name)' (expected $($Font.sha256), got $actual). Refusing to install." } # Hash gate passed -- safe to create the extract directory now. $extractDir = Join-Path $cacheDir 'files' New-Item -ItemType Directory -Path $extractDir -Force | Out-Null # A direct .ttf/.otf download (no 'files') -- copy it through as-is. $ext = [System.IO.Path]::GetExtension($archive).ToLowerInvariant() if ((-not $Font.files -or @($Font.files).Count -eq 0) -and ($ext -in '.ttf','.otf','.ttc')) { $dest = Join-Path $extractDir (Split-Path -Leaf $Font.url) Copy-Item -LiteralPath $archive -Destination $dest -Force [string[]]$out = @($dest) return ,$out } Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($archive) [string[]]$out = @() try { foreach ($want in @($Font.files)) { $norm = $want -replace '\\','/' $entry = $zip.Entries | Where-Object { ($_.FullName -replace '\\','/') -eq $norm } | Select-Object -First 1 if (-not $entry) { throw "Archive for '$($Font.name)' has no entry '$want'." } $dest = Join-Path $extractDir (Split-Path -Leaf $norm) [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) $out += $dest } } finally { $zip.Dispose() } return ,$out } function Install-Font { # Install font files for the current user (no admin): copy to the per-user # Fonts dir, register under HKCU, then activate in the current session via # AddFontResource + a WM_FONTCHANGE broadcast so new processes (and WT on # reload) see them. -FontsDir / -RegistryRoot are test seams. param( [Parameter(Mandatory)][string[]]$FontFiles, [string]$FontsDir = (Get-TStylesFontDir), [string]$RegistryRoot = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Fonts' ) if (-not (Test-Path -LiteralPath $FontsDir)) { New-Item -ItemType Directory -Path $FontsDir -Force | Out-Null } # Registration is a Windows-only concept. On macOS, CoreText scans # ~/Library/Fonts and picks up a dropped file immediately -- no registry, no # broadcast. On Linux, fontconfig indexes the dir (fc-cache below nudges it). $isWindows = (Get-TStylesPlatform) -eq 'Windows' if ($isWindows -and -not (Test-Path -LiteralPath $RegistryRoot)) { New-Item -Path $RegistryRoot -Force | Out-Null } $plan = Get-UserFontInstallPlan -FontFiles $FontFiles -FontsDir $FontsDir $count = 0 foreach ($p in $plan) { Copy-Item -LiteralPath $p.Source -Destination $p.Dest -Force if ($isWindows) { New-ItemProperty -Path $RegistryRoot -Name $p.ValueName -Value $p.ValueData -PropertyType String -Force | Out-Null } $count++ } if (-not $isWindows) { # Best-effort cache refresh for fontconfig (Linux, and macOS setups that # have it via Homebrew). Absent on a stock Mac, where it isn't needed. if ((Get-TStylesPlatform) -eq 'Linux') { try { if (Get-Command fc-cache -ErrorAction SilentlyContinue) { & fc-cache -f $FontsDir *> $null } } catch { } } return $count } # Activate in this session (best effort; the file+registry install is the # durable part, so failures here are non-fatal). try { if (-not ('TStylesFontApi' -as [type])) { Add-Type -Namespace '' -Name 'TStylesFontApi' -MemberDefinition @' [System.Runtime.InteropServices.DllImport("gdi32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)] public static extern int AddFontResource(string lpFileName); [System.Runtime.InteropServices.DllImport("user32.dll", CharSet=System.Runtime.InteropServices.CharSet.Auto)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.IntPtr wParam, System.IntPtr lParam, uint flags, uint timeout, out System.IntPtr result); '@ } foreach ($p in $plan) { [void][TStylesFontApi]::AddFontResource($p.Dest) } $HWND_BROADCAST = [System.IntPtr]0xffff; $WM_FONTCHANGE = 0x001D $res = [System.IntPtr]::Zero [void][TStylesFontApi]::SendMessageTimeout($HWND_BROADCAST, $WM_FONTCHANGE, [System.IntPtr]::Zero, [System.IntPtr]::Zero, 0, 1000, [ref]$res) } catch { } return $count } function Set-ProfileFont { # Set $Family as the font.face on the target Windows Terminal profile (or # profiles.defaults). Returns $true if applied, $false if a named target # doesn't exist (file left untouched). Uses the atomic settings writer. param( [Parameter(Mandatory)][string]$SettingsPath, [Parameter(Mandatory)][string]$TargetName, [Parameter(Mandatory)][string]$Family ) $json = [System.IO.File]::ReadAllText($SettingsPath, [System.Text.UTF8Encoding]::new($false)) $settings = ConvertFrom-WTJson $json $entry = $null if ($TargetName -eq 'defaults') { if (-not $settings.profiles.PSObject.Properties.Match('defaults').Count) { $settings.profiles | Add-Member -NotePropertyName defaults -NotePropertyValue ([pscustomobject]@{}) } $entry = $settings.profiles.defaults } else { $entry = $settings.profiles.list | Where-Object name -eq $TargetName | Select-Object -First 1 if (-not $entry) { return $false } } if (-not $entry.PSObject.Properties.Match('font').Count) { $entry | Add-Member -NotePropertyName font -NotePropertyValue ([pscustomobject]@{}) } if ($entry.font.PSObject.Properties.Match('face').Count) { $entry.font.face = $Family } else { $entry.font | Add-Member -NotePropertyName face -NotePropertyValue $Family -Force } Write-SettingsAtomic -Path $SettingsPath -Json ($settings | ConvertTo-Json -Depth 100) return $true } function Show-FontList { # List the font catalog with an installed/installable marker. -Catalog and # -Installed are test seams; real callers omit them. param( [object[]]$Catalog, [string[]]$Installed ) if (-not $Catalog) { $Catalog = @(Get-FontCatalog) } Write-Host "" Write-Host " Available coding fonts ([+] installed, [ ] installable):" -ForegroundColor Cyan Write-Host "" foreach ($f in $Catalog) { $isIn = if ($PSBoundParameters.ContainsKey('Installed')) { Test-FontInstalled -Family $f.family -Installed $Installed } else { Test-FontInstalled -Family $f.family } $mark = if ($isIn) { '[+]' } else { '[ ]' } Write-Host (" {0} {1,-20} {2}" -f $mark, $f.name, $f.license) } Write-Host "" Write-Host " Install + apply one with: tstyles font <name>" -ForegroundColor DarkGray } function Invoke-TerminalStyleFont { # `tstyles font` (list) / `tstyles font <name>` (install if needed + apply). param( [string]$Name, [string]$Target ) if (-not $Name) { Show-FontList; return } $catalog = @(Get-FontCatalog) $font = $catalog | Where-Object { $_.name -eq $Name } | Select-Object -First 1 if (-not $font) { Write-Host "Unknown font: '$Name'" -ForegroundColor Yellow Write-Host "Available: $(@($catalog | ForEach-Object name) -join ', ')" -ForegroundColor DarkGray return } if (Test-FontInstalled -Family $font.family) { Write-Host "'$($font.family)' is already installed." -ForegroundColor Green } else { Write-Host "Installing '$($font.name)'..." -ForegroundColor Cyan try { $files = Resolve-FontPackage -Font $font $n = Install-Font -FontFiles $files Write-Host " Installed $n file(s) for '$($font.family)'." -ForegroundColor Green } catch { Write-Host "Font install failed: $_" -ForegroundColor Red return } } # Apply to the active profile. Applying a font means writing it into a # profile, and Set-ProfileFont only knows how to write Windows Terminal's # settings.json -- there is no escape sequence for a font face. So off WT # the install above IS the whole job: say so, rather than chasing a # settings.json that cannot exist and reporting "Could not locate Windows # Terminal settings.json" in red after an install that actually succeeded. $fontKind = Get-TerminalKind if (-not (Get-TerminalCapability -Kind $fontKind).Font) { Write-Host (" {0} takes its font from its own preferences, so TerminalStyles cannot apply it for you." -f (Get-TerminalDisplayName -Kind $fontKind)) -ForegroundColor DarkGray Write-Host (" '{0}' is installed and will be listed there." -f $font.family) -ForegroundColor DarkGray return } $settingsPath = Find-WTSettingsPath if (-not $settingsPath) { Write-Host "Could not locate Windows Terminal settings.json." -ForegroundColor Red; return } if (-not $Target) { $json = [System.IO.File]::ReadAllText($settingsPath, [System.Text.UTF8Encoding]::new($false)) $Target = Get-CurrentWTProfileName -Settings (ConvertFrom-WTJson $json) } if (-not $Target) { Write-Host "Could not detect the current profile; pass -Target '<name>'." -ForegroundColor Yellow; return } try { [System.IO.File]::WriteAllText("$settingsPath.bak", [System.IO.File]::ReadAllText($settingsPath, [System.Text.UTF8Encoding]::new($false)), [System.Text.UTF8Encoding]::new($false)) } catch { } if (Set-ProfileFont -SettingsPath $settingsPath -TargetName $Target -Family $font.family) { Write-Host " Applied '$($font.family)' to '$Target'. Open a new tab to see it." -ForegroundColor Green } else { Write-Host "Profile '$Target' not found in settings.json." -ForegroundColor Yellow } } |