GoSaveTool.psm1
|
# GoSaveTool: SRT and WebVTT subtitle tools, word and character counts, social media limits and case changes. # The PowerShell version of @gosavetool/subtitles and @gosavetool/text. https://www.gosavetool.com/ Set-StrictMode -Version 3 #region Subtitles $script:TimeRx = '(\d{1,2}:)?(\d{1,2}):(\d{1,2})[,.](\d{1,3})' $script:ArrowRx = '^\s*((?:\d{1,2}:)?\d{1,2}:\d{1,2}[,.]\d{1,3})\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{1,2}[,.]\d{1,3})(.*)$' function ConvertTo-Milliseconds([string] $Stamp) { $m = [regex]::Match($Stamp, $script:TimeRx) if (-not $m.Success) { return $null } $h = if ($m.Groups[1].Success) { [int]$m.Groups[1].Value.TrimEnd(':') } else { 0 } return (([int64]$h * 60 + [int]$m.Groups[2].Value) * 60 + [int]$m.Groups[3].Value) * 1000 + [int]$m.Groups[4].Value.PadRight(3, '0') } function ConvertFrom-Milliseconds([double] $Ms, [string] $Separator = ',') { # Half up, as JavaScript's Math.round $ms = [int64][math]::Max(0, [math]::Floor($Ms + 0.5)) return '{0:00}:{1:00}:{2:00}{3}{4:000}' -f [math]::Floor($ms / 3600000), ([math]::Floor($ms / 60000) % 60), ([math]::Floor($ms / 1000) % 60), $Separator, ($ms % 1000) } function Read-SubtitleInput($Path, $Text) { if ($Path) { return [IO.File]::ReadAllText((Resolve-Path -LiteralPath $Path).ProviderPath, [Text.Encoding]::UTF8) } return [string]$Text } function Test-WebVtt([string] $Text) { return $Text.TrimStart([char]0xFEFF).StartsWith('WEBVTT', [StringComparison]::Ordinal) } <# .SYNOPSIS Reads the cues of an SRT or WebVTT file or text. .DESCRIPTION Returns one object per cue with Start and End in milliseconds, Settings (WebVTT cue settings) and Text. Headers, NOTE and STYLE blocks are skipped. Online editor: https://www.gosavetool.com/vtt-subtitle-generator .EXAMPLE ConvertFrom-Subtitle -Path movie.srt | Select-Object -First 3 .EXAMPLE Get-Content movie.vtt -Raw | ConvertFrom-Subtitle #> function ConvertFrom-Subtitle { [CmdletBinding(DefaultParameterSetName = 'Text')] param( [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)] [string] $Path, [Parameter(Mandatory, ParameterSetName = 'Text', ValueFromPipeline)] [AllowEmptyString()] [string] $Text ) process { $src = Read-SubtitleInput $Path $Text # "\r\r\n" too: a file saved twice with Windows line endings $norm = [regex]::Replace($src.TrimStart([char]0xFEFF), '\r*\n|\r', "`n") foreach ($block in [regex]::Split($norm, '\n{2,}')) { $lines = $block -split "`n" for ($i = 0; $i -lt $lines.Count; $i++) { $m = [regex]::Match($lines[$i], $script:ArrowRx) if ($m.Success) { $body = if ($i + 1 -lt $lines.Count) { ($lines[($i + 1)..($lines.Count - 1)] -join "`n").Trim() } else { '' } [PSCustomObject]@{ PSTypeName = 'GoSaveTool.Cue' Start = ConvertTo-Milliseconds $m.Groups[1].Value End = ConvertTo-Milliseconds $m.Groups[2].Value Settings = $m.Groups[3].Value.Trim() Text = $body } break } } } } } <# .SYNOPSIS Writes cues as SRT text, numbered from 1. .EXAMPLE ConvertFrom-Subtitle -Path movie.vtt | ConvertTo-Srt | Set-Content movie.srt -Encoding UTF8 #> function ConvertTo-Srt { [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyCollection()] [object[]] $Cue) begin { $all = [Collections.Generic.List[object]]::new() } process { foreach ($c in $Cue) { $all.Add($c) } } end { $i = 0 (($all | ForEach-Object { $i++; "$i`n$(ConvertFrom-Milliseconds $_.Start ',') --> $(ConvertFrom-Milliseconds $_.End ',')`n$($_.Text)" }) -join "`n`n") + "`n" } } <# .SYNOPSIS Writes cues as WebVTT text; cue settings are kept. .EXAMPLE ConvertFrom-Subtitle -Path movie.srt | ConvertTo-WebVtt | Set-Content movie.vtt -Encoding UTF8 #> function ConvertTo-WebVtt { [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyCollection()] [object[]] $Cue) begin { $all = [Collections.Generic.List[object]]::new() } process { foreach ($c in $Cue) { $all.Add($c) } } end { $body = ($all | ForEach-Object { $settings = if ($_.Settings) { ' ' + $_.Settings } else { '' } "$(ConvertFrom-Milliseconds $_.Start '.') --> $(ConvertFrom-Milliseconds $_.End '.')$settings`n$($_.Text)" }) -join "`n`n" "WEBVTT`n`n" + $body + "`n" } } function Write-SubtitleOutput([string] $Content, [string] $Target, [switch] $PassThru) { if ($PassThru) { return $Content } # UTF-8 without a byte order mark, as players expect [IO.File]::WriteAllText($Target, $Content, [Text.UTF8Encoding]::new($false)) Get-Item -LiteralPath $Target } <# .SYNOPSIS Converts an SRT file to WebVTT or a WebVTT file to SRT. .DESCRIPTION Writes name.vtt or name.srt next to the file, or to -OutFile. With -PassThru the text is returned instead. .EXAMPLE Convert-Subtitle movie.srt -To vtt .EXAMPLE Get-ChildItem *.srt | Convert-Subtitle -To vtt #> function Convert-Subtitle { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path, [Parameter(Mandatory)] [ValidateSet('srt', 'vtt')] [string] $To, [string] $OutFile, [switch] $PassThru ) process { $cues = @(ConvertFrom-Subtitle -Path $Path) if (-not $cues.Count) { Write-Error "No subtitle cues found in $Path"; return } $out = if ($To -eq 'vtt') { ConvertTo-WebVtt -Cue $cues } else { ConvertTo-Srt -Cue $cues } $full = (Resolve-Path -LiteralPath $Path).ProviderPath $target = if ($OutFile) { $OutFile } else { [IO.Path]::ChangeExtension($full, $To) } if ($PassThru -or $PSCmdlet.ShouldProcess($target, 'Write subtitles')) { Write-SubtitleOutput $out $target -PassThru:$PassThru } } } <# .SYNOPSIS Moves every cue of a subtitle file earlier or later. .DESCRIPTION Positive seconds move cues later, negative earlier. Cues pushed before zero are clipped or dropped. Changes the file in place unless -OutFile or -PassThru is given. .EXAMPLE Move-SubtitleTiming movie.srt -Seconds -1.5 #> function Move-SubtitleTiming { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path, [Parameter(Mandatory, Position = 1)] [double] $Seconds, [string] $OutFile, [switch] $PassThru ) process { $src = Read-SubtitleInput $Path $null $ms = [int64][math]::Floor($Seconds * 1000 + 0.5) $cues = @(ConvertFrom-Subtitle -Text $src | ForEach-Object { $end = [math]::Max(0, $_.End + $ms) if ($end -gt 0) { [PSCustomObject]@{ PSTypeName = 'GoSaveTool.Cue'; Start = [math]::Max(0, $_.Start + $ms); End = $end; Settings = $_.Settings; Text = $_.Text } } }) $out = if (Test-WebVtt $src) { ConvertTo-WebVtt -Cue $cues } else { ConvertTo-Srt -Cue $cues } $target = if ($OutFile) { $OutFile } else { (Resolve-Path -LiteralPath $Path).ProviderPath } if ($PassThru -or $PSCmdlet.ShouldProcess($target, "Move cues by $Seconds s")) { Write-SubtitleOutput $out $target -PassThru:$PassThru } } } <# .SYNOPSIS Repairs a subtitle file: sorts cues, fixes overlaps and end times before the start, drops empty cues, renumbers. .DESCRIPTION Changes the file in place unless -OutFile or -PassThru is given, and writes the number of problems fixed to the information stream. .EXAMPLE Repair-Subtitle movie.srt -InformationAction Continue #> function Repair-Subtitle { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path, [int] $MinDuration = 2000, [string] $OutFile, [switch] $PassThru ) process { $src = Read-SubtitleInput $Path $null $r = Repair-Cue -Cue @(ConvertFrom-Subtitle -Text $src) -MinDuration $MinDuration $out = if (Test-WebVtt $src) { ConvertTo-WebVtt -Cue $r.Cues } else { ConvertTo-Srt -Cue $r.Cues } Write-Information ($(if ($r.Fixed) { "$($r.Fixed) problems fixed" } else { 'No problems found' })) $target = if ($OutFile) { $OutFile } else { (Resolve-Path -LiteralPath $Path).ProviderPath } if ($PassThru -or $PSCmdlet.ShouldProcess($target, 'Repair subtitles')) { Write-SubtitleOutput $out $target -PassThru:$PassThru } } } <# .SYNOPSIS Repairs cue objects; returns an object with Cues and Fixed (the number of changes). #> function Repair-Cue { [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Cue, [int] $MinDuration = 2000) $fixed = @($Cue | Where-Object { $_.Text -eq '' }).Count $i = 0 $out = @($Cue | Where-Object { $_.Text -ne '' } | ForEach-Object { [PSCustomObject]@{ PSTypeName = 'GoSaveTool.Cue'; Start = $_.Start; End = $_.End; Settings = $_.Settings; Text = $_.Text; Index = $i++ } }) $before = @($out | ForEach-Object { $_.Start }) # Stable: equal starts keep their order $out = @($out | Sort-Object Start, Index) for ($k = 0; $k -lt $out.Count; $k++) { if ($out[$k].Start -ne $before[$k]) { $fixed++; break } } for ($k = 0; $k -lt $out.Count; $k++) { $c = $out[$k] if (-not ($c.End -gt $c.Start)) { $c.End = $c.Start + $MinDuration; $fixed++ } if ($k + 1 -lt $out.Count -and $c.End -gt $out[$k + 1].Start) { $c.End = [math]::Max($c.Start + 1, $out[$k + 1].Start - 1); $fixed++ } $c.PSObject.Properties.Remove('Index') } [PSCustomObject]@{ Cues = $out; Fixed = $fixed } } <# .SYNOPSIS Format, number of cues and end time of a subtitle file. .EXAMPLE Get-ChildItem *.srt | Get-SubtitleInfo #> function Get-SubtitleInfo { [CmdletBinding()] param([Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path) process { $src = Read-SubtitleInput $Path $null $cues = @(ConvertFrom-Subtitle -Text $src) $vtt = Test-WebVtt $src $end = if ($cues.Count) { ($cues | Measure-Object End -Maximum).Maximum } else { 0 } [PSCustomObject]@{ Path = $Path Format = if ($vtt) { 'WebVTT' } else { 'SRT' } Cues = $cues.Count EndsAt = ConvertFrom-Milliseconds $end $(if ($vtt) { '.' } else { ',' }) } } } #endregion #region Text $script:Limits = [ordered]@{ 'x-post' = 280 'instagram-caption' = 2200 'tiktok-caption' = 4000 'youtube-title' = 100 'youtube-description' = 5000 'linkedin-post' = 3000 'pinterest-description' = 500 'seo-title' = 60 'meta-description' = 160 } $script:Small = @('a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet', 'via', 'vs') # Code points, so an emoji counts as one character as in JavaScript's [...s].length function Get-CodePointCount([string] $s) { $n = 0 foreach ($ch in $s.ToCharArray()) { if (-not [char]::IsLowSurrogate($ch)) { $n++ } } return $n } <# .SYNOPSIS Words, characters, sentences, lines and reading time of a text. .DESCRIPTION Reading time uses 238 words a minute, the average silent reading speed for English non-fiction. The same counter is online at https://www.gosavetool.com/character-counter .EXAMPLE Get-Content post.txt -Raw | Get-TextStats #> function Get-TextStats { [CmdletBinding()] param([Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text) process { $words = [regex]::Matches($Text, "[\p{L}\p{N}][\p{L}\p{N}'’_-]*").Count [PSCustomObject]@{ Words = $words Chars = Get-CodePointCount $Text CharsNoSpaces = Get-CodePointCount ([regex]::Replace($Text, '\s', '')) Lines = if ($Text -eq '') { 0 } else { [regex]::Split($Text, '\r\n|\r|\n').Count } Sentences = [regex]::Matches($Text, '[^.!?…]+[.!?…]+').Count ReadSeconds = [int][math]::Floor($words / 238 * 60 + 0.5) } } } <# .SYNOPSIS How a text meets the character limits of X, Instagram, TikTok, YouTube, LinkedIn, Pinterest and SEO fields. .EXAMPLE 'My new video is live!' | Test-TextLimit | Format-Table .EXAMPLE Test-TextLimit -Text $caption -Field instagram-caption, tiktok-caption #> function Test-TextLimit { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text, [ValidateSet('x-post', 'instagram-caption', 'tiktok-caption', 'youtube-title', 'youtube-description', 'linkedin-post', 'pinterest-description', 'seo-title', 'meta-description')] [string[]] $Field ) process { $used = Get-CodePointCount $Text foreach ($k in $script:Limits.Keys) { if ($Field -and $Field -notcontains $k) { continue } [PSCustomObject]@{ Field = $k; Limit = $script:Limits[$k]; Used = $used; Left = $script:Limits[$k] - $used; Fits = $used -le $script:Limits[$k] } } } } function Split-Words([string] $s) { $spaced = [regex]::Replace($s, '([\p{Ll}\p{N}])(\p{Lu})', '$1 $2') return @([regex]::Split($spaced, '[^\p{L}\p{N}]+') | Where-Object { $_ }) } function Get-TitleCase([string] $s) { $state = @{ First = $true } return [regex]::Replace($s.ToLowerInvariant(), "[\p{L}\p{N}][\p{L}\p{N}'’]*", { param($m) $afterColon = [regex]::IsMatch($s.Substring(0, $m.Index), '[:.!?]\s*$') $keep = -not $state.First -and -not $afterColon -and $script:Small -ccontains $m.Value $state.First = $false if ($keep) { $m.Value } else { $m.Value.Substring(0, 1).ToUpperInvariant() + $m.Value.Substring(1) } }) } function Get-SentenceCase([string] $s) { return [regex]::Replace($s.ToLowerInvariant(), '(^\s*|[.!?…]\s+)(\p{L})', { param($m) $m.Groups[1].Value + $m.Groups[2].Value.ToUpperInvariant() }) } <# .SYNOPSIS URL slug of a text, with Turkish and accented letters: 'Ölçü Birimi & Çeviri!' becomes 'olcu-birimi-ceviri'. .EXAMPLE 'Best Posting Time on Instagram' | ConvertTo-Slug #> function ConvertTo-Slug { [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text) begin { $map = @{ 0x131 = 'i'; 0x130 = 'i'; 0x15F = 's'; 0x15E = 's'; 0x11F = 'g'; 0x11E = 'g'; 0xE7 = 'c'; 0xC7 = 'c'; 0xF6 = 'o'; 0xD6 = 'o' 0xFC = 'u'; 0xDC = 'u'; 0xDF = 'ss'; 0xE6 = 'ae'; 0xF8 = 'o'; 0x111 = 'd'; 0x142 = 'l' } } process { $sb = [Text.StringBuilder]::new() foreach ($ch in $Text.ToCharArray()) { if ($map.ContainsKey([int]$ch)) { [void]$sb.Append($map[[int]$ch]) } else { [void]$sb.Append($ch) } } $plain = [regex]::Replace($sb.ToString().Normalize([Text.NormalizationForm]::FormKD), '[̀-ͯ]', '').ToLowerInvariant() [regex]::Replace([regex]::Replace($plain, '[^a-z0-9]+', '-'), '^-+|-+$', '') } } <# .SYNOPSIS Changes the case of a text: Title, Sentence, Upper, Lower, Slug, Camel, Snake or Kebab. .DESCRIPTION Title Case keeps short words (a, of, the, and…) in lower case unless they start the text or follow a colon. .EXAMPLE 'the lord of the rings: return of the king' | Convert-TextCase -To Title .EXAMPLE Convert-TextCase 'best posting time' -To Camel #> function Convert-TextCase { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text, [Parameter(Mandatory)] [ValidateSet('Title', 'Sentence', 'Upper', 'Lower', 'Slug', 'Camel', 'Snake', 'Kebab')] [string] $To ) process { switch ($To) { 'Title' { Get-TitleCase $Text } 'Sentence' { Get-SentenceCase $Text } 'Upper' { $Text.ToUpperInvariant() } 'Lower' { $Text.ToLowerInvariant() } 'Slug' { ConvertTo-Slug $Text } 'Camel' { $w = @(Split-Words $Text) $parts = for ($i = 0; $i -lt $w.Count; $i++) { if ($i -eq 0) { $w[$i].ToLowerInvariant() } else { $w[$i].Substring(0, 1).ToUpperInvariant() + $w[$i].Substring(1).ToLowerInvariant() } } -join $parts } 'Snake' { (Split-Words $Text | ForEach-Object { $_.ToLowerInvariant() }) -join '_' } 'Kebab' { (Split-Words $Text | ForEach-Object { $_.ToLowerInvariant() }) -join '-' } } } } <# .SYNOPSIS Removes trailing spaces, runs of spaces and more than one blank line in a row. .EXAMPLE Get-Content draft.txt -Raw | Format-TextWhitespace | Set-Content draft.txt #> function Format-TextWhitespace { [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text) process { $nl = if ($Text.Contains("`r`n")) { "`r`n" } else { "`n" } $lines = [regex]::Split($Text, '\r\n|\n') | ForEach-Object { [regex]::Replace([regex]::Replace($_, '[ \t]+$', ''), '(\S)[ \t]{2,}', '$1 ') } [regex]::Replace(($lines -join $nl), '(' + [regex]::Escape($nl) + '){3,}', $nl + $nl) } } #endregion Export-ModuleMember -Function ConvertFrom-Subtitle, ConvertTo-Srt, ConvertTo-WebVtt, Convert-Subtitle, Move-SubtitleTiming, Repair-Subtitle, Get-SubtitleInfo, Get-TextStats, Test-TextLimit, Convert-TextCase, ConvertTo-Slug, Format-TextWhitespace |