├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── main-manual.yml │ └── main.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md └── src ├── PgsToSrt.sln ├── PgsToSrt ├── BluRaySup │ ├── BluRaySupParserExtensions.cs │ ├── BluRaySupParserImageSharp.cs │ └── ImageExtensions.cs ├── CommandLineOptions.cs ├── MkvUtilities.cs ├── NLog.config ├── Options │ ├── TrackOption.cs │ └── TrackOutputOption.cs ├── PgsOcr.cs ├── PgsParser.cs ├── PgsToSrt.csproj ├── Program.cs ├── Runner.cs ├── TesseractApi.cs └── TesseractData.cs ├── entrypoint.sh └── publish.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | tessdata/.git 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root 2 | root = true 3 | 4 | # Global config 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [Makefile] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/main-manual.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | docker: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: set lower case owner name 10 | run: | 11 | echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV} 12 | env: 13 | OWNER: '${{ github.repository_owner }}' 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v2 17 | - 18 | name: Set up QEMU 19 | uses: docker/setup-qemu-action@v1 20 | - 21 | name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v1 23 | - 24 | name: Login to DockerHub 25 | uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | - 30 | name: Login to GitHub Container Registry 31 | uses: docker/login-action@v1 32 | with: 33 | registry: ghcr.io 34 | username: ${{ env.OWNER_LC }} 35 | password: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 36 | - 37 | name: Build and push 38 | uses: docker/build-push-action@v2 39 | with: 40 | context: . 41 | file: Dockerfile 42 | platforms: linux/amd64 43 | push: true 44 | tags: | 45 | ${{ secrets.DOCKERHUB_USERNAME }}/pgstosrt:latest 46 | ${{ secrets.DOCKERHUB_USERNAME }}/pgstosrt:${{ github.ref_name }} 47 | ghcr.io/${{ env.OWNER_LC }}/pgstosrt:latest 48 | ghcr.io/${{ env.OWNER_LC }}/pgstosrt:${{ github.ref_name }} 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: set lower case owner name 13 | run: | 14 | echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV} 15 | env: 16 | OWNER: '${{ github.repository_owner }}' 17 | - 18 | name: Checkout 19 | uses: actions/checkout@v2 20 | - 21 | name: Set up QEMU 22 | uses: docker/setup-qemu-action@v1 23 | - 24 | name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v1 26 | - 27 | name: Login to DockerHub 28 | uses: docker/login-action@v1 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | # - 33 | # name: Login to GitHub Container Registry 34 | # uses: docker/login-action@v1 35 | # with: 36 | # registry: ghcr.io 37 | # username: ${{ env.OWNER_LC }} 38 | # password: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 39 | - 40 | name: Build and push 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | file: Dockerfile 45 | platforms: linux/amd64 46 | push: true 47 | tags: | 48 | ${{ secrets.DOCKERHUB_USERNAME }}/pgstosrt:latest 49 | ${{ secrets.DOCKERHUB_USERNAME }}/pgstosrt:${{ github.ref_name }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc 262 | 263 | /PgsToSrt/Properties/launchSettings.json 264 | 265 | Out 266 | 267 | /tessdata 268 | Makefile.env 269 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder 2 | 3 | ARG LANGUAGE=eng 4 | 5 | RUN apt-get -y update && \ 6 | apt-get -y upgrade && \ 7 | apt-get -y install \ 8 | automake \ 9 | ca-certificates \ 10 | g++ \ 11 | libtool \ 12 | libtesseract5 \ 13 | make \ 14 | pkg-config \ 15 | wget \ 16 | libc6-dev 17 | 18 | ADD https://github.com/tesseract-ocr/tessdata/raw/main/eng.traineddata /tessdata/ 19 | 20 | COPY ./src /src 21 | 22 | RUN cd /src && \ 23 | dotnet restore && \ 24 | dotnet publish -c Release -f net8.0 -o /src/PgsToSrt/out 25 | 26 | FROM mcr.microsoft.com/dotnet/runtime:8.0 27 | WORKDIR /app 28 | ENV LANGUAGE=eng 29 | ENV INPUT=/input.sup 30 | ENV OUTPUT=/output.srt 31 | 32 | RUN apt-get update && \ 33 | apt-get install -y \ 34 | libtesseract5 \ 35 | && apt-get clean && \ 36 | rm -rf /var/lib/apt/lists/* 37 | 38 | VOLUME /tessdata 39 | 40 | COPY --from=builder /src/PgsToSrt/out . 41 | COPY --from=builder /tessdata /tessdata 42 | COPY ./src/entrypoint.sh /entrypoint.sh 43 | 44 | # Docker for Windows: EOL must be LF. 45 | ENTRYPOINT ["/entrypoint.sh"] 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables 2 | REPOSITORY := tentacule/pgstosrt 3 | LANGUAGE := eng 4 | TAG_ALL := latest 5 | 6 | # Makefile.env 7 | ifneq (,$(wildcard ./Makefile.env)) 8 | include Makefile.env 9 | export 10 | endif 11 | 12 | ## 13 | ##@ General 14 | ## 15 | 16 | ## Print this message and exit 17 | .PHONY: help 18 | help: 19 | @awk ' \ 20 | BEGIN { \ 21 | printf "\nUsage:\n make \033[36m\033[0m\n" \ 22 | } \ 23 | END { \ 24 | printf "\n" \ 25 | } \ 26 | /^[0-9A-Za-z-]+:/ { \ 27 | if (prev ~ /^## /) { \ 28 | printf " \x1b[36m%-23s\x1b[0m %s\n", substr($$1, 0, length($$1)-1), substr(prev, 3) \ 29 | } \ 30 | } \ 31 | /^##@/ { \ 32 | printf "\n\033[1m%s\033[0m\n", substr($$0, 5) \ 33 | } \ 34 | !/^\.PHONY/ { \ 35 | prev = $$0 \ 36 | } \ 37 | ' $(MAKEFILE_LIST) 38 | 39 | 40 | ## 41 | ##@ Supplemental 42 | ## 43 | 44 | ## Download tesseract-ocr data files 45 | tessdata: 46 | git clone --depth=1 https://github.com/tesseract-ocr/tessdata.git 47 | 48 | 49 | ## 50 | ##@ Single language 51 | ## 52 | 53 | ## Build a single-language docker image (options: LANGUAGE=eng) 54 | build-single: tessdata 55 | docker build . \ 56 | --file Dockerfile \ 57 | --tag $(REPOSITORY):$(LANGUAGE) \ 58 | --build-arg LANGUAGE=$(LANGUAGE) 59 | 60 | ## Push a single-language docker image (options: LANGUAGE=eng) 61 | push-single: 62 | docker push $(REPOSITORY):$(LANGUAGE) 63 | 64 | 65 | ## 66 | ##@ Multi language 67 | ## 68 | 69 | ## Build all-languages docker image (default language is `eng`) 70 | build-all: tessdata 71 | docker build . \ 72 | --file Dockerfile \ 73 | --tag $(REPOSITORY):$(TAG_ALL) \ 74 | --build-arg LANGUAGE=* 75 | 76 | ## Push all-languages docker image 77 | push-all: 78 | docker push $(REPOSITORY):$(TAG_ALL) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PgsToSrt 2 | 3 | Convert [PGS](https://en.wikipedia.org/wiki/Presentation_Graphic_Stream) subtitles to [SRT](https://en.wikipedia.org/wiki/SubRip) using [OCR](https://en.wikipedia.org/wiki/Optical_character_recognition). 4 | 5 | ## Prerequisites 6 | 7 | - [.NET 8.0 Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) 8 | - [Tesseract 4 language data files](https://github.com/tesseract-ocr/tessdata/) 9 | 10 | Data files must be placed in the `tessdata` folder inside PgsToSrt folder, or the path can be specified in the command line with the --tesseractdata parameter. 11 | 12 | You only need data files for the language(s) you want to convert. 13 | 14 | ## Usage 15 | 16 | dotnet PgsToSrt.dll [parameters] 17 | 18 | | Parameter | Description | 19 | | --------------------- |--------------------------------------------------------------------------------------------------------------------------------------------------| 20 | | `--input` | Input filename, can be an mkv file or pgs subtitle extracted to a .sup file with mkvextract. | 21 | | `--output` | Output SubRip (`.srt`) filename. Auto generated from input filename if not set. | 22 | | `--track` | Track number of the subtitle to process in an `.mkv` file (only required when input is a matroska file)
This can be obtained with `mkvinfo` | 23 | | `--tracklanguage` | Convert all tracks of the specified language (only works with `.mkv` input) | 24 | | `--tesseractlanguage` | Tesseract language to use if multiple languages are available in the tesseract data directory. | 25 | | `--tesseractdata` | Path of tesseract language data files, by default `tessdata` in the executable directory. | 26 | | `--tesseractversion` | libtesseract version, support 4 and 5 (default: 4) (ignored on Windows platform) | 27 | | `--libleptname` | leptonica library name, usually lept or leptonica, 'lib' prefix is automatically added (default: lept) (ignored on Windows platform) | 28 | | `--libleptversion` | leptonica library version (default: 5) (ignored on Windows platform) | 29 | 30 | ## Example (Command Line) 31 | 32 | ``` sh 33 | dotnet PgsToSrt.dll --input video1.fr.sup --output video1.fr.srt --tesseractlanguage fra 34 | dotnet PgsToSrt.dll --input video1.mkv --output video1.srt --track 4 35 | ``` 36 | 37 | ## Example (Docker) 38 | 39 | Examime `entrypoint.sh` for a full list of all available arguments. 40 | 41 | ``` sh 42 | docker run -it --rm \ 43 | -v /data:/data \ 44 | -e INPUT=/data/myImageSubtitle.sup \ 45 | -e OUTPUT=/data/myTextSubtitle.srt \ 46 | -e LANGUAGE=eng \ 47 | tentacule/pgstosrt 48 | ``` 49 | 50 | Hint: The default arguments coming from `Dockerfile` are `INPUT=/input.sup` and `OUTPUT=/output.srt`, so you can easily: 51 | 52 | ``` sh 53 | touch output-file.srt # This needs to be a file, otherwise Docker will just assume it's a directory mount and it will fail. 54 | docker run --it -rm \ 55 | -v source-file.sup:/input.sup \ 56 | -v output-file.srt:/output.srt \ 57 | -e LANGUAGE=eng \ 58 | tentacule/pgstosrt 59 | ``` 60 | 61 | ## Dependencies 62 | 63 | - Windows : none, tesseract/leptonica libraries are included in the release package. 64 | - Linux : libtesseract5 (`sudo apt install libtesseract5` or whatever your distro requires) 65 | 66 | ## Build 67 | 68 | To build PgsToSrt.dll execute the following commands in the `src/` directory: 69 | 70 | ``` sh 71 | dotnet restore 72 | dotnet publish -c Release -o out --framework net6.0 73 | # The file produced is PgsToSrt/out/PgsToSrt.dll 74 | ``` 75 | 76 | To build a Docker image for all languages: 77 | 78 | ``` sh 79 | make build-all 80 | ``` 81 | 82 | To build a docker image for a single language: 83 | 84 | ``` sh 85 | make build-single LANGUAGE=eng # or any other Tessaract-available language code 86 | ``` 87 | 88 | ## Built With 89 | 90 | - LibSE from [Subtitle Edit](https://www.nikse.dk/SubtitleEdit/) 91 | - [Tesseract .net wrapper](https://github.com/charlesw/tesseract/) 92 | - [CommandLineParser](https://github.com/commandlineparser/commandline) 93 | - [SixLabors ImageSharp](https://github.com/SixLabors/ImageSharp) 94 | -------------------------------------------------------------------------------- /src/PgsToSrt.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.28407.52 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PgsToSrt", "PgsToSrt\PgsToSrt.csproj", "{EA301B7E-3C30-4D3B-98EA-EFBC03AB7085}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {EA301B7E-3C30-4D3B-98EA-EFBC03AB7085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {EA301B7E-3C30-4D3B-98EA-EFBC03AB7085}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {EA301B7E-3C30-4D3B-98EA-EFBC03AB7085}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {EA301B7E-3C30-4D3B-98EA-EFBC03AB7085}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {6CC5BBD1-197F-4C3B-AEFB-D94219164EEA} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /src/PgsToSrt/BluRaySup/BluRaySupParserExtensions.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.PixelFormats; 3 | using SixLabors.ImageSharp.Processing; 4 | using System; 5 | using System.Collections.Generic; 6 | using Nikse.SubtitleEdit.Core.BluRaySup; 7 | 8 | namespace PgsToSrt.BluRaySup 9 | { 10 | public static class BluRaySupParserExtensions 11 | { 12 | public static Image GetRgba32(this BluRaySupParserImageSharp.PcsData pcsData) 13 | { 14 | if (pcsData.PcsObjects.Count == 1) 15 | return SupDecoder.DecodeImage(pcsData.PcsObjects[0], pcsData.BitmapObjects[0], pcsData.PaletteInfos); 16 | 17 | var r = Rectangle.Empty; 18 | for (var ioIndex = 0; ioIndex < pcsData.PcsObjects.Count; ioIndex++) 19 | { 20 | var ioRect = new Rectangle(pcsData.PcsObjects[ioIndex].Origin, pcsData.BitmapObjects[ioIndex][0].Size); 21 | r = r.IsEmpty ? ioRect : Rectangle.Union(r, ioRect); 22 | } 23 | 24 | var mergedBmp = new Image(r.Width, r.Height); 25 | for (var ioIndex = 0; ioIndex < pcsData.PcsObjects.Count; ioIndex++) 26 | { 27 | var offset = pcsData.PcsObjects[ioIndex].Origin - new Size(r.Location); 28 | using var singleBmp = SupDecoder.DecodeImage(pcsData.PcsObjects[ioIndex], pcsData.BitmapObjects[ioIndex], pcsData.PaletteInfos); 29 | 30 | mergedBmp.Mutate(b => b.DrawImage(singleBmp, new Point(offset.X, offset.Y), 0)); 31 | } 32 | 33 | return mergedBmp; 34 | } 35 | } 36 | 37 | static class SupDecoder 38 | { 39 | /// 40 | /// Decode caption from the input stream 41 | /// 42 | /// bitmap of the decoded caption 43 | public static Image DecodeImage( 44 | BluRaySupParserImageSharp.PcsObject pcs, 45 | IList data, 46 | List palettes) 47 | { 48 | if (pcs == null || data == null || data.Count == 0) 49 | return new Image(1, 1); 50 | var width = data[0].Size.Width; 51 | var height = data[0].Size.Height; 52 | if (width <= 0 || height <= 0 || data[0].Fragment.ImageBuffer.Length == 0) 53 | return new Image(1, 1); 54 | 55 | using var bmp = new Image(width, height); 56 | 57 | bmp.DangerousTryGetSinglePixelMemory(out var pixelMemory); 58 | var pixelSpan = pixelMemory.Span; 59 | 60 | var palette = BluRaySupParserImageSharp.DecodePalette(palettes); 61 | int num1 = 0; 62 | int num2 = 0; 63 | int num3 = 0; 64 | byte[] imageBuffer = data[0].Fragment.ImageBuffer; 65 | do 66 | { 67 | var color1 = imageBuffer[num3++] & byte.MaxValue; 68 | if (color1 == 0 && num3 < imageBuffer.Length) 69 | { 70 | int num4 = imageBuffer[num3++] & byte.MaxValue; 71 | if (num4 == 0) 72 | { 73 | num1 = num1 / width * width; 74 | if (num2 < width) 75 | num1 += width; 76 | num2 = 0; 77 | } 78 | else if ((num4 & 192) == 64) 79 | { 80 | if (num3 < imageBuffer.Length) 81 | { 82 | int num5 = (num4 - 64 << 8) + ((int) imageBuffer[num3++] & (int) byte.MaxValue); 83 | Color color2 = GetColorFromInt(palette.GetArgb(0)); 84 | for (int index = 0; index < num5; ++index) 85 | PutPixel(pixelSpan, num1++, color2); 86 | num2 += num5; 87 | } 88 | } 89 | else if ((num4 & 192) == 128) 90 | { 91 | if (num3 < imageBuffer.Length) 92 | { 93 | int num6 = num4 - 128; 94 | int index1 = imageBuffer[num3++] & byte.MaxValue; 95 | Color color3 = GetColorFromInt(palette.GetArgb(index1)); 96 | for (int index2 = 0; index2 < num6; ++index2) 97 | PutPixel(pixelSpan, num1++, color3); 98 | num2 += num6; 99 | } 100 | } 101 | else if ((num4 & 192) != 0) 102 | { 103 | if (num3 < imageBuffer.Length) 104 | { 105 | int num7 = num4 - 192 << 8; 106 | byte[] numArray1 = imageBuffer; 107 | int index3 = num3; 108 | int num8 = index3 + 1; 109 | int num9 = numArray1[index3] & byte.MaxValue; 110 | int num10 = num7 + num9; 111 | byte[] numArray2 = imageBuffer; 112 | int index4 = num8; 113 | num3 = index4 + 1; 114 | int index5 = (int) numArray2[index4] & byte.MaxValue; 115 | Color color4 = GetColorFromInt(palette.GetArgb(index5)); 116 | for (int index6 = 0; index6 < num10; ++index6) 117 | PutPixel(pixelSpan, num1++, color4); 118 | num2 += num10; 119 | } 120 | } 121 | else 122 | { 123 | Color color5 = GetColorFromInt(palette.GetArgb(0)); 124 | for (int index = 0; index < num4; ++index) 125 | PutPixel(pixelSpan, num1++, color5); 126 | num2 += num4; 127 | } 128 | } 129 | else 130 | { 131 | PutPixel(pixelSpan, num1++, color1, palette); 132 | ++num2; 133 | } 134 | } while (num3 < imageBuffer.Length); 135 | 136 | var bmp2 = new Image(width + 50, height + 50); 137 | // ReSharper disable once AccessToDisposedClosure 138 | bmp2.Mutate(i => i.DrawImage(bmp, new Point(25, 25), 1f)); 139 | 140 | return bmp2; 141 | } 142 | 143 | private static void PutPixel(Span bmp, int index, int color, BluRaySupPalette palette) 144 | { 145 | var colorArgb = GetColorFromInt(palette.GetArgb(color)); 146 | PutPixel(bmp, index, colorArgb); 147 | } 148 | 149 | private static void PutPixel(Span bmp, int index, Rgba32 color) 150 | { 151 | if (color.A > 0) 152 | { 153 | bmp[index] = color; 154 | } 155 | } 156 | 157 | private static Rgba32 GetColorFromInt(int number) 158 | { 159 | var values = BitConverter.GetBytes(number); 160 | if (!BitConverter.IsLittleEndian) Array.Reverse(values); 161 | 162 | var b = values[0]; 163 | var g = values[1]; 164 | var r = values[2]; 165 | var a = values[3]; 166 | 167 | return new Rgba32(r, g, b, a); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/PgsToSrt/BluRaySup/BluRaySupParserImageSharp.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using Nikse.SubtitleEdit.Core.BluRaySup; 7 | using Nikse.SubtitleEdit.Core.Common; 8 | using Nikse.SubtitleEdit.Core.ContainerFormats.Matroska; 9 | using SixLabors.ImageSharp; 10 | 11 | namespace PgsToSrt.BluRaySup; 12 | 13 | public static class BluRaySupParserImageSharp 14 | { 15 | public static bool BluRaySupForceMergeAll { get; set; } 16 | public static bool BluRaySupSkipMerge { get; set; } 17 | 18 | public static List ParseBluRaySup(string fileName, StringBuilder log) 19 | { 20 | using var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); 21 | var lastPalettes = new Dictionary>(); 22 | var bitmapObjects = new Dictionary>(); 23 | return ParseBluRaySup(stream, log, false, lastPalettes, bitmapObjects); 24 | } 25 | 26 | public static List ParseBluRaySupFromMatroska( 27 | MatroskaTrackInfo matroskaSubtitleInfo, 28 | MatroskaFile matroska) 29 | { 30 | var subtitle = matroska.GetSubtitle(matroskaSubtitleInfo.TrackNumber, null); 31 | var raySupFromMatroska = new List(); 32 | var log = new StringBuilder(); 33 | var ms = new MemoryStream(); 34 | var lastPalettes = new Dictionary>(); 35 | var bitmapObjects = new Dictionary>(); 36 | foreach (var matroskaSubtitle in subtitle) 37 | { 38 | var data = matroskaSubtitle.GetData(matroskaSubtitleInfo); 39 | if (data is {Length: > 2}) 40 | { 41 | ms.Write(data, 0, data.Length); 42 | if (ContainsBluRayStartSegment(data)) 43 | { 44 | if (raySupFromMatroska.Count > 0 && raySupFromMatroska[raySupFromMatroska.Count - 1].StartTime == raySupFromMatroska[raySupFromMatroska.Count - 1].EndTime) 45 | raySupFromMatroska[raySupFromMatroska.Count - 1].EndTime = (long) ((matroskaSubtitle.Start - 1L) * 90.0); 46 | ms.Position = 0L; 47 | foreach (var pcsData in ParseBluRaySup(ms, log, true, lastPalettes, bitmapObjects)) 48 | { 49 | pcsData.StartTime = (long) ((matroskaSubtitle.Start - 1L) * 90.0); 50 | pcsData.EndTime = (long) ((matroskaSubtitle.End - 1L) * 90.0); 51 | raySupFromMatroska.Add(pcsData); 52 | if (raySupFromMatroska.Count > 1 && subtitle[raySupFromMatroska.Count - 2].End > subtitle[raySupFromMatroska.Count - 1].Start) 53 | raySupFromMatroska[raySupFromMatroska.Count - 2].EndTime = raySupFromMatroska[raySupFromMatroska.Count - 1].StartTime - 1L; 54 | } 55 | 56 | ms = new MemoryStream(); 57 | } 58 | } 59 | else if (raySupFromMatroska.Count > 0) 60 | { 61 | PcsData pcsData = raySupFromMatroska[raySupFromMatroska.Count - 1]; 62 | if (pcsData.StartTime == pcsData.EndTime) 63 | { 64 | pcsData.EndTime = (long) ((matroskaSubtitle.Start - 1L) * 90.0); 65 | if (pcsData.EndTime - pcsData.StartTime > 1000000L) 66 | pcsData.EndTime = pcsData.StartTime; 67 | } 68 | } 69 | } 70 | 71 | return raySupFromMatroska; 72 | } 73 | 74 | private static bool ContainsBluRayStartSegment(byte[] buffer) 75 | { 76 | int num; 77 | for (int index = 0; index + 3 <= buffer.Length; index += num) 78 | { 79 | if (buffer[index] == 128) 80 | return true; 81 | num = BigEndianInt16(buffer, index + 1) + 3; 82 | } 83 | 84 | return false; 85 | } 86 | 87 | private static SupSegment ParseSegmentHeader(byte[] buffer, StringBuilder log) 88 | { 89 | var segmentHeader = new SupSegment(); 90 | if (buffer[0] == 80 && buffer[1] == 71) 91 | { 92 | segmentHeader.PtsTimestamp = BigEndianInt32(buffer, 2); 93 | segmentHeader.Type = buffer[10]; 94 | segmentHeader.Size = BigEndianInt16(buffer, 11); 95 | } 96 | 97 | return segmentHeader; 98 | } 99 | 100 | private static SupSegment ParseSegmentHeaderFromMatroska(byte[] buffer) 101 | { 102 | return new SupSegment() 103 | { 104 | Type = buffer[0], 105 | Size = BigEndianInt16(buffer, 1) 106 | }; 107 | } 108 | 109 | private static PcsObject ParsePcs(byte[] buffer, int offset) 110 | { 111 | return new PcsObject() 112 | { 113 | ObjectId = BigEndianInt16(buffer, 11 + offset), 114 | WindowId = buffer[13 + offset], 115 | IsForced = (buffer[14 + offset] & 64) == 64, 116 | Origin = new Point(BigEndianInt16(buffer, 15 + offset), BigEndianInt16(buffer, 17 + offset)) 117 | }; 118 | } 119 | 120 | private static PcsData ParsePicture( 121 | byte[] buffer, 122 | SupSegment segment) 123 | { 124 | if (buffer.Length < 11) 125 | return new PcsData() 126 | { 127 | CompositionState = CompositionState.Invalid 128 | }; 129 | var stringBuilder = new StringBuilder(); 130 | var picture = new PcsData() 131 | { 132 | Size = new Size(BigEndianInt16(buffer, 0), BigEndianInt16(buffer, 2)), 133 | FramesPerSecondType = buffer[4], 134 | CompNum = BigEndianInt16(buffer, 5), 135 | CompositionState = GetCompositionState(buffer[7]), 136 | StartTime = segment.PtsTimestamp, 137 | PaletteUpdate = buffer[8] == 128, 138 | PaletteId = buffer[9] 139 | }; 140 | var num = buffer[10]; 141 | stringBuilder.Append($"CompNum: {picture.CompNum}, Pts: {ToolBox.PtsToTimeString(picture.StartTime)}, State: {(object) picture.CompositionState}, PalUpdate: {(object) picture.PaletteUpdate}, PalId {(object) picture.PaletteId}"); 142 | if (picture.CompositionState == CompositionState.Invalid) 143 | { 144 | stringBuilder.Append("Illegal composition state Invalid"); 145 | } 146 | else 147 | { 148 | var offset = 0; 149 | picture.PcsObjects = new List(); 150 | for (var index = 0; index < num; ++index) 151 | { 152 | var pcs = ParsePcs(buffer, offset); 153 | picture.PcsObjects.Add(pcs); 154 | stringBuilder.AppendLine(); 155 | stringBuilder.Append($"ObjId: {pcs.ObjectId}, WinId: {pcs.WindowId}, Forced: {pcs.IsForced}, X: {pcs.Origin.X}, Y: {pcs.Origin.Y}"); 156 | offset += 8; 157 | } 158 | } 159 | 160 | picture.Message = stringBuilder.ToString(); 161 | return picture; 162 | } 163 | 164 | private static bool CompletePcs( 165 | PcsData pcs, 166 | Dictionary> bitmapObjects, 167 | Dictionary> palettes) 168 | { 169 | if (pcs?.PcsObjects == null || palettes == null) 170 | return false; 171 | if (pcs.PcsObjects.Count == 0) 172 | return true; 173 | if (!palettes.ContainsKey(pcs.PaletteId)) 174 | return false; 175 | pcs.PaletteInfos = new List(palettes[pcs.PaletteId]); 176 | pcs.BitmapObjects = new List>(); 177 | var flag = false; 178 | for (var index = 0; index < pcs.PcsObjects.Count; ++index) 179 | { 180 | var objectId = pcs.PcsObjects[index].ObjectId; 181 | if (bitmapObjects.ContainsKey(objectId)) 182 | { 183 | pcs.BitmapObjects.Add(bitmapObjects[objectId]); 184 | flag = true; 185 | } 186 | } 187 | 188 | return flag; 189 | } 190 | 191 | private static PdsData ParsePds( 192 | byte[] buffer, 193 | SupSegment segment) 194 | { 195 | int num1 = buffer[0]; 196 | int num2 = buffer[1]; 197 | PaletteInfo paletteInfo = new PaletteInfo() 198 | { 199 | PaletteSize = (segment.Size - 2) / 5 200 | }; 201 | if (paletteInfo.PaletteSize <= 0) 202 | return new PdsData() 203 | { 204 | Message = "Empty palette" 205 | }; 206 | paletteInfo.PaletteBuffer = new byte[paletteInfo.PaletteSize * 5]; 207 | Buffer.BlockCopy(buffer, 2, paletteInfo.PaletteBuffer, 0, paletteInfo.PaletteSize * 5); 208 | return new PdsData() 209 | { 210 | Message = "PalId: " + num1 + ", update: " + num2 + ", " + paletteInfo.PaletteSize + " entries", 211 | PaletteId = num1, 212 | PaletteVersion = num2, 213 | PaletteInfo = paletteInfo 214 | }; 215 | } 216 | 217 | private static OdsData ParseOds( 218 | byte[] buffer, 219 | SupSegment segment, 220 | bool forceFirst) 221 | { 222 | var num1 = BigEndianInt16(buffer, 0); 223 | var num2 = buffer[2]; 224 | var num3 = buffer[3]; 225 | var flag1 = (num3 & 128) == 128 | forceFirst; 226 | var flag2 = (num3 & 64) == 64; 227 | var imageObjectFragment = new ImageObjectFragment(); 228 | if (flag1) 229 | { 230 | var width = BigEndianInt16(buffer, 7); 231 | var height = BigEndianInt16(buffer, 9); 232 | imageObjectFragment.ImagePacketSize = segment.Size - 11; 233 | imageObjectFragment.ImageBuffer = new byte[imageObjectFragment.ImagePacketSize]; 234 | Buffer.BlockCopy(buffer, 11, imageObjectFragment.ImageBuffer, 0, imageObjectFragment.ImagePacketSize); 235 | return new OdsData() 236 | { 237 | IsFirst = true, 238 | Size = new Size(width, height), 239 | ObjectId = num1, 240 | ObjectVersion = num2, 241 | Fragment = imageObjectFragment, 242 | Message = "ObjId: " + num1 + ", ver: " + num2 + ", seq: first" + (flag2 ? "/" : "") + (flag2 ? "last" : "") + ", width: " + width.ToString() + ", height: " + height.ToString() 243 | }; 244 | } 245 | 246 | imageObjectFragment.ImagePacketSize = segment.Size - 4; 247 | imageObjectFragment.ImageBuffer = new byte[imageObjectFragment.ImagePacketSize]; 248 | Buffer.BlockCopy(buffer, 4, imageObjectFragment.ImageBuffer, 0, imageObjectFragment.ImagePacketSize); 249 | return new OdsData() 250 | { 251 | IsFirst = false, 252 | ObjectId = num1, 253 | ObjectVersion = num2, 254 | Fragment = imageObjectFragment, 255 | Message = "Continued ObjId: " + num1 + ", ver: " + num2 + ", seq: " + (flag2 ? "last" : "") 256 | }; 257 | } 258 | 259 | private static List ParseBluRaySup( 260 | Stream ms, 261 | StringBuilder log, 262 | bool fromMatroskaFile, 263 | Dictionary> lastPalettes, 264 | Dictionary> bitmapObjects) 265 | { 266 | var num1 = ms.Position; 267 | var num2 = 0; 268 | var dictionary = new Dictionary>(); 269 | var forceFirst = true; 270 | var bluRaySup = new List(); 271 | 272 | PcsData pcs1 = null; 273 | 274 | var buffer1 = fromMatroskaFile ? new byte[3] : new byte[13]; 275 | while (ms.Read(buffer1, 0, buffer1.Length) == buffer1.Length) 276 | { 277 | var segment = fromMatroskaFile ? ParseSegmentHeaderFromMatroska(buffer1) : ParseSegmentHeader(buffer1, log); 278 | var num3 = num1 + buffer1.Length; 279 | try 280 | { 281 | byte[] buffer2 = new byte[segment.Size]; 282 | if (ms.Read(buffer2, 0, buffer2.Length) >= buffer2.Length) 283 | { 284 | switch (segment.Type) 285 | { 286 | case 20: 287 | if (pcs1 != null) 288 | { 289 | PdsData pds = ParsePds(buffer2, segment); 290 | if (pds.PaletteInfo != null) 291 | { 292 | if (!dictionary.ContainsKey(pds.PaletteId)) 293 | dictionary[pds.PaletteId] = new List(); 294 | else if (pcs1.PaletteUpdate) 295 | dictionary[pds.PaletteId].RemoveAt(dictionary[pds.PaletteId].Count - 1); 296 | dictionary[pds.PaletteId].Add(pds.PaletteInfo); 297 | break; 298 | } 299 | 300 | break; 301 | } 302 | 303 | break; 304 | case 21: 305 | if (pcs1 != null) 306 | { 307 | OdsData ods = ParseOds(buffer2, segment, forceFirst); 308 | List odsDataList; 309 | if (!pcs1.PaletteUpdate) 310 | { 311 | if (ods.IsFirst) 312 | { 313 | odsDataList = new List() 314 | { 315 | ods 316 | }; 317 | bitmapObjects[ods.ObjectId] = odsDataList; 318 | } 319 | else if (bitmapObjects.TryGetValue(ods.ObjectId, out odsDataList)) 320 | odsDataList.Add(ods); 321 | } 322 | 323 | forceFirst = false; 324 | break; 325 | } 326 | 327 | break; 328 | case 22: 329 | if (pcs1 != null && CompletePcs(pcs1, bitmapObjects, dictionary.Count > 0 ? dictionary : lastPalettes)) 330 | bluRaySup.Add(pcs1); 331 | forceFirst = true; 332 | PcsData picture = ParsePicture(buffer2, segment); 333 | if (picture.StartTime > 0L && bluRaySup.Count > 0 && bluRaySup.Last().EndTime == 0L) 334 | bluRaySup.Last().EndTime = picture.StartTime; 335 | pcs1 = picture; 336 | if (pcs1.CompositionState == CompositionState.EpochStart) 337 | { 338 | bitmapObjects.Clear(); 339 | dictionary.Clear(); 340 | break; 341 | } 342 | 343 | break; 344 | case 23: 345 | if (pcs1 != null) 346 | { 347 | int num4 = buffer2[0]; 348 | int num5 = 0; 349 | for (int index = 0; index < num4; ++index) 350 | { 351 | int num6 = buffer2[1 + num5]; 352 | int num7 = BigEndianInt16(buffer2, 2 + num5); 353 | int num8 = BigEndianInt16(buffer2, 4 + num5); 354 | int num9 = BigEndianInt16(buffer2, 6 + num5); 355 | int num10 = BigEndianInt16(buffer2, 8 + num5); 356 | log.AppendLine(string.Format("WinId: {4}, X: {0}, Y: {1}, Width: {2}, Height: {3}", num7, num8, num9, num10, num6)); 357 | num5 += 9; 358 | } 359 | 360 | break; 361 | } 362 | 363 | break; 364 | case 128: 365 | forceFirst = true; 366 | if (pcs1 != null) 367 | { 368 | if (CompletePcs(pcs1, bitmapObjects, dictionary.Count > 0 ? dictionary : lastPalettes)) 369 | bluRaySup.Add(pcs1); 370 | pcs1 = null; 371 | break; 372 | } 373 | 374 | break; 375 | } 376 | } 377 | else 378 | break; 379 | } 380 | catch (IndexOutOfRangeException ex) 381 | { 382 | log.Append($"Index of of range at pos {num3 - buffer1.Length}: {ex.StackTrace}"); 383 | } 384 | 385 | num1 = num3 + segment.Size; 386 | ++num2; 387 | } 388 | 389 | if (pcs1 != null && CompletePcs(pcs1, bitmapObjects, dictionary.Count > 0 ? dictionary : lastPalettes)) 390 | bluRaySup.Add(pcs1); 391 | for (int index = 1; index < bluRaySup.Count; ++index) 392 | { 393 | PcsData pcsData = bluRaySup[index - 1]; 394 | if (pcsData.EndTime == 0L) 395 | pcsData.EndTime = bluRaySup[index].StartTime; 396 | } 397 | 398 | bluRaySup.RemoveAll((Predicate) (pcs => pcs.PcsObjects.Count == 0)); 399 | foreach (PcsData pcsData in bluRaySup) 400 | { 401 | foreach (List bitmapObject in pcsData.BitmapObjects) 402 | { 403 | if (bitmapObject.Count > 1) 404 | { 405 | int length = 0; 406 | foreach (OdsData odsData in bitmapObject) 407 | length += odsData.Fragment.ImagePacketSize; 408 | byte[] dst = new byte[length]; 409 | int dstOffset = 0; 410 | foreach (OdsData odsData in bitmapObject) 411 | { 412 | Buffer.BlockCopy(odsData.Fragment.ImageBuffer, 0, dst, dstOffset, odsData.Fragment.ImagePacketSize); 413 | dstOffset += odsData.Fragment.ImagePacketSize; 414 | } 415 | 416 | bitmapObject[0].Fragment.ImageBuffer = dst; 417 | bitmapObject[0].Fragment.ImagePacketSize = length; 418 | while (bitmapObject.Count > 1) 419 | bitmapObject.RemoveAt(1); 420 | } 421 | } 422 | } 423 | 424 | if (!BluRaySupSkipMerge || BluRaySupForceMergeAll) 425 | { 426 | var source1 = new List(); 427 | var deleteNo = 0; 428 | for (var pcsIndex = bluRaySup.Count - 1; pcsIndex > 0; pcsIndex--) 429 | { 430 | var pcsData1 = bluRaySup[pcsIndex]; 431 | var pcsData2 = bluRaySup[pcsIndex - 1]; 432 | if (Math.Abs(pcsData2.EndTime - pcsData1.StartTime) < 10L) 433 | { 434 | var size = pcsData2.Size; 435 | var width1 = size.Width; 436 | size = pcsData1.Size; 437 | var width2 = size.Width; 438 | if (width1 == width2) 439 | { 440 | size = pcsData2.Size; 441 | int height1 = size.Height; 442 | size = pcsData1.Size; 443 | int height2 = size.Height; 444 | if (height1 == height2) 445 | { 446 | if (pcsData1.BitmapObjects.Count > 0 && pcsData1.BitmapObjects[0].Count > 0 && pcsData2.BitmapObjects.Count == pcsData1.BitmapObjects.Count && pcsData2.BitmapObjects[0].Count == pcsData1.BitmapObjects[0].Count) 447 | { 448 | var flag = true; 449 | for (var index1 = 0; index1 < pcsData1.BitmapObjects.Count; ++index1) 450 | { 451 | var bitmapObject1 = pcsData1.BitmapObjects[index1]; 452 | var bitmapObject2 = pcsData2.BitmapObjects[index1]; 453 | if (bitmapObject2.Count == bitmapObject1.Count) 454 | { 455 | for (var index2 = 0; index2 < bitmapObject1.Count; ++index2) 456 | { 457 | if (!ByteArraysEqual(bitmapObject1[index2].Fragment.ImageBuffer, bitmapObject2[index2].Fragment.ImageBuffer)) 458 | { 459 | flag = false; 460 | break; 461 | } 462 | } 463 | } 464 | else 465 | { 466 | flag = false; 467 | break; 468 | } 469 | } 470 | 471 | if (flag) 472 | { 473 | if (!source1.Any((Func) (p => p.Number == deleteNo && p.Index == pcsIndex - 1))) 474 | source1.Add(new DeleteIndex() 475 | { 476 | Number = deleteNo, 477 | Index = pcsIndex - 1 478 | }); 479 | if (!source1.Any((Func) (p => p.Number == deleteNo && p.Index == pcsIndex))) 480 | { 481 | source1.Add(new DeleteIndex() 482 | { 483 | Number = deleteNo, 484 | Index = pcsIndex 485 | }); 486 | //continue; 487 | } 488 | 489 | continue; 490 | } 491 | 492 | deleteNo++; 493 | //continue; 494 | } 495 | 496 | continue; 497 | } 498 | } 499 | } 500 | 501 | deleteNo++; 502 | } 503 | 504 | var mergeCount = source1.GroupBy((Func) (p => p.Number)).Count>(); 505 | foreach (IGrouping source2 in source1.GroupBy((Func) (p => p.Number)).OrderBy, int>((Func, int>) (p => p.Key))) 506 | { 507 | DeleteIndex[] array = source2.OrderByDescending((Func) (p => p.Index)).ToArray(); 508 | int index = (int) Math.Round(source2.Count() / 2.0); 509 | DeleteIndex deleteIndex1 = array[index]; 510 | if (QualifiesForMerge(array, bluRaySup, mergeCount)) 511 | { 512 | bluRaySup[deleteIndex1.Index].StartTime = bluRaySup[array.Last().Index].StartTime; 513 | bluRaySup[deleteIndex1.Index].EndTime = bluRaySup[array.First().Index].EndTime; 514 | foreach (DeleteIndex deleteIndex2 in (IEnumerable) source2.OrderByDescending((Func) (p => p.Index))) 515 | { 516 | if (deleteIndex2 != deleteIndex1) 517 | bluRaySup.RemoveAt(deleteIndex2.Index); 518 | } 519 | } 520 | } 521 | } 522 | 523 | if (lastPalettes != null && dictionary.Count > 0) 524 | { 525 | lastPalettes.Clear(); 526 | foreach (var keyValuePair in dictionary) 527 | lastPalettes.Add(keyValuePair.Key, keyValuePair.Value); 528 | } 529 | 530 | return bluRaySup; 531 | } 532 | 533 | private static bool QualifiesForMerge( 534 | IReadOnlyList arr, 535 | IReadOnlyList pcsList, 536 | int mergeCount) 537 | { 538 | if (BluRaySupForceMergeAll || mergeCount < 3) 539 | return false; 540 | if (arr.Count != 2) 541 | return true; 542 | var pcs1 = pcsList[arr[0].Index]; 543 | var pcs2 = pcsList[arr[1].Index]; 544 | var num1 = pcs1.EndTimeCode.TotalMilliseconds - pcs1.StartTimeCode.TotalMilliseconds; 545 | var num2 = pcs2.EndTimeCode.TotalMilliseconds - pcs2.StartTimeCode.TotalMilliseconds; 546 | if (num1 < 400.0 || num2 < 400.0 || pcs1.PaletteInfos.Count > 2 || pcs2.PaletteInfos.Count > 2) 547 | return true; 548 | 549 | using var bitmap1 = pcs1.GetRgba32(); 550 | using var bitmap2 = pcs2.GetRgba32(); 551 | 552 | var transparentHeight = bitmap1.GetNonTransparentHeight(); 553 | var transparentWidth = bitmap1.GetNonTransparentWidth(); 554 | if (transparentHeight > 110 || transparentWidth > 300) 555 | return true; 556 | 557 | return bitmap1.IsEqualTo(bitmap2); 558 | } 559 | 560 | private static bool ByteArraysEqual(byte[] b1, byte[] b2) 561 | { 562 | if (b1 == b2) 563 | return true; 564 | if (b1 == null || b2 == null || b1.Length != b2.Length) 565 | return false; 566 | for (int index = 0; index < b1.Length; ++index) 567 | { 568 | if (b1[index] != b2[index]) 569 | return false; 570 | } 571 | 572 | return true; 573 | } 574 | 575 | private static CompositionState GetCompositionState(byte type) 576 | { 577 | switch (type) 578 | { 579 | case 0: 580 | return CompositionState.Normal; 581 | case 64: 582 | return CompositionState.AcquPoint; 583 | case 128: 584 | return CompositionState.EpochStart; 585 | case 192: 586 | return CompositionState.EpochContinue; 587 | default: 588 | return CompositionState.Invalid; 589 | } 590 | } 591 | 592 | private static int BigEndianInt16(byte[] buffer, int index) 593 | { 594 | return buffer.Length < 2 ? 0 : buffer[index + 1] | buffer[index] << 8; 595 | } 596 | 597 | private static uint BigEndianInt32(byte[] buffer, int index) 598 | { 599 | return buffer.Length < 4 ? 0U : (uint) (buffer[index + 3] + (buffer[index + 2] << 8) + (buffer[index + 1] << 16) + (buffer[index] << 24)); 600 | } 601 | 602 | private class SupSegment 603 | { 604 | public int Type { get; set; } 605 | 606 | public int Size { get; set; } 607 | 608 | public long PtsTimestamp { get; set; } 609 | } 610 | 611 | public class PcsObject 612 | { 613 | public int ObjectId { get; init; } 614 | 615 | public int WindowId { get; init; } 616 | 617 | public bool IsForced { get; init; } 618 | 619 | public Point Origin { get; init; } 620 | } 621 | 622 | public static BluRaySupPalette DecodePalette(IList paletteInfos) 623 | { 624 | var bluRaySupPalette = new BluRaySupPalette(256); 625 | if (paletteInfos.Count == 0) 626 | return bluRaySupPalette; 627 | var paletteInfo = paletteInfos[paletteInfos.Count - 1]; 628 | var flag = false; 629 | var index1 = 0; 630 | for (var index2 = 0; index2 < paletteInfo.PaletteSize; ++index2) 631 | { 632 | var index3 = paletteInfo.PaletteBuffer[index1]; 633 | int num1; 634 | var yn = paletteInfo.PaletteBuffer[num1 = index1 + 1]; 635 | int num2; 636 | var crn = paletteInfo.PaletteBuffer[num2 = num1 + 1]; 637 | int num3; 638 | var cbn = paletteInfo.PaletteBuffer[num3 = num2 + 1]; 639 | int num4; 640 | var alpha1 = paletteInfo.PaletteBuffer[num4 = num3 + 1]; 641 | var alpha2 = bluRaySupPalette.GetAlpha(index3); 642 | if (alpha1 >= alpha2) 643 | { 644 | if (alpha1 < 14) 645 | { 646 | yn = 16; 647 | crn = 128; 648 | cbn = 128; 649 | } 650 | 651 | bluRaySupPalette.SetAlpha(index3, alpha1); 652 | } 653 | else 654 | flag = true; 655 | 656 | bluRaySupPalette.SetYCbCr(index3, yn, cbn, crn); 657 | index1 = num4 + 1; 658 | } 659 | 660 | int num = flag ? 1 : 0; 661 | return bluRaySupPalette; 662 | } 663 | 664 | public class PcsData 665 | { 666 | public int CompNum { get; init; } 667 | 668 | public CompositionState CompositionState { get; init; } 669 | 670 | public bool PaletteUpdate { get; init; } 671 | 672 | public long StartTime { get; set; } 673 | 674 | public long EndTime { get; set; } 675 | 676 | public Size Size { get; init; } 677 | 678 | public int FramesPerSecondType { get; set; } 679 | 680 | public int PaletteId { get; init; } 681 | 682 | public List PcsObjects { get; set; } 683 | 684 | public string Message { get; set; } 685 | 686 | public List> BitmapObjects { get; set; } 687 | 688 | public List PaletteInfos { get; set; } 689 | 690 | public bool IsForced 691 | { 692 | get { return PcsObjects.Any((Func) (obj => obj.IsForced)); } 693 | } 694 | 695 | public Position GetPosition() 696 | { 697 | return PcsObjects.Count > 0 ? new Position(PcsObjects.Min((Func) (p => p.Origin.X)), this.PcsObjects.Min((Func) (p => p.Origin.Y))) : new Position(0, 0); 698 | } 699 | 700 | public TimeCode StartTimeCode => new TimeCode(StartTime / 90.0); 701 | 702 | public TimeCode EndTimeCode => new TimeCode(EndTime / 90.0); 703 | } 704 | 705 | private class PdsData 706 | { 707 | public string Message { get; set; } 708 | 709 | public int PaletteId { get; set; } 710 | 711 | public int PaletteVersion { get; set; } 712 | 713 | public PaletteInfo PaletteInfo { get; set; } 714 | } 715 | 716 | public class OdsData 717 | { 718 | public int ObjectId { get; set; } 719 | 720 | public int ObjectVersion { get; set; } 721 | 722 | public string Message { get; set; } 723 | 724 | public bool IsFirst { get; init; } 725 | 726 | public Size Size { get; init; } 727 | 728 | public ImageObjectFragment Fragment { get; init; } 729 | } 730 | 731 | public enum CompositionState 732 | { 733 | Normal, 734 | AcquPoint, 735 | EpochStart, 736 | EpochContinue, 737 | Invalid, 738 | } 739 | 740 | private class DeleteIndex 741 | { 742 | public int Number { get; init; } 743 | 744 | public int Index { get; init; } 745 | } 746 | 747 | public class PaletteInfo 748 | { 749 | public int PaletteSize { get; init; } 750 | public byte[] PaletteBuffer { get; set; } 751 | } 752 | } 753 | -------------------------------------------------------------------------------- /src/PgsToSrt/BluRaySup/ImageExtensions.cs: -------------------------------------------------------------------------------- 1 | using SixLabors.ImageSharp; 2 | using SixLabors.ImageSharp.PixelFormats; 3 | 4 | namespace PgsToSrt.BluRaySup; 5 | 6 | public static class ImageExtensions 7 | { 8 | 9 | private static int GetAlpha(Image image, int x, int y) 10 | { 11 | return image[x, y].A; 12 | } 13 | 14 | private static bool IsLineTransparent(this Image image,int y) 15 | { 16 | for (var x = 0; x < image.Width; ++x) 17 | { 18 | if (image[x, y].A != 0) 19 | return false; 20 | } 21 | 22 | return true; 23 | } 24 | 25 | private static bool IsVerticalLineTransparent(this Image image,int x) 26 | { 27 | for (var y = 0; y < image.Height; ++y) 28 | { 29 | if (GetAlpha(image, x, y) > 0) 30 | return false; 31 | } 32 | 33 | return true; 34 | } 35 | 36 | public static int GetNonTransparentHeight(this Image image) 37 | { 38 | var num1 = 0; 39 | var num2 = 0; 40 | for (var y = 0; y < image.Height; ++y) 41 | { 42 | var flag = image.IsLineTransparent(y); 43 | if (num1 == y & flag) 44 | ++num1; 45 | else if (flag) 46 | ++num2; 47 | else 48 | num2 = 0; 49 | } 50 | 51 | return image.Height - num1 - num2; 52 | } 53 | 54 | public static int GetNonTransparentWidth(this Image image) 55 | { 56 | var num1 = 0; 57 | var num2 = 0; 58 | for (var x = 0; x < image.Width; ++x) 59 | { 60 | var flag = image.IsVerticalLineTransparent(x); 61 | if (num1 == x & flag) 62 | ++num1; 63 | else if (flag) 64 | ++num2; 65 | else 66 | num2 = 0; 67 | } 68 | 69 | return image.Width - num1 - num2; 70 | } 71 | 72 | public static bool IsEqualTo(this Image image, Image image2) 73 | { 74 | if (image.Width != image2.Width || image.Height != image2.Height) 75 | return false; 76 | if (image.Width == image2.Width && image.Height == image2.Height && image.Width == 0 && image2.Height == 0) 77 | return true; 78 | 79 | image.DangerousTryGetSinglePixelMemory(out var pixelMemory0); 80 | var pixelSpan0 = pixelMemory0.Span; 81 | 82 | image2.DangerousTryGetSinglePixelMemory(out var pixelMemory1); 83 | var pixelSpan1 = pixelMemory1.Span; 84 | 85 | for (int index = 0; index < pixelSpan0.Length; ++index) 86 | { 87 | if (pixelSpan0[index] != pixelSpan1[index]) 88 | return false; 89 | } 90 | 91 | return true; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/PgsToSrt/CommandLineOptions.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | 3 | namespace PgsToSrt 4 | { 5 | internal class CommandLineOptions 6 | { 7 | [Option(Required = true, HelpText = "Input filename, it can be a .mkv or a .sup extracted with mkvextract.")] 8 | public string Input { get; set; } 9 | 10 | [Option(HelpText = "Track language.")] 11 | public string TrackLanguage { get; set; } 12 | 13 | [Option(HelpText = "Track number of the PGS subtitle to use, only needed when input is an .mkv file.")] 14 | public int? Track { get; set; } 15 | 16 | [Option(Required = false, HelpText = "Output .srt filename.")] 17 | public string Output { get; set; } 18 | 19 | [Option(HelpText = "Tesseract language to use if multiple languages are available in the tesseract data directory.")] 20 | public string TesseractLanguage { get; set; } 21 | 22 | [Option(HelpText = "Path of tesseract language data files, by default 'tessdata' in the executable directory.")] 23 | public string TesseractData { get; set; } 24 | 25 | [Option(HelpText = $"Tesseract version", Default = Runner.DefaultTesseractVersion)] 26 | public string TesseractVersion { get; set; } 27 | 28 | [Option(Required = false, HelpText = "Leptonica library name.", Default = "lept")] 29 | public string LibLeptName { get; set; } 30 | 31 | [Option(Required = false, HelpText = "Leptonica library version.", Default = "5")] 32 | public string LibLeptVersion { get; set; } 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PgsToSrt/MkvUtilities.cs: -------------------------------------------------------------------------------- 1 | using Nikse.SubtitleEdit.Core.ContainerFormats.Matroska; 2 | using PgsToSrt.Options; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | using System.Linq; 7 | 8 | namespace PgsToSrt 9 | { 10 | internal static class MkvUtilities 11 | { 12 | private const string _pgsTrackCodecId = "S_HDMV/PGS"; 13 | private static readonly string[] _mkvExtensions = { ".mkv", ".mks" }; 14 | 15 | internal static bool IsMkvFile(string filename) 16 | { 17 | return _mkvExtensions.Contains(Path.GetExtension(filename), StringComparer.OrdinalIgnoreCase); 18 | } 19 | 20 | internal static string GetDefaultOutputFilename(List trackOutputOptions, string filename, MatroskaTrackInfo track, string output) 21 | { 22 | string result = null; 23 | int? number = null; 24 | 25 | while (true) 26 | { 27 | var defaultOutputFilename = GetDefaultOutputFilename(filename, track, number, output); 28 | var existing = ( 29 | from t in trackOutputOptions 30 | where string.Equals(t.Output, defaultOutputFilename, StringComparison.OrdinalIgnoreCase) 31 | select t).Any(); 32 | 33 | if (!existing) 34 | { 35 | result = defaultOutputFilename; 36 | break; 37 | } 38 | else 39 | { 40 | if (!number.HasValue) 41 | number = 1; 42 | 43 | number += 1; 44 | } 45 | } 46 | 47 | return result; 48 | } 49 | 50 | internal static string GetDefaultOutputFilename(string filename, MatroskaTrackInfo track, int? number, string output) 51 | { 52 | var defaultOutputFilename = GetBaseDefaultOutputFilename(filename, output) + "." + track.Language + number + (track.IsForced ? ".forced" : "") + ".srt"; 53 | return defaultOutputFilename; 54 | } 55 | 56 | internal static string GetBaseDefaultOutputFilename(string filename, string output) 57 | { 58 | string outputDirectory; 59 | 60 | if (output != null && output.EndsWith(Path.DirectorySeparatorChar)) 61 | { 62 | outputDirectory = output; 63 | } 64 | else 65 | { 66 | outputDirectory = Path.GetDirectoryName(filename); 67 | } 68 | 69 | var defaultOutputFilename = Path.Combine( 70 | outputDirectory, 71 | Path.GetFileNameWithoutExtension(filename)); 72 | 73 | return defaultOutputFilename; 74 | } 75 | 76 | internal static List GetTracksByLanguage(string filename, string trackLanguage, string output) 77 | { 78 | var result = new List(); 79 | 80 | using (var matroska = new MatroskaFile(filename)) 81 | { 82 | if (matroska.IsValid) 83 | { 84 | var pgsTracks = GetPgsSubtitleTracks(matroska); 85 | var tracks = (from t in pgsTracks where string.Equals(trackLanguage, t.Language, StringComparison.OrdinalIgnoreCase) select t); 86 | 87 | foreach (var track in tracks) 88 | { 89 | var defaultOutputFilename = GetDefaultOutputFilename(result, filename, track, output); 90 | result.Add(new TrackOutputOption() { Track = track.TrackNumber, Output = defaultOutputFilename }); 91 | } 92 | } 93 | } 94 | 95 | return result; 96 | } 97 | 98 | public static List GetPgsSubtitleTracks(MatroskaFile matroska) 99 | { 100 | var result = new List(); 101 | 102 | if (matroska.IsValid) 103 | { 104 | var tracks = matroska.GetTracks(true); 105 | var pgsTrack = ( 106 | from t in tracks 107 | orderby t.TrackNumber 108 | where string.Equals(_pgsTrackCodecId, t.CodecId) 109 | select t); 110 | 111 | result.AddRange(pgsTrack); 112 | } 113 | 114 | return result; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/PgsToSrt/NLog.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/PgsToSrt/Options/TrackOption.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace PgsToSrt.Options 8 | { 9 | internal class TrackOption 10 | { 11 | public string Input { get; set; } 12 | public string Output { get; set; } 13 | public int? Track { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/PgsToSrt/Options/TrackOutputOption.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace PgsToSrt.Options 8 | { 9 | internal class TrackOutputOption 10 | { 11 | public int Track { get; set; } 12 | public string Output { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/PgsToSrt/PgsOcr.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Logging; 9 | using Nikse.SubtitleEdit.Core.Common; 10 | using Nikse.SubtitleEdit.Core.SubtitleFormats; 11 | using PgsToSrt.BluRaySup; 12 | using SixLabors.ImageSharp; 13 | using SixLabors.ImageSharp.PixelFormats; 14 | using TesseractOCR; 15 | using TesseractOCR.Enums; 16 | 17 | namespace PgsToSrt; 18 | 19 | public class PgsOcr 20 | { 21 | private readonly Microsoft.Extensions.Logging.ILogger _logger; 22 | private readonly Subtitle _subtitle = new (); 23 | private readonly string _tesseractVersion; 24 | private readonly string _libLeptName; 25 | private readonly string _libLeptVersion; 26 | private List _bluraySubtitles; 27 | 28 | public string TesseractDataPath { get; set; } 29 | public string TesseractLanguage { get; set; } = "eng"; 30 | 31 | public PgsOcr(Microsoft.Extensions.Logging.ILogger logger, string tesseractVersion, string libLeptName, string libLeptVersion) 32 | { 33 | _logger = logger; 34 | _tesseractVersion = tesseractVersion; 35 | _libLeptName = libLeptName; 36 | _libLeptVersion = libLeptVersion; 37 | } 38 | 39 | public bool ToSrt(List subtitles, string outputFileName) 40 | { 41 | _bluraySubtitles = subtitles; 42 | 43 | if (!DoOcrParallel()) 44 | return false; 45 | 46 | try 47 | { 48 | Save(outputFileName); 49 | _logger.LogInformation($"Saved '{outputFileName}' with {_subtitle.Paragraphs.Count} items."); 50 | return true; 51 | } 52 | catch (Exception ex) 53 | { 54 | _logger.LogError(ex, $"Saving '{outputFileName}' failed:"); 55 | return false; 56 | } 57 | } 58 | 59 | private void Save(string outputFileName) 60 | { 61 | using var file = new StreamWriter(outputFileName, false, new UTF8Encoding(false)); 62 | file.Write(_subtitle.ToText(new SubRip())); 63 | } 64 | 65 | private bool DoOcrParallel() 66 | { 67 | _logger.LogInformation($"Starting OCR for {_bluraySubtitles.Count} items..."); 68 | _logger.LogInformation($"Tesseract version {_tesseractVersion}"); 69 | 70 | var exception = TesseractApi.Initialize(_tesseractVersion, _libLeptName, _libLeptVersion); 71 | if (exception != null) 72 | { 73 | _logger.LogError(exception, $"Failed: {exception.Message}"); 74 | return false; 75 | } 76 | 77 | var ocrResults = new ConcurrentBag(); 78 | 79 | // Process subtitles in parallel 80 | Parallel.ForEach(Enumerable.Range(0, _bluraySubtitles.Count), i => 81 | { 82 | try 83 | { 84 | using var engine = new Engine(TesseractDataPath, TesseractLanguage); 85 | 86 | var item = _bluraySubtitles[i]; 87 | 88 | var paragraph = new Paragraph 89 | { 90 | Number = i + 1, 91 | StartTime = new TimeCode(item.StartTime / 90.0), 92 | EndTime = new TimeCode(item.EndTime / 90.0), 93 | Text = GetText(engine, i) 94 | }; 95 | 96 | ocrResults.Add(paragraph); 97 | 98 | if (i % 50 == 0) 99 | { 100 | _logger.LogInformation($"Processed item {paragraph.Number}."); 101 | } 102 | } 103 | catch (Exception ex) 104 | { 105 | _logger.LogError(ex, $"Error processing item {i}: {ex.Message}"); 106 | } 107 | }); 108 | 109 | // Sort the results and add them to the subtitle 110 | _subtitle.Paragraphs.AddRange(ocrResults.OrderBy(p => p.Number)); 111 | 112 | _logger.LogInformation("Finished OCR."); 113 | return true; 114 | } 115 | 116 | private string GetText(Engine engine, int index) 117 | { 118 | using var bitmap = GetSubtitleBitmap(index); 119 | using var image = GetPix(bitmap); 120 | using var page = engine.Process(image, PageSegMode.Auto); 121 | 122 | return page.Text?.Trim(); 123 | } 124 | 125 | private static TesseractOCR.Pix.Image GetPix(Image bitmap) 126 | { 127 | byte[] bytes; 128 | using (var stream = new MemoryStream()) 129 | { 130 | bitmap.SaveAsBmp(stream); 131 | bytes = stream.ToArray(); 132 | } 133 | return TesseractOCR.Pix.Image.LoadFromMemory(bytes); 134 | } 135 | 136 | private Image GetSubtitleBitmap(int index) 137 | { 138 | return _bluraySubtitles[index].GetRgba32(); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/PgsToSrt/PgsParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using Microsoft.Extensions.Logging; 7 | using Nikse.SubtitleEdit.Core.ContainerFormats.Matroska; 8 | using PgsToSrt.BluRaySup; 9 | 10 | namespace PgsToSrt 11 | { 12 | internal class PgsParser 13 | { 14 | private readonly ILogger _logger; 15 | 16 | public PgsParser(ILogger logger) 17 | { 18 | _logger = logger; 19 | } 20 | 21 | public List Load(string filename, int track) => 22 | Path.GetExtension(filename.ToLowerInvariant()) switch 23 | { 24 | ".sup" => LoadSubtitles(filename), 25 | _ when MkvUtilities.IsMkvFile(filename) => LoadMkv(filename, track), 26 | _ => throw new InvalidOperationException( 27 | $"Unsupported file type: {Path.GetExtension(filename.ToLowerInvariant())}") 28 | }; 29 | 30 | private List LoadMkv(string filename, int trackNumber) 31 | { 32 | using var matroska = new MatroskaFile(filename); 33 | if (!matroska.IsValid) 34 | { 35 | _logger.LogInformation($"Invalid Matroska file '{filename}'"); 36 | return null; 37 | } 38 | 39 | var pgsTracks = MkvUtilities.GetPgsSubtitleTracks(matroska); 40 | var track = pgsTracks.FirstOrDefault(t => t.TrackNumber == trackNumber); 41 | 42 | if (track != null) 43 | { 44 | return LoadSubtitles(matroska, track); 45 | } 46 | 47 | _logger.LogInformation($"Track {trackNumber} is not a PGS track."); 48 | LogPgsTracks(filename, pgsTracks); 49 | return null; 50 | } 51 | 52 | private void LogPgsTracks(string filename, List pgsTracks) 53 | { 54 | _logger.LogInformation($"-> {pgsTracks.Count} PGS tracks found in '{filename}'"); 55 | foreach (var track in pgsTracks) 56 | { 57 | _logger.LogInformation($"- {track.TrackNumber,-2} {track.Language,3} {track.Name}"); 58 | } 59 | } 60 | 61 | private static List LoadSubtitles(string supFileName) 62 | { 63 | var log = new StringBuilder(); 64 | return BluRaySupParserImageSharp.ParseBluRaySup(supFileName, log); 65 | } 66 | 67 | private static List LoadSubtitles(MatroskaFile matroska, MatroskaTrackInfo track) 68 | { 69 | return matroska.IsValid 70 | ? BluRaySupParserImageSharp.ParseBluRaySupFromMatroska(track, matroska) 71 | : null; 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/PgsToSrt/PgsToSrt.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | 0.0.0.0 7 | 0.0.0.0 8 | 0.0.0 9 | 10 | 11 | 12 | none 13 | false 14 | 15 | 16 | 17 | preview 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | PreserveNewest 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/PgsToSrt/Program.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using NLog.Extensions.Logging; 5 | using System; 6 | using System.Reflection; 7 | 8 | namespace PgsToSrt 9 | { 10 | class Program 11 | { 12 | 13 | static void Main(string[] args) 14 | { 15 | var options = Parser.Default.ParseArguments(args); 16 | 17 | if (options is Parsed values) 18 | { 19 | var version = Assembly.GetExecutingAssembly().GetName().Version.ToString(); 20 | Console.WriteLine($"PgsToSrt {version}"); 21 | Console.WriteLine(); 22 | 23 | var servicesProvider = new ServiceCollection() 24 | .AddLogging(builder => 25 | { 26 | builder.SetMinimumLevel(LogLevel.Debug); 27 | builder.AddNLog(); 28 | }) 29 | .AddTransient() 30 | .BuildServiceProvider(); 31 | 32 | var runner = servicesProvider.GetRequiredService(); 33 | 34 | runner.Run(values); 35 | Console.Write("Done."); 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/PgsToSrt/Runner.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using Microsoft.Extensions.Logging; 3 | using PgsToSrt.Options; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Runtime.InteropServices; 9 | 10 | namespace PgsToSrt 11 | { 12 | internal class Runner 13 | { 14 | public const string DefaultTesseractVersion = "4"; 15 | 16 | private readonly string[] _tesseractSupportedVersions = ["4", "5"]; 17 | private readonly ILogger _logger; 18 | 19 | private string _tesseractData; 20 | private string _tesseractLanguage; 21 | private string _tesseractVersion = DefaultTesseractVersion; 22 | private string _libLeptName; 23 | private string _libLeptVersion; 24 | 25 | public Runner(ILogger logger) 26 | { 27 | _logger = logger; 28 | } 29 | 30 | public void Run(Parsed values) 31 | { 32 | if (values != null) 33 | { 34 | var (argumentChecked, runnerOptions) = GetTrackOptions(values); 35 | 36 | if (argumentChecked) 37 | { 38 | foreach (var runnerOption in runnerOptions) 39 | { 40 | ConvertPgs(runnerOption.Input, runnerOption.Track, runnerOption.Output); 41 | } 42 | } 43 | } 44 | } 45 | 46 | private (bool result, List trackOptions) GetTrackOptions(Parsed values) 47 | { 48 | var result = true; 49 | var trackOptions = new List(); 50 | var input = values.Value.Input; 51 | var output = values.Value.Output; 52 | var trackLanguage = values.Value.TrackLanguage; 53 | var track = values.Value.Track; 54 | 55 | // Windows uses tesseract50.dll installed by nuget package, so always use v5 56 | // Other systems can uses different libtesseract versions, keep v4 as default. 57 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 58 | { 59 | if (values.Value.TesseractVersion != null) 60 | { 61 | _tesseractVersion = values.Value.TesseractVersion; 62 | 63 | if (!_tesseractSupportedVersions.Contains(_tesseractVersion)) 64 | { 65 | _logger.LogError($"Unsupported Tesseract version '{_tesseractVersion}' (Supported versions: 4, 5)"); 66 | result = false; 67 | } 68 | } 69 | 70 | _libLeptName = values.Value.LibLeptName; 71 | _libLeptVersion = values.Value.LibLeptVersion; 72 | } 73 | else 74 | { 75 | _tesseractVersion = "5"; 76 | } 77 | 78 | _tesseractData = !string.IsNullOrEmpty(values.Value.TesseractData) 79 | ? values.Value.TesseractData 80 | : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tessdata"); 81 | 82 | if (!File.Exists(values.Value.Input)) 83 | { 84 | _logger.LogError($"Input file '{input}' doesn't exist."); 85 | result = false; 86 | } 87 | 88 | if (MkvUtilities.IsMkvFile(input)) 89 | { 90 | if (string.IsNullOrEmpty(trackLanguage) && !track.HasValue) 91 | { 92 | _logger.LogError("Track must be set when input is an mkv/s file."); 93 | result = false; 94 | } 95 | else if (!string.IsNullOrEmpty(trackLanguage)) 96 | { 97 | var runnerOptionLanguages = MkvUtilities.GetTracksByLanguage(input, trackLanguage, output); 98 | trackOptions.AddRange(runnerOptionLanguages.Select(item => new TrackOption() { Input = input, Output = item.Output, Track = item.Track })); 99 | } 100 | else 101 | { 102 | trackOptions.Add(new TrackOption() {Input = input, Output = output, Track = track}); 103 | } 104 | } 105 | else 106 | { 107 | var outputFilename = !string.IsNullOrEmpty(output) ? output : MkvUtilities.GetBaseDefaultOutputFilename(input, output) + ".srt"; 108 | 109 | trackOptions.Add(new TrackOption() {Input = input, Output = outputFilename, Track = null}); 110 | } 111 | 112 | if (Directory.Exists(_tesseractData)) 113 | { 114 | var tesseractData = new TesseractData(_logger); 115 | _tesseractLanguage = tesseractData.GetTesseractLanguage(_tesseractData, values.Value.TesseractLanguage); 116 | 117 | if (string.IsNullOrEmpty(_tesseractLanguage)) 118 | { 119 | result = false; 120 | } 121 | } 122 | else 123 | { 124 | _logger.LogError($"Tesseract data directory '{_tesseractData}' doesn't exist."); 125 | result = false; 126 | } 127 | 128 | return (result, trackOptions); 129 | } 130 | 131 | private bool ConvertPgs(string input, int? track, string output) 132 | { 133 | var pgsParser = new PgsParser(_logger); 134 | var subtitles = pgsParser.Load(input, track.GetValueOrDefault()); 135 | 136 | if (subtitles is null) 137 | return false; 138 | 139 | var pgsOcr = new PgsOcr(_logger, _tesseractVersion, _libLeptName, _libLeptVersion) 140 | { 141 | TesseractDataPath = _tesseractData, 142 | TesseractLanguage = _tesseractLanguage 143 | }; 144 | 145 | pgsOcr.ToSrt(subtitles, output); 146 | 147 | return true; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/PgsToSrt/TesseractApi.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Reflection.Emit; 6 | using System.Runtime.InteropServices; 7 | using TesseractOCR.Interop; 8 | using Tncl.NativeLoader; 9 | 10 | namespace PgsToSrt 11 | { 12 | static class TesseractApi 13 | { 14 | private const string _assemblyName = "Assembly.Tesseract"; 15 | 16 | public static Exception Initialize(string tesseractVersion, string libLeptName, string libLeptVersion) 17 | { 18 | Exception exception = null; 19 | 20 | if (string.IsNullOrEmpty(libLeptName)) 21 | libLeptName = "lept"; 22 | 23 | if (string.IsNullOrEmpty(libLeptVersion)) 24 | libLeptVersion = "5"; 25 | 26 | var tessApiType = typeof(TesseractOCR.Page).Assembly.GetType("TesseractOCR.Interop.TessApi"); 27 | var leptApiType = typeof(TesseractOCR.Page).Assembly.GetType("TesseractOCR.Interop.LeptonicaApi"); 28 | 29 | var tessApiCustomType = CreateInterfaceType("tesseract53", "tesseract", tesseractVersion); 30 | var leptApiCustomType = CreateInterfaceType("leptonica-1.83.1", libLeptName, libLeptVersion); 31 | 32 | var loader = new NativeLoader(); 33 | loader.WindowsOptions.UseSetDllDirectory = true; 34 | 35 | try 36 | { 37 | var tessApiInstance = (ITessApiSignatures)loader.CreateInstance(tessApiCustomType); 38 | var leptApiInstance = (ILeptonicaApiSignatures)loader.CreateInstance(leptApiCustomType); 39 | 40 | tessApiType.GetField("native", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, tessApiInstance); 41 | leptApiType.GetField("native", BindingFlags.NonPublic | BindingFlags.Static).SetValue(null, leptApiInstance); 42 | } 43 | catch (TargetInvocationException e) 44 | { 45 | exception = e.InnerException; 46 | } 47 | catch (Exception e) 48 | { 49 | exception = e; 50 | } 51 | 52 | return exception; 53 | } 54 | 55 | public static Type CreateInterfaceType(string windowsLibraryName, string commonLibraryName, string version) 56 | { 57 | var interfaceType = typeof(T); 58 | var typeName = $"{_assemblyName}.{interfaceType.Name}2"; 59 | var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(_assemblyName), AssemblyBuilderAccess.Run); 60 | var moduleBuilder = assemblyBuilder.DefineDynamicModule(_assemblyName); 61 | var typeBuilder = moduleBuilder.DefineType(typeName, TypeAttributes.Interface | TypeAttributes.Abstract | TypeAttributes.Public, null, new Type[1] { interfaceType }); 62 | var tesseractMethods = GetTesseractMethods(interfaceType); 63 | 64 | foreach (var tesseractMethod in tesseractMethods) 65 | { 66 | var methodInfo = tesseractMethod.Key; 67 | var parametersType = from p in methodInfo.GetParameters() select p.ParameterType; 68 | 69 | var methodBuilder = typeBuilder.DefineMethod( 70 | methodInfo.Name, methodInfo.Attributes, 71 | methodInfo.CallingConvention, methodInfo.ReturnType, parametersType.ToArray()); 72 | 73 | var nativeLoaderOverrideAttributeProperties = new Dictionary(); 74 | nativeLoaderOverrideAttributeProperties.Add("LibraryName", windowsLibraryName); 75 | nativeLoaderOverrideAttributeProperties.Add("Platform", Platform.Windows); 76 | methodBuilder.AddCustomAttribute(nativeLoaderOverrideAttributeProperties); 77 | 78 | var runtimeUnmanagedFunctionPointerAttributeProperties = new Dictionary(); 79 | runtimeUnmanagedFunctionPointerAttributeProperties.Add("LibraryName", commonLibraryName); 80 | runtimeUnmanagedFunctionPointerAttributeProperties.Add("LibraryVersion", version); 81 | runtimeUnmanagedFunctionPointerAttributeProperties.Add("CallingConvention", CallingConvention.Cdecl); 82 | runtimeUnmanagedFunctionPointerAttributeProperties.Add("EntryPoint", tesseractMethod.Value); 83 | methodBuilder.AddCustomAttribute(runtimeUnmanagedFunctionPointerAttributeProperties); 84 | } 85 | 86 | return typeBuilder.CreateType(); 87 | } 88 | 89 | public static List> GetTesseractMethods(Type type) 90 | { 91 | var result = new List>(); 92 | var runtimeDllImportAttributeType = typeof(TesseractOCR.Page).Assembly.GetType("TesseractOCR.InteropDotNet.RuntimeDllImportAttribute"); 93 | 94 | foreach (var methodInfo in type.GetMethods()) 95 | { 96 | var runtimeDllImportAttribute = methodInfo.GetCustomAttribute(runtimeDllImportAttributeType); 97 | 98 | if (runtimeDllImportAttribute != null) 99 | { 100 | var entryPoint = (string)runtimeDllImportAttribute.GetType().GetField("EntryPoint").GetValue(runtimeDllImportAttribute); 101 | result.Add(new KeyValuePair(methodInfo, entryPoint)); 102 | } 103 | } 104 | 105 | return result; 106 | } 107 | 108 | public static void AddCustomAttribute(this MethodBuilder methodBuilder, Dictionary propertiesNameValue) 109 | { 110 | var attributeType = typeof(T); 111 | var attributeConstructorInfo = attributeType.GetConstructor(new Type[0] { }); 112 | var (propertyInfos, propertyValues) = SplitPropertiesNameValue(attributeType, propertiesNameValue); 113 | var attributeBuilder = new CustomAttributeBuilder(attributeConstructorInfo, new object[0] { }, propertyInfos, propertyValues); 114 | 115 | methodBuilder.SetCustomAttribute(attributeBuilder); 116 | } 117 | 118 | public static (PropertyInfo[] propertyInfos, object[] propertyValues) SplitPropertiesNameValue(Type type, Dictionary propertiesNameValue) 119 | { 120 | var propertyInfos = new List(); 121 | var propertyValues = new List(); 122 | 123 | foreach (var item in propertiesNameValue) 124 | { 125 | var propertyName = item.Key; 126 | var propertyValue = item.Value; 127 | 128 | var property = type.GetProperty(propertyName); 129 | propertyInfos.Add(property); 130 | propertyValues.Add(propertyValue); 131 | } 132 | 133 | return (propertyInfos.ToArray(), propertyValues.ToArray()); 134 | } 135 | 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/PgsToSrt/TesseractData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace PgsToSrt 8 | { 9 | internal class TesseractData 10 | { 11 | private readonly ILogger _logger; 12 | 13 | public TesseractData(ILogger logger) 14 | { 15 | _logger = logger; 16 | } 17 | 18 | public string GetTesseractLanguage(string tesseractData, string wantedLanguage) 19 | { 20 | string result = null; 21 | var languages = GetAvailableLanguages(tesseractData); 22 | 23 | if (wantedLanguage != null && !languages.Contains(wantedLanguage.ToLowerInvariant())) 24 | { 25 | _logger.LogError($"Language '{wantedLanguage}' is not available in Tesseract data directory."); 26 | _logger.LogInformation("Available languages:"); 27 | foreach (var language in languages) 28 | { 29 | _logger.LogInformation($"- {language}"); 30 | } 31 | } 32 | else if (wantedLanguage != null) 33 | { 34 | result = wantedLanguage; 35 | } 36 | else if (languages.Any()) 37 | { 38 | result = GetDefaultTesseractLanguage(languages); 39 | } 40 | else 41 | { 42 | _logger.LogError("No tesseract language data files found."); 43 | } 44 | 45 | return result; 46 | } 47 | 48 | public List GetAvailableLanguages(string dataPath) 49 | { 50 | var files = Directory.GetFiles(dataPath, "*.traineddata"); 51 | var result = new List(); 52 | 53 | foreach (var trainedDataFile in files) 54 | { 55 | var language = Path.GetFileNameWithoutExtension(trainedDataFile).ToLowerInvariant(); 56 | result.Add(language); 57 | _logger.LogInformation($"Detected tesseract language data for language '{language}'."); 58 | } 59 | 60 | return result; 61 | } 62 | 63 | public static string GetDefaultTesseractLanguage(List languages) 64 | { 65 | return string.Join("+", languages); ; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | args=() 4 | 5 | if [[ -n "${INPUT}" ]]; then 6 | args+=('--input' "${INPUT}") 7 | fi 8 | if [[ -n "${OUTPUT}" ]]; then 9 | args+=('--output' "${OUTPUT}") 10 | fi 11 | if [[ -n "${TRACK}" ]]; then 12 | args+=('--track' "${TRACK}") 13 | fi 14 | if [[ -n "${TRACK_LANGUAGE}" ]]; then 15 | args+=('--tracklanguage' "${TRACK_LANGUAGE}") 16 | fi 17 | if [[ -n "${LANGUAGE}" ]]; then 18 | args+=('--tesseractlanguage' "${LANGUAGE}") 19 | fi 20 | if [[ -n "${TESSDATA}" ]]; then 21 | args+=('--tesseractdata' "${TESSDATA}") 22 | else 23 | args+=('--tesseractdata' '/tessdata') 24 | fi 25 | 26 | args+=('--tesseractversion' '5') 27 | 28 | echo "dotnet /app/PgsToSrt.dll ${args[*]}" 29 | dotnet /app/PgsToSrt.dll "${args[@]}" 30 | -------------------------------------------------------------------------------- /src/publish.sh: -------------------------------------------------------------------------------- 1 | VERSION="1.4.6" 2 | 3 | sed -i "s/0.0.0.0/$VERSION.0/" ./PgsToSrt/PgsToSrt.csproj 4 | sed -i "s/0.0.0/$VERSION/" ./PgsToSrt/PgsToSrt.csproj 5 | 6 | dotnet publish -f net8.0 --no-self-contained -o out/publish 7 | 8 | cd out/publish 9 | zip -r ../PgsToStr-$VERSION.zip ./* 10 | cd .. 11 | --------------------------------------------------------------------------------