├── .gitignore ├── CbzMage.sln ├── LICENSE ├── README.md └── Source ├── AzwConverter ├── AzwConvertSettings.cs ├── AzwConvertSettings.json ├── AzwConverter.csproj ├── CbzItem.cs ├── Converter │ ├── AzwFileOrDirectoryConverter.cs │ ├── AzwLibraryConverter.cs │ └── BaseAzwConverter.cs ├── Engine │ ├── AbstractImageEngine.cs │ ├── AnalyzeEngine.cs │ ├── ConvertBookEngine.cs │ ├── ConvertFileEngine.cs │ ├── HDContainerHelper.cs │ ├── MetadataEngine.cs │ ├── SaveBookCoverEngine.cs │ ├── SaveFileCoverEngine.cs │ ├── ScanBookEngine.cs │ └── ScanFileEngine.cs ├── Extensions.cs ├── LibraryManager.cs ├── MetadataManager.cs └── Settings.cs ├── CbzMage.Shared ├── AppVersions │ ├── App.cs │ ├── AppVersion.cs │ ├── AppVersionManager.cs │ ├── GhostscriptVersion.cs │ └── SevenZipVersion.cs ├── BandcampCollector.Shared.csproj ├── Buffers │ └── ArrayPoolBufferWriter.cs ├── CbzMage.Shared.csproj ├── CbzMageAction.cs ├── CollectionManager │ ├── Collection.cs │ ├── CollectionDb.cs │ ├── CollectionItem.cs │ ├── ItemReader.cs │ └── ItemSyncer.cs ├── Extensions │ ├── DirectoryExtensions.cs │ ├── DisposableExtensions.cs │ ├── EnumerableExtensions.cs │ ├── ExceptionExtensions.cs │ ├── FileSystemExtensions.cs │ ├── NumberExtensions.cs │ ├── PageStringExtensions.cs │ ├── StringExtensions.cs │ └── TimeSpanExtensions.cs ├── Helpers │ ├── ProcessRunner.cs │ └── ProgressReporter.cs ├── IO │ └── AsyncStreams.cs ├── JobQueue │ ├── AbstractJobQueue.cs │ ├── IJobConsumer.cs │ ├── IJobProducer.cs │ ├── JobEventArgs.cs │ ├── JobExecutor.cs │ ├── JobProducerConsumer.cs │ └── JobWaiter.cs ├── Settings │ └── SharedSettings.cs └── StringCasing │ └── CaseValidation.cs ├── CbzMage ├── CbzMage.csproj ├── DefaultSettings.json └── Program.cs ├── EpubConverter ├── ConverterEngine.cs ├── Epub.cs ├── EpubConvertSettings.cs ├── EpubConverter.csproj ├── EpubFileOrDirectoryConverter.cs ├── EpubParser.cs ├── OpfReader.cs └── Settings.cs ├── PdfConverter ├── ConverterEngine.cs ├── DpiCalculatedEventArgs.cs ├── DpiCalculator.cs ├── Exceptions │ ├── PdfEncryptedException.cs │ └── SomethingWentWrongSorryException.cs ├── Ghostscript │ ├── GhostscriptImageStreamReader.cs │ ├── GhostscriptPageMachine.cs │ └── SingleImageDataHandler.cs ├── Helpers │ └── StatsCount.cs ├── IImageDataHandler.cs ├── ImageExt.cs ├── ImageProducer │ ├── AbstractImageProducer.cs │ ├── GhostScriptImageProducer.cs │ └── ITextImageProducer.cs ├── Jobs │ ├── ImageCompressorJob.cs │ └── ImageConverterJob.cs ├── PageChunker.cs ├── PageCompressor.cs ├── PageConvertedEventArgs.cs ├── PageConverter.cs ├── PageParsedEventArgs.cs ├── PagesCompressedEventArgs.cs ├── Pdf.cs ├── PdfConvertSettings.cs ├── PdfConvertSettings.json ├── PdfConverter.csproj ├── PdfFileOrDirectoryConverter.cs ├── PdfImageParser.cs └── Settings.cs ├── Publish.bat └── ReadmeExternalProjects.txt /.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 | /Source/CbzMage/Properties/PublishProfiles 264 | /Source/CbzMage/Properties/launchSettings.json 265 | 266 | /Source/AzwConverter/AzwConvertSettings.Development.json 267 | /Source/BlackSteedConverter/BlackSteedConvertSettings.Development.json 268 | /Source/PdfConverter/PdfConvertSettings.Development.json 269 | 270 | -------------------------------------------------------------------------------- /CbzMage.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.3.32922.545 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CbzMage", "Source\CbzMage\CbzMage.csproj", "{9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PdfConverter", "Source\PdfConverter\PdfConverter.csproj", "{8FCD5823-D2C7-4E36-8432-FDFB07FC3886}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CbzMage.Shared", "Source\CbzMage.Shared\CbzMage.Shared.csproj", "{391C0C20-EB3F-452C-A683-353243BAE7EB}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzwConverter", "Source\AzwConverter\AzwConverter.csproj", "{A03C4A66-534E-4C23-94E9-5EFC38A220E2}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CbzMageExternal", "CbzMageExternal", "{926715BF-4197-4640-A99E-80F88BBBFA99}" 15 | ProjectSection(SolutionItems) = preProject 16 | Source\Publish.bat = Source\Publish.bat 17 | Source\ReadmeExternalProjects.txt = Source\ReadmeExternalProjects.txt 18 | EndProjectSection 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MobiMetadata", "..\CbzMageExternal\MobiMetadata\Source\MobiMetadata\MobiMetadata.csproj", "{E849A028-6AE9-478D-B405-B3038F5D1777}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EpubConverter", "Source\EpubConverter\EpubConverter.csproj", "{84095547-4052-4AB1-AB98-3946FDCA1DAC}" 23 | EndProject 24 | Global 25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 26 | Debug|Any CPU = Debug|Any CPU 27 | Debug|x64 = Debug|x64 28 | Debug|x86 = Debug|x86 29 | Release|Any CPU = Release|Any CPU 30 | Release|x64 = Release|x64 31 | Release|x86 = Release|x86 32 | EndGlobalSection 33 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 34 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Debug|x64.ActiveCfg = Debug|Any CPU 37 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Debug|x64.Build.0 = Debug|Any CPU 38 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Debug|x86.ActiveCfg = Debug|Any CPU 39 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Debug|x86.Build.0 = Debug|Any CPU 40 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Release|x64.ActiveCfg = Release|Any CPU 43 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Release|x64.Build.0 = Release|Any CPU 44 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Release|x86.ActiveCfg = Release|Any CPU 45 | {9F90CA65-B5FF-43FB-83F4-0214CFEA6C56}.Release|x86.Build.0 = Release|Any CPU 46 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Debug|x64.ActiveCfg = Debug|Any CPU 49 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Debug|x64.Build.0 = Debug|Any CPU 50 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Debug|x86.ActiveCfg = Debug|Any CPU 51 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Debug|x86.Build.0 = Debug|Any CPU 52 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Release|x64.ActiveCfg = Release|Any CPU 55 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Release|x64.Build.0 = Release|Any CPU 56 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Release|x86.ActiveCfg = Release|Any CPU 57 | {8FCD5823-D2C7-4E36-8432-FDFB07FC3886}.Release|x86.Build.0 = Release|Any CPU 58 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Debug|x64.ActiveCfg = Debug|Any CPU 61 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Debug|x64.Build.0 = Debug|Any CPU 62 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Debug|x86.ActiveCfg = Debug|Any CPU 63 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Debug|x86.Build.0 = Debug|Any CPU 64 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Release|Any CPU.Build.0 = Release|Any CPU 66 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Release|x64.ActiveCfg = Release|Any CPU 67 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Release|x64.Build.0 = Release|Any CPU 68 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Release|x86.ActiveCfg = Release|Any CPU 69 | {391C0C20-EB3F-452C-A683-353243BAE7EB}.Release|x86.Build.0 = Release|Any CPU 70 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 71 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Debug|Any CPU.Build.0 = Debug|Any CPU 72 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Debug|x64.ActiveCfg = Debug|Any CPU 73 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Debug|x64.Build.0 = Debug|Any CPU 74 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Debug|x86.ActiveCfg = Debug|Any CPU 75 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Debug|x86.Build.0 = Debug|Any CPU 76 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Release|Any CPU.ActiveCfg = Release|Any CPU 77 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Release|Any CPU.Build.0 = Release|Any CPU 78 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Release|x64.ActiveCfg = Release|Any CPU 79 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Release|x64.Build.0 = Release|Any CPU 80 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Release|x86.ActiveCfg = Release|Any CPU 81 | {A03C4A66-534E-4C23-94E9-5EFC38A220E2}.Release|x86.Build.0 = Release|Any CPU 82 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 83 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Debug|Any CPU.Build.0 = Debug|Any CPU 84 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Debug|x64.ActiveCfg = Debug|Any CPU 85 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Debug|x64.Build.0 = Debug|Any CPU 86 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Debug|x86.ActiveCfg = Debug|Any CPU 87 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Debug|x86.Build.0 = Debug|Any CPU 88 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Release|Any CPU.ActiveCfg = Release|Any CPU 89 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Release|Any CPU.Build.0 = Release|Any CPU 90 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Release|x64.ActiveCfg = Release|Any CPU 91 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Release|x64.Build.0 = Release|Any CPU 92 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Release|x86.ActiveCfg = Release|Any CPU 93 | {E849A028-6AE9-478D-B405-B3038F5D1777}.Release|x86.Build.0 = Release|Any CPU 94 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 95 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Debug|Any CPU.Build.0 = Debug|Any CPU 96 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Debug|x64.ActiveCfg = Debug|Any CPU 97 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Debug|x64.Build.0 = Debug|Any CPU 98 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Debug|x86.ActiveCfg = Debug|Any CPU 99 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Debug|x86.Build.0 = Debug|Any CPU 100 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Release|Any CPU.ActiveCfg = Release|Any CPU 101 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Release|Any CPU.Build.0 = Release|Any CPU 102 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Release|x64.ActiveCfg = Release|Any CPU 103 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Release|x64.Build.0 = Release|Any CPU 104 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Release|x86.ActiveCfg = Release|Any CPU 105 | {84095547-4052-4AB1-AB98-3946FDCA1DAC}.Release|x86.Build.0 = Release|Any CPU 106 | EndGlobalSection 107 | GlobalSection(SolutionProperties) = preSolution 108 | HideSolutionNode = FALSE 109 | EndGlobalSection 110 | GlobalSection(ExtensibilityGlobals) = postSolution 111 | SolutionGuid = {BB0A3794-000A-44B2-A6EE-29561EF6FE00} 112 | EndGlobalSection 113 | EndGlobal 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ToofDerling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CbzMage. 2 | 3 | **Azw file conversion doesn't work with the latest Kdl. You have to use a version earlier than 2.2.0 (read more [here](https://github.com/ToofDerling/CbzMage/discussions/35)).** 4 | 5 | CzbMage is a comic book converter. It aims to do exactly two things: 6 | 1. **Convert azw files to nice cbz files**, ready to read in your favorite cbz reader. Azw files entirely like the ones found in Kdl for PC or Mac. 7 | Additionally, if CbzMage finds a matching azw.res file it will **merge in any HD images found for the highest possible quality**. 8 | Comic title and publisher will be read from the azw file, and running CbzMage in scan mode will allow you to edit the values before the conversion. You can also point CbzMage at a file or directory and it will process any azw or azw3 files it finds directly. 9 | 2. **Convert pdf files to nice cbz files**. Point CbzMage at a single pdf comic book or a directory of pdf comic books and it will convert them to cbz files in the highest possible quality. 10 | 11 | **All of this works fully in [CbzMage Version 1.0](https://github.com/ToofDerling/CbzMage/releases/tag/v1.0).** 12 | 13 | CbzMage is a commandline tool written in c#. It requires no installation, very little configuration and no drm plugin/tool (the last part may change in the future). It does require that **[.NET 6](https://dotnet.microsoft.com/en-us/download)** is installed on your computer. The pdf conversion requires that **[Ghostscript version 10+](https://ghostscript.com/releases/gsdnld.html)** is installed on your computer. 14 | 15 | CbzMage is released for Windows, Linux, and macOS (but support for the macOS version will be limited as I don't own a Mac). Since the Kdl app only works on PC and Mac the azw conversion is probably not relevant for Linux users, but the pdf conversion works as advertised (but see the note about Ghostscript 10 on Linux below). 16 | 17 | Download CbzMage to your hard drive and unpack it anywhere. Have a look at the settings in AzwConvertSettings.json or PdfConvertSettings.json, they are all thoroughly documented there (I hope). Open a command shell and run CbzMage right away, or check out some more information: 18 | 19 | ## Azw conversion. 20 | 21 | Open the AzwConvertSettings.json file in a text editor and **configure AzwDir**. Please note the comment about [moving the azw directory](https://github.com/ToofDerling/CbzMage/wiki/Azw:-How-to-move-Kdl-content-folder) as **running CbzMage for the first time may double the size of your azw directory.** 22 | 23 | **Important:** Close Kdl before running CbzMage. Kdl locks some of the azw files when it's running, so there's a high chance that CbzMage will crash because it can't read a locked azw file. 24 | 25 | **Running CbzMage with the "AzwConvert" parameter.** 26 | 27 | * In the titles directory (TitlesDir in AzwConvertSettings.json) you will find a small file with the title and publisher of each comic book currently in the azw directory. 28 | * In a subdirectory of the titles directory you will find a similar file for each converted title. If you ever want **to reconvert a title simply delete the title file from the converted titles directory.** 29 | * In the cbz directory (CbzDir in AzwConvertSettings.json) you will find the converted comic books sorted by publisher. 30 | * If you set SaveCover to true in AzwConvertSettings.json CbzMage will save a copy of the cover image together with the cbz file. If you specify SaveCoverDir the cover image will be saved there instead. There's even a SaveCoverOnly option if you just want the covers. 31 | 32 | **Running CbzMage with the "AzwScan" parameter.** 33 | 34 | * Like the conversion, in the titles directory you will find a small file with the title and publisher of each comic book currently in the azw directory. 35 | * Unlike the conversion, each new title will have a ".NEW" marker (and each upgraded title will have an ".UPDATED" marker). 36 | * You can now edit the publishers and titles as you like and the values will be used when you run AzwConvert. You don't have to remove the markers, the conversion will handle it automatically. 37 | 38 | **Azw conversion notes.** 39 | 40 | * **Updated title.** This means a title that has been upgraded, ie it now has a HD cover or more HD images than before. CbzMage will scan the azw files and add the ".UPDATED" marker to any upgraded title. It will also detect if a title has been downgraded, this is of course not supposed to happen. Upgrades happen seldom and I have never seen a title being downgraded. 41 | * **The titles directory** will always reflect the comic books currently in Kdl and is updated each time you run CbzMage. If you edit publisher or name of a title the values will be used when the title is converted 42 | * **The converted titles directory** contains your converted titles. To reconvert a title you must delete the file in that directory. 43 | * **The database.** In the titles directory there's a database file with the state of every title that has passed through CbzMage. It's used when checking if a title has been updated and to store the new name and publisher of the title if you edit these values. 44 | 45 | **Using CbzMage to convert or scan files directly.** 46 | 47 | * **Specify a file or directory after the AzwConvert or AzwScan command** and CbzMage will process any azw or azw3 files it finds directly. All the TitleDir infrastructure is ignored. 48 | * Add two asterisks after the directory to search subdirectories for files. On Windows: [directory]\\** On Linux (and macOs, I think) it must be included in quotes: "[directory]/\**" 49 | * You can still use CbzDir to tell CbzMage where to create the cbz files, else they will be created in the directory with the azw/azw3 files. 50 | * SaveCover, SaveCoverDir, and SaveCoverOnly also works. 51 | 52 | ## Pdf conversion. 53 | 54 | It's been mentioned before, but let me say it again: pdf conversion requires that that **[Ghostscript version 10+](https://ghostscript.com/releases/gsdnld.html)** is installed on your computer. Once you have that part working there's no need to configure anything, simply try 55 | 56 | **Running CbzMage with the "PdfConvert" parameter.** 57 | 58 | * Open a command shell and point CbzMage at a single pdf file or a directory with pdf files and it will happily create a cbz file alongside each pdf (unless you have configured CbzDir in PdfConvertSettings.json, then they will created in that directory). That's all there is to it, really. 59 | 60 | **Pdf conversion notes.** 61 | 62 | * **Ghostscript 10 on Linux.** The only distro I know of that has upgraded to Ghostscript version 10 is [Arch Linux](https://archlinux.org/). I tried a handful of the popular ones and they were all at version 9, which doesn't work with CbzMage. 63 | * **Search subdirectories for pdf files**. To do this add two asterisks after the directory. On Windows: [directory]\\** On Linux (and macOs, I think) it must be included in quotes: "[directory]/\**" 64 | * **Cbz filesize.** Cbz files created by PdfConvert will typically be 50 - 100 % larger than the original pdf file. Now and then they're smaller and sometimes much larger - though CbzMage tries to handle the most extreme cases without sacrificing any of the conversion quality (see the MinimumHeight and MaximumHeight settings in PdfConvertSettings.json). 65 | * **SaveCover and CbzDir** both works the same as for AzwConvert. And the same goes for the rest of the settings that are shared between the two conversion modes. 66 | 67 | ## 68 | 69 | **Notes.** 70 | 71 | * **The "GUI" mode.** (Windows only). This simply means that if you run CbzMage by doubleclicking the exe it will detect that and make the window hang around until you press enter. You can also create a shortcut for each of the modes (open Properties for the shortcut and add the parameter in Target) and have one file to doubleclick for azw conversion and one for converting pdfs in a specific directory. 72 | 73 | **Credits.** 74 | 75 | The [azw parser](https://github.com/ToofDerling/MobiMetadata) is mostly copied from the [Mobi Metadata Reader](https://www.mobileread.com/forums/showthread.php?t=185565) by Limey. I cleaned it up, fixed the FullName parsing, and added support for retrieving SD and HD images, the rest is Limey's work. This [Stack Overflow post](https://stackoverflow.com/questions/24233834/getting-cover-image-from-a-mobi-file) was very helpful when figuring out how to extract cover images. Azw6 header structure was gleaned from [UnpackKindleS](https://github.com/Aeroblast/UnpackKindleS) 76 | 77 | **That's it.** 78 | 79 | If you have any questions or want to request a feature use [Discussions](https://github.com/ToofDerling/CbzMage/discussions). If you want to report a bug use [Issues](https://github.com/ToofDerling/CbzMage/issues). Happy converting. 80 | -------------------------------------------------------------------------------- /Source/AzwConverter/AzwConvertSettings.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using CbzMage.Shared.Helpers; 3 | using CbzMage.Shared.Settings; 4 | 5 | namespace AzwConverter 6 | { 7 | public class AzwConvertSettings 8 | { 9 | public static Settings Settings => new(); 10 | 11 | private readonly SharedSettings _settingsHelper = new(); 12 | 13 | public void CreateSettings() 14 | { 15 | _settingsHelper.CreateSettings(nameof(AzwConvertSettings), Settings); 16 | 17 | ConfigureSettings(); 18 | } 19 | 20 | // Defaults 21 | private const string _defaultTitlesDir = "Titles"; 22 | private const string _defaultCbzDir = "Cbz Backups"; 23 | 24 | private const string _defaultConvertedTitlesDirName = "Converted Titles"; 25 | private const string _defaultNewTitleMarker = ".NEW"; 26 | private const string _defaultUpdateTitleMarker = ".UPDATED"; 27 | 28 | private void ConfigureSettings() 29 | { 30 | //AzwDir 31 | if (string.IsNullOrWhiteSpace(Settings.AzwDir)) 32 | { 33 | ProgressReporter.Warning($"{nameof(Settings.AzwDir)} is not configured in AzwConvertSettings.json"); 34 | } 35 | else if (!Directory.Exists(Settings.AzwDir)) 36 | { 37 | throw new Exception($"{nameof(Settings.AzwDir)} [{Settings.AzwDir}] does not exist"); 38 | } 39 | 40 | //TitlesDir 41 | if (string.IsNullOrWhiteSpace(Settings.TitlesDir)) 42 | { 43 | var dir = new DirectoryInfo(Settings.AzwDir).Parent; 44 | Settings.TitlesDir = Path.Combine(dir.FullName, _defaultTitlesDir); 45 | } 46 | Settings.TitlesDir.CreateDirIfNotExists(); 47 | 48 | //SaveCover/SaveCoverOnly 49 | Settings.SaveCoverOnly = Settings.SaveCoverOnly && Settings.SaveCover; 50 | 51 | //SaveCoverDir 52 | if (Settings.SaveCover && !string.IsNullOrWhiteSpace(Settings.SaveCoverDir)) 53 | { 54 | Settings.SaveCoverDir.CreateDirIfNotExists(); 55 | } 56 | else 57 | { 58 | Settings.SaveCoverDir = null; 59 | } 60 | 61 | //ConvertedTitlesDirName 62 | if (string.IsNullOrWhiteSpace(Settings.ConvertedTitlesDirName)) 63 | { 64 | Settings.ConvertedTitlesDirName = _defaultConvertedTitlesDirName; 65 | } 66 | Settings.SetConvertedTitlesDir(Path.Combine(Settings.TitlesDir, Settings.ConvertedTitlesDirName)); 67 | Settings.ConvertedTitlesDir.CreateDirIfNotExists(); 68 | 69 | //CbzDir 70 | if (string.IsNullOrWhiteSpace(Settings.CbzDir)) 71 | { 72 | var dir = new DirectoryInfo(Settings.AzwDir).Parent; 73 | Settings.CbzDir = Path.Combine(dir.FullName, _defaultCbzDir); 74 | Settings.CbzDir.CreateDirIfNotExists(); 75 | 76 | Settings.SetCbzDirSetBySystem(); 77 | } 78 | 79 | //AnalysisDir 80 | if (!string.IsNullOrWhiteSpace(Settings.AnalysisDir)) 81 | { 82 | Settings.AnalysisDir.CreateDirIfNotExists(); 83 | } 84 | 85 | //NewTitleMarker/UpdatedTitleMarker 86 | Settings.NewTitleMarker = string.IsNullOrWhiteSpace(Settings.NewTitleMarker) 87 | ? _defaultNewTitleMarker 88 | : Settings.NewTitleMarker; 89 | 90 | Settings.UpdatedTitleMarker = string.IsNullOrWhiteSpace(Settings.UpdatedTitleMarker) 91 | ? _defaultUpdateTitleMarker 92 | : Settings.UpdatedTitleMarker; 93 | 94 | Settings.SetAllMarkers(); 95 | 96 | //TrimPublishers 97 | Settings.TrimPublishers ??= Array.Empty(); 98 | 99 | //NumberOfThreads 100 | Settings.NumberOfThreads = _settingsHelper.GetThreadCount(Settings.NumberOfThreads); 101 | Settings.SetParallelOptions(new ParallelOptions { MaxDegreeOfParallelism = Settings.NumberOfThreads }); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Source/AzwConverter/AzwConvertSettings.json: -------------------------------------------------------------------------------- 1 | // Punction is important! Don't remove quotes, colons, commas or square brackets 2 | // (or otherwise change the structure). Back slashes in directory paths must be 3 | // escaped like this: "C:\\System\\Apps\\CbzMage\\Titles" 4 | { 5 | // 6 | // Settings recognized by the AzwConvert and AzwScan commands. 7 | // 8 | 9 | // AzwDir is a required setting if you use CbzMage to convert your Kdl library. 10 | // It must point to where your azw files are stored - aka the Kdl data directory. 11 | // If you have a large library it's recommended to move this directory to a hard 12 | // disk with plenty of space (see the CbzMage Wiki for how to move the directory). 13 | "AzwDir": "", 14 | 15 | // TitlesDir is where the azw title files are stored. Titles currently in Kdl, 16 | // titles converted to cbz, and the archive database all live here. This can be 17 | // placed anywhere you want (and takes up very little space). If you specify 18 | // nothing it will default to "Titles" in the parent directory of AzwDir. 19 | "TitlesDir": "", 20 | 21 | // CbzDir is where your cbz backups are created. This can be placed anywhere 22 | // you want. If you specify nothing the default for AzwConvert is to use "Cbz 23 | // Backups" in the parent directory of AzwDir. 24 | "CbzDir": "", 25 | 26 | // SaveCover true or false. Save a copy of the coverimage alongside the cbz file 27 | // when converting a title. The default value is false, don't save the cover. 28 | "SaveCover": false, 29 | 30 | // If you would like to save the coverimages in their own directory instead of 31 | // alongside the cbz file you can specify the directory here. 32 | "SaveCoverDir": "", 33 | 34 | // SaveCoverOnly true or false. Use this if you just want to save coverimages, no 35 | // cbz files. To generate a cover gallery of all the books in Kdl simply delete 36 | // all files in the Converted Titles directory (they will be regenerated). Note: 37 | // SaveCover must also be true for SaveCoverOnly to work. 38 | "SaveCoverOnly": false, 39 | 40 | // Normally CbzMage scans and converts all azw files in your Kdl library. If you 41 | // set ConvertAllBookTypes to false it will only convert azw files with the 42 | // "comic" booktype, which may be useful if you also have regular books in your 43 | // library. But beware that some non-Cmxlgy graphic novels have a different 44 | // booktype than "comic", so be careful when changing this. 45 | "ConvertAllBookTypes": true, 46 | 47 | // The converted titles directory is a subdirectory of TitlesDir. The default 48 | // name is "Converted Titles", you can change it to something else below. Don't 49 | // forget to rename the actual directory if you do. 50 | "ConvertedTitlesDirName": "", 51 | 52 | // Change the name of the ".NEW" and ".UPDATED" markers here if you like. It's 53 | // a good idea to keep "." in front of the name to keep the new and updated 54 | // titles sorted in front of the other titles. 55 | "NewTitleMarker": "", 56 | "UpdatedTitleMarker": "", 57 | 58 | // Speed up conversion by doing it in parallel. Set this to 0 to have CbzMage try 59 | // to figure out a good value. If memory usage is too high try setting this to a 60 | // lower value than the calculated. 61 | "NumberOfThreads": 0, 62 | 63 | // Compression level for the cbz file. Valid options are "Fastest", "Optimal" or 64 | // "NoCompression". The default is "Fastest" because jpg files in the cbz file are 65 | // already compressed so "Optimal" gives very little difference in archive size. 66 | "CompressionLevel": "Fastest", 67 | 68 | // The names of publishers in this list will be normalized. For example if a 69 | // publisher name starts with "Marvel" (like "Marvel", "Marvel Comics" or "Marvel 70 | // Entertainment Inc.") it will be normalized to just "Marvel". 71 | "TrimPublishers": [ 72 | "Aftershock", 73 | "Archaia", 74 | "Avatar", 75 | "Avery Hill", 76 | "Berger", 77 | "Boom", 78 | "Caliber", 79 | "Cinebook", 80 | "Creator Owned", 81 | "Dark Horse", 82 | "DC", 83 | "Dead Canary", 84 | "Dover", 85 | "Drawn & Quarterly", 86 | "Dynamite", 87 | "Europe", 88 | "Fanbase", 89 | "Fantagraphics", 90 | "Humanoids", 91 | "IDW", 92 | "Image", 93 | "Kodansha", 94 | "Legendary", 95 | "Magnetic", 96 | "Markosia", 97 | "Marvel", 98 | "MAX", 99 | "NBM", 100 | "Soaring Penguin", 101 | "Source Point", 102 | "Strawberry", 103 | "Top Shelf", 104 | "Vault", 105 | "Vertical", 106 | "Viz", 107 | "Wildstorm", 108 | "Yen", 109 | "Z2" 110 | ] 111 | } 112 | -------------------------------------------------------------------------------- /Source/AzwConverter/AzwConverter.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Always 21 | 22 | 23 | Always 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Source/AzwConverter/CbzItem.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.CollectionManager; 2 | 3 | namespace AzwConverter 4 | { 5 | public sealed class CbzItem : CollectionItem 6 | { 7 | public bool HdCover { get; set; } 8 | public bool SdCover { get; set; } 9 | 10 | public int HdImages { get; set; } 11 | public int SdImages { get; set; } 12 | 13 | public int Pages { get; set; } 14 | 15 | public DateTime? Checked { get; set; } 16 | 17 | public CbzItem? Changed { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Source/AzwConverter/Converter/AzwFileOrDirectoryConverter.cs: -------------------------------------------------------------------------------- 1 | using AzwConverter.Engine; 2 | using CbzMage.Shared; 3 | using CbzMage.Shared.Extensions; 4 | using CbzMage.Shared.Helpers; 5 | using CbzMage.Shared.IO; 6 | using CbzMage.Shared.Settings; 7 | using MobiMetadata; 8 | using System.Collections.Concurrent; 9 | 10 | namespace AzwConverter.Converter 11 | { 12 | public class AzwFileOrDirectoryConverter : BaseAzwConverter 13 | { 14 | private string _fileOrDirectory; 15 | 16 | public AzwFileOrDirectoryConverter(CbzMageAction action, string fileOrDirectory) 17 | : base(action) 18 | { 19 | _fileOrDirectory = fileOrDirectory; 20 | 21 | if (!string.IsNullOrEmpty(Settings.CbzDir) && !Settings.CbzDirSetBySystem) 22 | { 23 | ProgressReporter.Info($"Cbz backups: {Settings.CbzDir}"); 24 | ProgressReporter.Line(); 25 | } 26 | ProgressReporter.Info($"Conversion threads: {Settings.NumberOfThreads}"); 27 | ProgressReporter.Info($"Cbz compression: {Settings.CompressionLevel}"); 28 | } 29 | 30 | public async Task ConvertOrScanAsync() 31 | { 32 | if (string.IsNullOrEmpty(_fileOrDirectory)) 33 | { 34 | throw new ArgumentNullException(nameof(_fileOrDirectory)); 35 | } 36 | 37 | // Must run before before the checks for file/dir existance 38 | _fileOrDirectory = SharedSettings.GetDirectorySearchOption(_fileOrDirectory, out var searchOption); 39 | 40 | var azwFiles = new List(); 41 | var allFiles = new List(); 42 | 43 | if (File.Exists(_fileOrDirectory)) 44 | { 45 | var fileInfo = new FileInfo(_fileOrDirectory); 46 | 47 | if (!fileInfo.IsAzwOrAzw3File()) 48 | { 49 | ProgressReporter.Error($"Not an azw or azw3 file [{_fileOrDirectory}]"); 50 | return; 51 | } 52 | 53 | azwFiles.Add(fileInfo); 54 | 55 | var directory = fileInfo.Directory; 56 | if (directory == null) 57 | { 58 | ProgressReporter.Error($"Error retrieving directory of [{_fileOrDirectory}]"); 59 | return; 60 | } 61 | 62 | allFiles.AddRange(directory.GetFiles()); 63 | } 64 | else if (Directory.Exists(_fileOrDirectory)) 65 | { 66 | var directoryInfo = new DirectoryInfo(_fileOrDirectory); 67 | allFiles.AddRange(directoryInfo.GetFiles("*", searchOption)); 68 | 69 | azwFiles = allFiles.Where(file => file.IsAzwOrAzw3File()).ToList(); 70 | if (azwFiles.Count == 0) 71 | { 72 | ProgressReporter.Error($"No azw or azw3 files found in [{_fileOrDirectory}]"); 73 | return; 74 | } 75 | } 76 | else 77 | { 78 | ProgressReporter.Error($"File or directory does not exist [{_fileOrDirectory}]"); 79 | return; 80 | } 81 | 82 | var hdContainerFiles = allFiles.Where(file => file.IsAzwResOrAzw6File()).ToList(); 83 | await DoConvertAzwFilesAndHDContainersAsync(azwFiles, hdContainerFiles); 84 | } 85 | 86 | private async Task DoConvertAzwFilesAndHDContainersAsync(List azwFiles, List hdContainerFiles) 87 | { 88 | ProgressReporter.Line(); 89 | 90 | var actionString = Action == CbzMageAction.AzwConvert ? "Converting" : "Listing"; 91 | ProgressReporter.Info($"{actionString} {azwFiles.Count} azw/azw3 file{azwFiles.SIfNot1()}"); 92 | 93 | if (hdContainerFiles.Count > 0) 94 | { 95 | ProgressReporter.Info($"Found {hdContainerFiles.Count} azw.res/azw6 file{hdContainerFiles.SIfNot1()} with HD images"); 96 | } 97 | 98 | _totalBooks = azwFiles.Count; 99 | 100 | ConversionBegin(); 101 | 102 | var azwDict = azwFiles.ToDictionary(azw => azw.FullName, azw => new List()); 103 | var hdContainers = await AnalyzeHdContainersAsync(hdContainerFiles); 104 | 105 | // Group hdcontainers by directory 106 | var hdContainerLookup = hdContainers.ToLookup(hd => hd.Path!.Directory!.FullName); 107 | 108 | // Match each azwfile with all hdcontainers in the same directory. 109 | foreach (var azw in azwDict) 110 | { 111 | var azwDir = Path.GetDirectoryName(azw.Key); 112 | if (hdContainerLookup.Contains(azwDir!)) 113 | { 114 | azw.Value.AddRange(hdContainerLookup[azwDir!]); 115 | } 116 | } 117 | 118 | await ConvertAzwFilesAndHdContainersAsync(azwDict); 119 | 120 | ConversionEnd(azwDict.Count); 121 | } 122 | 123 | private async Task ConvertAzwFilesAndHdContainersAsync(IDictionary> azwDict) 124 | { 125 | await Parallel.ForEachAsync(azwDict, Settings.ParallelOptions, 126 | async (azw, _) => 127 | { 128 | CbzItem? state = null; 129 | 130 | var azwFile = new FileInfo(azw.Key); 131 | var hdContainers = azw.Value; 132 | 133 | switch (Action) 134 | { 135 | case CbzMageAction.AzwConvert: 136 | { 137 | if (Settings.SaveCoverOnly) 138 | { 139 | var saveConverEngine = new SaveFileCoverEngine(); 140 | await saveConverEngine.SaveFileCoverAsync(azwFile, hdContainers); 141 | 142 | PrintCoverString(saveConverEngine.GetCoverFile(), saveConverEngine.GetCoverString()); 143 | } 144 | else 145 | { 146 | var convertEngine = new ConvertFileEngine(); 147 | state = await convertEngine.ConvertFileAsync(azwFile, hdContainers); 148 | 149 | PrintCbzState(state!.Name, state); 150 | } 151 | break; 152 | } 153 | case CbzMageAction.AzwScan: 154 | { 155 | var scanEngine = new ScanFileEngine(); 156 | state = await scanEngine.ScanFileAsync(azwFile, hdContainers); 157 | 158 | PrintCbzState(state!.Name, state); 159 | break; 160 | } 161 | } 162 | }); 163 | } 164 | 165 | private static async Task> AnalyzeHdContainersAsync(List hdContainerFiles) 166 | { 167 | if (hdContainerFiles.Count == 0) 168 | { 169 | return new List(); 170 | } 171 | 172 | var hdContainerMap = new ConcurrentDictionary(); 173 | 174 | await Parallel.ForEachAsync(hdContainerFiles, Settings.ParallelOptions, 175 | async (hdContainerFile, _) => 176 | { 177 | using var hdStream = AsyncStreams.AsyncFileReadStream(hdContainerFile.FullName); 178 | 179 | var pdbHeader = new PDBHead(skipProperties: false, skipRecords: false); 180 | await pdbHeader.ReadHeaderAsync(hdStream).ConfigureAwait(false); 181 | 182 | var azw6Header = new Azw6Head(skipExthHeader: false); 183 | await azw6Header.ReadHeaderAsync(hdStream).ConfigureAwait(false); 184 | 185 | azw6Header.Path = hdContainerFile; 186 | hdContainerMap[hdContainerFile.FullName] = azw6Header; 187 | }); 188 | 189 | return hdContainerMap.Values.ToList(); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Source/AzwConverter/Converter/BaseAzwConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Text; 3 | using CbzMage.Shared; 4 | using CbzMage.Shared.Extensions; 5 | using CbzMage.Shared.Helpers; 6 | 7 | namespace AzwConverter.Converter 8 | { 9 | public class BaseAzwConverter 10 | { 11 | protected int _totalBooks; 12 | 13 | // Global veriables updated by processing threads 14 | protected volatile int _bookCount; 15 | protected volatile int _pagesCount; 16 | 17 | protected CbzMageAction Action { get; private set; } 18 | 19 | private readonly Stopwatch _stopWatch = new(); 20 | 21 | public BaseAzwConverter(CbzMageAction action) 22 | { 23 | Action = action; 24 | 25 | var config = new AzwConvertSettings(); 26 | config.CreateSettings(); 27 | } 28 | 29 | protected void ConversionBegin() 30 | { 31 | _stopWatch.Start(); 32 | } 33 | 34 | protected void ConversionEnd(int unconvertedCount) 35 | { 36 | _stopWatch.Stop(); 37 | ProgressReporter.Line(); 38 | 39 | if (Action == CbzMageAction.AzwConvert && _pagesCount > 0) 40 | { 41 | var elapsed = _stopWatch.Elapsed; 42 | var secsPerPage = elapsed.TotalSeconds / _pagesCount; 43 | 44 | if (Settings.SaveCoverOnly) 45 | { 46 | ProgressReporter.Info($"{_bookCount} covers saved in {elapsed.Hhmmss()}"); 47 | } 48 | else if (unconvertedCount > 0) 49 | { 50 | ProgressReporter.Info($"{_pagesCount} pages converted in {elapsed.Hhmmss()} ({secsPerPage:F2} sec/page)"); 51 | } 52 | else 53 | { 54 | ProgressReporter.Info("Done"); 55 | } 56 | } 57 | else 58 | { 59 | ProgressReporter.Info("Done"); 60 | } 61 | } 62 | 63 | protected string BookCountOutputHelper(string path, out StringBuilder sb) 64 | { 65 | sb = new StringBuilder(); 66 | sb.AppendLine(); 67 | 68 | var count = Interlocked.Increment(ref _bookCount); 69 | var str = $"{count}/{_totalBooks} - "; 70 | 71 | sb.Append(str).Append(Path.GetFileName(path)); 72 | 73 | var insert = " ".PadLeft(str.Length); 74 | return insert; 75 | } 76 | 77 | protected void PrintCbzState(string cbzFile, CbzItem state, bool showPagesAndCover = true, bool showAllCovers = false, DateTime? convertedDate = null, 78 | string? doneMsg = null, string? errorMsg = null) 79 | { 80 | Interlocked.Add(ref _pagesCount, state.Pages); 81 | 82 | var insert = BookCountOutputHelper(cbzFile, out var sb); 83 | sb.AppendLine(); 84 | 85 | if (convertedDate.HasValue) 86 | { 87 | sb.Append(insert).Append("Converted: ").Append(convertedDate.Value.Date.ToShortDateString()); 88 | sb.AppendLine(); 89 | } 90 | 91 | sb.Append(insert); 92 | sb.Append(state.Pages).Append(" pages"); 93 | 94 | if (showPagesAndCover) 95 | { 96 | sb.Append(" ("); 97 | 98 | if (state.HdImages > 0) 99 | { 100 | sb.Append(state.HdImages).Append(" HD"); 101 | if (state.SdImages > 0) 102 | { 103 | sb.Append('/'); 104 | } 105 | } 106 | if (state.SdImages > 0) 107 | { 108 | sb.Append(state.SdImages).Append(" SD"); 109 | } 110 | 111 | sb.Append(". "); 112 | if (showAllCovers) 113 | { 114 | if (state.HdCover) 115 | { 116 | sb.Append("HD"); 117 | if (state.SdCover) 118 | { 119 | sb.Append('/'); 120 | } 121 | } 122 | if (state.SdCover) 123 | { 124 | sb.Append("SD"); 125 | } 126 | if (!state.HdCover && !state.SdCover) 127 | { 128 | sb.Append("No"); 129 | } 130 | sb.Append(" cover"); 131 | } 132 | else 133 | { 134 | if (state.HdCover) 135 | { 136 | sb.Append("HD cover"); 137 | } 138 | else if (state.HdCover) 139 | { 140 | sb.Append("SD cover)"); 141 | } 142 | else 143 | { 144 | sb.Append("No cover"); 145 | } 146 | } 147 | sb.Append(')'); 148 | } 149 | 150 | PrintMsg(sb, insert, doneMsg, errorMsg); 151 | } 152 | 153 | private static void PrintMsg(StringBuilder sb, string insert, string? doneMsg, string? errorMsg) 154 | { 155 | if (doneMsg != null || errorMsg != null) 156 | { 157 | lock (_msgLock) 158 | { 159 | ProgressReporter.Info(sb.ToString()); 160 | 161 | if (doneMsg != null) 162 | { 163 | ProgressReporter.Done($"{insert}{doneMsg}"); 164 | } 165 | if (errorMsg != null) 166 | { 167 | ProgressReporter.Error($"{insert}{errorMsg}"); 168 | } 169 | } 170 | } 171 | else 172 | { 173 | ProgressReporter.Info(sb.ToString()); 174 | } 175 | } 176 | 177 | private static readonly object _msgLock = new(); 178 | 179 | protected void PrintCoverString(string coverFile, string coverString) 180 | { 181 | var insert = BookCountOutputHelper(coverFile, out var sb); 182 | 183 | sb.AppendLine(); 184 | sb.Append(insert).Append(coverString); 185 | 186 | ProgressReporter.Info(sb.ToString()); 187 | } 188 | } 189 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/AbstractImageEngine.cs: -------------------------------------------------------------------------------- 1 | using MobiMetadata; 2 | using CbzMage.Shared.Helpers; 3 | using System.IO.MemoryMappedFiles; 4 | 5 | namespace AzwConverter.Engine 6 | { 7 | public abstract class AbstractImageEngine 8 | { 9 | protected MobiMetadata.MobiMetadata Metadata { get; set; } 10 | 11 | protected bool IgnoreHDContainerWarning { get; set; } 12 | 13 | protected async Task<(MobiMetadata.MobiMetadata, IDisposable[])> ReadMetadataAsync(FileInfo[] dataFiles) 14 | { 15 | var azwFile = dataFiles.First(file => file.IsAzwOrAzw3File()); 16 | 17 | MemoryMappedFile? mappedFile = null; 18 | try 19 | { 20 | mappedFile = MemoryMappedFile.CreateFromFile(azwFile.FullName); 21 | } 22 | catch (IOException) 23 | { 24 | ProgressReporter.Warning($"Error reading [{azwFile.FullName}] is Kdl running?"); 25 | throw; 26 | } 27 | 28 | var stream = mappedFile.CreateViewStream(); 29 | 30 | var disposables = new IDisposable[] { stream, mappedFile }; 31 | var metadata = MetadataManager.GetConfiguredMetadata(); 32 | try 33 | { 34 | await metadata.ReadMetadataAsync(stream); 35 | } 36 | catch (MobiMetadataException ex) 37 | { 38 | ProgressReporter.Error($"Error reading metadate from [{azwFile}]", ex); 39 | 40 | MetadataManager.DisposeDisposables(disposables); 41 | throw; 42 | } 43 | 44 | return (metadata, disposables); 45 | } 46 | 47 | protected async Task ReadImageDataAsync(string bookId, params FileInfo[] dataFiles) 48 | { 49 | var metadata = MetadataManager.GetCachedMetadata(bookId); 50 | 51 | IDisposable[]? disposables = null; 52 | try 53 | { 54 | if (metadata == null) 55 | { 56 | (metadata, disposables) = await ReadMetadataAsync(dataFiles); 57 | } 58 | Metadata = metadata; 59 | 60 | var hdContainer = SelectHDContainer(dataFiles); 61 | if (hdContainer != null) 62 | { 63 | using var hdMappedFile = MemoryMappedFile.CreateFromFile(hdContainer.FullName); 64 | using var hdStream = hdMappedFile.CreateViewStream(); 65 | 66 | await metadata.SetImageRecordsAsync(hdStream); 67 | 68 | return await ProcessImagesAsync(); 69 | } 70 | else 71 | { 72 | await metadata.SetImageRecordsAsync(null); 73 | 74 | if (!IgnoreHDContainerWarning) 75 | { 76 | ProgressReporter.Warning($"{Environment.NewLine}[{bookId}] / [{metadata.MobiHeader.GetFullTitle()}]: no HD image container"); 77 | } 78 | 79 | return await ProcessImagesAsync(); 80 | } 81 | } 82 | finally 83 | { 84 | if (disposables != null) 85 | { 86 | MetadataManager.DisposeDisposables(disposables); 87 | } 88 | else 89 | { 90 | MetadataManager.DisposeCachedMetadata(bookId); 91 | } 92 | } 93 | } 94 | 95 | protected virtual FileInfo? SelectHDContainer(FileInfo[] dataFiles) 96 | { 97 | return dataFiles.FirstOrDefault(file => file.IsAzwResOrAzw6File()); 98 | } 99 | 100 | protected abstract Task ProcessImagesAsync(); 101 | } 102 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/ConvertBookEngine.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using CbzMage.Shared.IO; 3 | using CbzMage.Shared.Settings; 4 | using MobiMetadata; 5 | using System.IO.Compression; 6 | using System.IO.MemoryMappedFiles; 7 | 8 | namespace AzwConverter.Engine 9 | { 10 | public class ConvertBookEngine : AbstractImageEngine 11 | { 12 | protected string? _cbzFile; 13 | protected string? _coverFile; 14 | 15 | protected long _mappedArchiveLen; 16 | 17 | public async Task ConvertBookAsync(string bookId, FileInfo[] dataFiles, string cbzFile, string? coverFile) 18 | { 19 | _cbzFile = cbzFile; 20 | _coverFile = coverFile; 21 | 22 | var azwFile = dataFiles.First(file => file.IsAzwOrAzw3File()); 23 | _mappedArchiveLen = azwFile.Length; 24 | 25 | var hdContainer = dataFiles.FirstOrDefault(file => file.IsAzwResOrAzw6File()); 26 | if (hdContainer != null) 27 | { 28 | _mappedArchiveLen += hdContainer.Length; 29 | } 30 | 31 | return await ReadImageDataAsync(bookId, dataFiles); 32 | } 33 | 34 | protected override async Task ProcessImagesAsync() => await CreateCbzAsync(); 35 | 36 | protected async Task CreateCbzAsync() 37 | { 38 | var tempFile = $"{_cbzFile}.temp"; 39 | 40 | var state = await ReadAndCompressAsync(tempFile); 41 | 42 | File.Move(tempFile, _cbzFile!, overwrite: true); 43 | 44 | return state; 45 | } 46 | 47 | private async Task ReadAndCompressAsync(string tempFile) 48 | { 49 | CbzItem state; 50 | long realArchiveLen; 51 | 52 | using (var mappedFileStream = AsyncStreams.AsyncFileWriteStream(tempFile)) 53 | { 54 | using (var mappedArchive = MemoryMappedFile.CreateFromFile(mappedFileStream, null, _mappedArchiveLen, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, 55 | leaveOpen: true)) 56 | { 57 | using (var archiveStream = mappedArchive.CreateViewStream()) 58 | { 59 | using (var zipArchive = new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true)) 60 | { 61 | state = await ReadAndCompressPagesAsync(zipArchive); 62 | } 63 | 64 | realArchiveLen = archiveStream.Position; 65 | } 66 | } 67 | 68 | if (mappedFileStream.Length != realArchiveLen) 69 | { 70 | mappedFileStream.SetLength(realArchiveLen); 71 | } 72 | } 73 | 74 | return state; 75 | } 76 | 77 | private async Task ReadAndCompressPagesAsync(ZipArchive zipArchive) 78 | { 79 | var state = new CbzItem(); 80 | const string coverName = "cover.jpg"; 81 | 82 | // Cover 83 | if (Metadata.MergedCoverRecord != null) 84 | { 85 | state.HdCover = Metadata.IsHdCover(); 86 | state.SdCover = !state.HdCover; 87 | 88 | await WriteRecordAsync(zipArchive, coverName, Metadata.MergedCoverRecord, isRealCover: true, isFakeCover: false); 89 | } 90 | 91 | // Pages 92 | for (int pageIndex = 0, sz = Metadata.MergedImageRecords.Count; pageIndex < sz; pageIndex++) 93 | { 94 | state.Pages++; 95 | 96 | var pageName = state.Pages.ToPageString(); 97 | 98 | var pageRecord = Metadata.MergedImageRecords[pageIndex]; 99 | 100 | if (Metadata.IsHdPage(pageIndex)) 101 | { 102 | state.HdImages++; 103 | } 104 | else 105 | { 106 | state.SdImages++; 107 | } 108 | 109 | var isFakeCover = pageIndex == 0 && !state.HdCover && !state.SdCover; 110 | 111 | await WriteRecordAsync(zipArchive, pageName, pageRecord, isRealCover: false, isFakeCover: isFakeCover); 112 | } 113 | 114 | return state; 115 | } 116 | 117 | private async Task WriteRecordAsync(ZipArchive zipArchive, string pageName, PageRecord record, bool isRealCover, bool isFakeCover) 118 | { 119 | // Write a cover file? 120 | Stream? coverStream = (isRealCover || isFakeCover) && _coverFile != null 121 | ? AsyncStreams.AsyncFileWriteStream(_coverFile) 122 | : null; 123 | 124 | var entry = zipArchive.CreateEntry(pageName, Settings.CompressionLevel); 125 | using var stream = entry.Open(); 126 | 127 | await record.WriteDataAsync(stream, coverStream!); 128 | coverStream?.Dispose(); 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/ConvertFileEngine.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using MobiMetadata; 3 | 4 | namespace AzwConverter.Engine 5 | { 6 | public class ConvertFileEngine : ConvertBookEngine 7 | { 8 | private List? _hdHeaderList; 9 | 10 | private FileInfo? _azwFile; 11 | 12 | public async Task ConvertFileAsync(FileInfo azwFile, List hdHeaderList) 13 | { 14 | _azwFile = azwFile; 15 | _mappedArchiveLen = azwFile.Length; 16 | 17 | _hdHeaderList = hdHeaderList; 18 | 19 | var state = await ReadImageDataAsync(azwFile.Name, azwFile); 20 | state.Name = _cbzFile!; 21 | 22 | return state; 23 | } 24 | 25 | protected override FileInfo? SelectHDContainer(FileInfo[] dataFiles) 26 | { 27 | IgnoreHDContainerWarning = true; 28 | 29 | var hdContainer = HDContainerHelper.FindHDContainer(Metadata!, _hdHeaderList); 30 | 31 | if (hdContainer != null) 32 | { 33 | _mappedArchiveLen += hdContainer.Length; 34 | } 35 | 36 | return hdContainer; 37 | } 38 | 39 | protected override async Task ProcessImagesAsync() 40 | { 41 | _cbzFile = GetCbzFile(_azwFile!.FullName, Metadata!.MobiHeader.GetFullTitle()); 42 | _coverFile = GetCoverFile(_cbzFile); 43 | 44 | return await CreateCbzAsync(); 45 | } 46 | 47 | private static string GetCbzFile(string azwFile, string title) 48 | { 49 | title = $"{title.ToFileSystemString()}.cbz"; 50 | 51 | if (!string.IsNullOrEmpty(Settings.CbzDir) && !Settings.CbzDirSetBySystem) 52 | { 53 | return Path.Combine(Settings.CbzDir, title); 54 | } 55 | 56 | var dir = Path.GetDirectoryName(azwFile); 57 | return Path.Combine(dir!, title); 58 | } 59 | 60 | private static string? GetCoverFile(string cbzFile) 61 | { 62 | if (!Settings.SaveCover) 63 | { 64 | return null; 65 | } 66 | 67 | var cover = Path.ChangeExtension(cbzFile, ".jpg"); 68 | 69 | if (!string.IsNullOrEmpty(Settings.SaveCoverDir)) 70 | { 71 | return Path.Combine(Settings.SaveCoverDir, Path.GetFileName(cover)); 72 | } 73 | 74 | return cover; 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/HDContainerHelper.cs: -------------------------------------------------------------------------------- 1 | using MobiMetadata; 2 | using System.Text; 3 | 4 | namespace AzwConverter.Engine 5 | { 6 | public class HDContainerHelper 7 | { 8 | public static FileInfo? FindHDContainer(MobiMetadata.MobiMetadata metadata, List? hdHeaderList) 9 | { 10 | var title = metadata!.MobiHeader.FullName; 11 | 12 | var list = hdHeaderList!.Where(header => header.Title == title).ToList(); 13 | 14 | if (list.Count > 1) 15 | { 16 | var errorMsg = $"Found {list.Count} HD containers with the same title [{title}]:{Environment.NewLine}"; 17 | 18 | var sb = new StringBuilder(errorMsg); 19 | foreach (var header in list) 20 | { 21 | sb.AppendLine(header.Path!.FullName); 22 | } 23 | 24 | throw new ArgumentException(sb.ToString()); 25 | } 26 | 27 | return list.Count == 1 ? list[0].Path : null; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/MetadataEngine.cs: -------------------------------------------------------------------------------- 1 | namespace AzwConverter.Engine 2 | { 3 | public class MetadataEngine : AbstractImageEngine 4 | { 5 | public async Task<(MobiMetadata.MobiMetadata metadata, IDisposable[] disposables)> GetMetadataAsync(FileInfo[] dataFiles) => await ReadMetadataAsync(dataFiles); 6 | 7 | protected override Task ProcessImagesAsync() => Task.FromResult(new CbzItem()); 8 | } 9 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/SaveBookCoverEngine.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.IO; 2 | 3 | namespace AzwConverter.Engine 4 | { 5 | public class SaveBookCoverEngine : AbstractImageEngine 6 | { 7 | protected string? _coverFile; 8 | private string? _coverString; 9 | 10 | public async Task SaveBookCoverAsync(string bookId, FileInfo[] dataFiles, string coverFile) 11 | { 12 | _coverFile = coverFile; 13 | 14 | return await ReadImageDataAsync(bookId, dataFiles); 15 | } 16 | 17 | public string? GetCoverString() 18 | { 19 | return _coverString; 20 | } 21 | 22 | protected override async Task ProcessImagesAsync() 23 | { 24 | await SaveCoverAsync(); 25 | return new CbzItem(); 26 | } 27 | 28 | private async Task SaveCoverAsync() 29 | { 30 | using var stream = AsyncStreams.AsyncFileWriteStream(_coverFile!); 31 | 32 | if (Metadata.MergedCoverRecord != null) 33 | { 34 | await Metadata.MergedCoverRecord.WriteDataAsync(stream); 35 | 36 | _coverString = Metadata.IsHdCover() ? "HD cover" : "SD cover"; 37 | } 38 | else 39 | { 40 | await Metadata.MergedImageRecords[0].WriteDataAsync(stream); 41 | 42 | _coverString = Metadata.IsHdPage(0) ? "HD page 1" : "SD page 1"; 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/SaveFileCoverEngine.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using MobiMetadata; 3 | 4 | namespace AzwConverter.Engine 5 | { 6 | public class SaveFileCoverEngine : SaveBookCoverEngine 7 | { 8 | private List? _hdHeaderList; 9 | 10 | private FileInfo? _azwFile; 11 | 12 | public async Task SaveFileCoverAsync(FileInfo azwFile, List hdHeaderList) 13 | { 14 | _hdHeaderList = hdHeaderList; 15 | 16 | _azwFile = azwFile; 17 | 18 | return await ReadImageDataAsync(azwFile.Name, azwFile); 19 | } 20 | 21 | public string GetCoverFile() 22 | { 23 | return _coverFile; 24 | } 25 | 26 | protected override FileInfo? SelectHDContainer(FileInfo[] dataFiles) 27 | { 28 | IgnoreHDContainerWarning = true; 29 | 30 | _coverFile = GetCoverFile(_azwFile!.FullName, Metadata!.MobiHeader.GetFullTitle()); 31 | 32 | return HDContainerHelper.FindHDContainer(Metadata!, _hdHeaderList); 33 | } 34 | 35 | private static string GetCoverFile(string azwFile, string title) 36 | { 37 | title = $"{title.ToFileSystemString()}.jpg"; 38 | 39 | if (!string.IsNullOrEmpty(Settings.SaveCoverDir)) 40 | { 41 | return Path.Combine(Settings.SaveCoverDir, title); 42 | } 43 | 44 | var dir = Path.GetDirectoryName(azwFile); 45 | return Path.Combine(dir!, title); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/ScanBookEngine.cs: -------------------------------------------------------------------------------- 1 | namespace AzwConverter.Engine 2 | { 3 | public class ScanBookEngine : AbstractImageEngine 4 | { 5 | public async Task ScanBookAsync(string bookId, FileInfo[] dataFiles) => await ReadImageDataAsync(bookId, dataFiles); 6 | 7 | protected override Task ProcessImagesAsync() => Task.FromResult(ReadCbzState()); 8 | 9 | private CbzItem ReadCbzState() 10 | { 11 | var state = new CbzItem 12 | { 13 | HdCover = Metadata.IsHdCover(), 14 | SdCover = Metadata.IsSdCover(), 15 | }; 16 | 17 | for (int i = 0, sz = Metadata.MergedImageRecords.Count; i < sz; i++) 18 | { 19 | state.Pages++; 20 | 21 | if (Metadata.IsHdPage(i)) 22 | { 23 | state.HdImages++; 24 | } 25 | else 26 | { 27 | state.SdImages++; 28 | } 29 | } 30 | 31 | return state; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Engine/ScanFileEngine.cs: -------------------------------------------------------------------------------- 1 | using MobiMetadata; 2 | 3 | namespace AzwConverter.Engine 4 | { 5 | public class ScanFileEngine : ScanBookEngine 6 | { 7 | private List? _hdHeaderList; 8 | 9 | public async Task ScanFileAsync(FileInfo azwFile, List hdHeaderList) 10 | { 11 | _hdHeaderList = hdHeaderList; 12 | 13 | var state = await ReadImageDataAsync(azwFile.Name, azwFile); 14 | state.Name = Metadata!.MobiHeader.GetFullTitle(); 15 | 16 | return state; 17 | } 18 | 19 | protected override FileInfo? SelectHDContainer(FileInfo[] dataFiles) 20 | { 21 | IgnoreHDContainerWarning = true; 22 | 23 | return HDContainerHelper.FindHDContainer(Metadata!, _hdHeaderList); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /Source/AzwConverter/Extensions.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using MobiMetadata; 3 | 4 | namespace AzwConverter 5 | { 6 | public static class Extensions 7 | { 8 | public static string RemoveAnyMarker(this string name) 9 | { 10 | foreach (var marker in Settings.AllMarkers) 11 | { 12 | if (name.StartsWith(marker)) 13 | { 14 | return name.Replace(marker, null).Trim(); 15 | } 16 | } 17 | return name; 18 | } 19 | 20 | public static string AddMarker(this string name, string marker) => !name.StartsWith(marker) ? $"{marker} {name}" : name; 21 | 22 | public static bool IsAzwOrAzw3File(this FileInfo fileInfo) => fileInfo.Name.IsAzwOrAzw3File(); 23 | 24 | public static bool IsAzwOrAzw3File(this string name) => name.EndsWithIgnoreCase(".azw") || name.EndsWithIgnoreCase(".azw3"); 25 | 26 | public static bool IsAzwResOrAzw6File(this FileInfo fileInfo) => fileInfo.Name.IsAzwResOrAzw6File(); 27 | 28 | public static bool IsAzwResOrAzw6File(this string name) => name.EndsWithIgnoreCase(".azw.res") || name.EndsWithIgnoreCase(".azw6"); 29 | 30 | public static string GetFullTitle(this MobiHead mobiHeader) 31 | { 32 | var title = mobiHeader.ExthHeader.UpdatedTitle; 33 | if (string.IsNullOrWhiteSpace(title)) 34 | { 35 | title = mobiHeader.FullName; 36 | } 37 | return title; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Source/AzwConverter/LibraryManager.cs: -------------------------------------------------------------------------------- 1 | using AzwConverter.Engine; 2 | using CbzMage.Shared.CollectionManager; 3 | using CbzMage.Shared.Extensions; 4 | using MobiMetadata; 5 | using System.Collections.Concurrent; 6 | 7 | namespace AzwConverter 8 | { 9 | public class LibraryManager 10 | { 11 | private readonly CollectionDb _collectionDb; 12 | 13 | public LibraryManager(CollectionDb collectionDb) 14 | { 15 | _collectionDb = collectionDb; 16 | } 17 | 18 | public IDictionary ReadBooks() 19 | { 20 | var dict = new ConcurrentDictionary(); 21 | 22 | var directoryInfo = new DirectoryInfo(Settings.AzwDir); 23 | var allFiles = directoryInfo.EnumerateFiles("*", SearchOption.AllDirectories); 24 | 25 | allFiles.ToLookup(dir => dir.DirectoryName).AsParallel().ForAll(files => 26 | { 27 | if (files.Any(file => file.IsAzwOrAzw3File())) 28 | { 29 | dict[Path.GetFileName(files.Key)!] = files.ToArray(); 30 | } 31 | }); 32 | 33 | return new Dictionary(dict); 34 | } 35 | 36 | public async Task<(int skippedItems, int ignoredItems)> SyncBooksToCollectionAsync(IDictionary books, IDictionary titles) 37 | { 38 | var itemsWithErrors = new ConcurrentBag(); 39 | var skippedItems = new ConcurrentBag(); 40 | 41 | var addedTitles = new ConcurrentDictionary(); 42 | 43 | await Parallel.ForEachAsync(books, Settings.ParallelOptions, async (item, _) => 44 | { 45 | var itemId = item.Key; 46 | 47 | // Book is not in current titles 48 | if (!titles.ContainsKey(itemId)) 49 | { 50 | // Try the archive 51 | if (_collectionDb.TryGetName(itemId, out var name)) 52 | { 53 | await SyncAsync(name); 54 | } 55 | else 56 | { 57 | // Or scan the book file 58 | MobiMetadata.MobiMetadata metadata; 59 | IDisposable[] disposables; 60 | try 61 | { 62 | var bookFiles = item.Value; 63 | 64 | var engine = new MetadataEngine(); 65 | (metadata, disposables) = await engine.GetMetadataAsync(bookFiles); 66 | 67 | if (!Settings.ConvertAllBookTypes && !metadata.MobiHeader.ExthHeader.BookType.EqualsIgnoreCase("comic")) 68 | { 69 | skippedItems.Add(itemId); 70 | return; 71 | } 72 | 73 | MetadataManager.CacheMetadata(itemId, metadata, disposables); 74 | } 75 | catch (MobiMetadataException) 76 | { 77 | itemsWithErrors.Add(itemId); 78 | return; 79 | } 80 | 81 | var title = metadata.MobiHeader.GetFullTitle(); 82 | title = title.ToFileSystemString(); 83 | 84 | var publisher = metadata.MobiHeader.ExthHeader.Publisher.ToFileSystemString(); 85 | publisher = TrimPublisher(publisher); 86 | 87 | await SyncAsync($"[{publisher}] {title}"); 88 | } 89 | 90 | async Task SyncAsync(string titleFile) 91 | { 92 | var file = Path.Combine(Settings.TitlesDir, titleFile); 93 | await File.WriteAllTextAsync(file, itemId, CancellationToken.None); 94 | 95 | // Add archived/scanned title to list of current titles 96 | addedTitles[itemId] = new FileInfo(file); 97 | } 98 | } 99 | }); 100 | 101 | foreach (var bookId in itemsWithErrors) 102 | { 103 | books.Remove(bookId); 104 | } 105 | 106 | foreach (var bookId in skippedItems) 107 | { 108 | books.Remove(bookId); 109 | } 110 | 111 | foreach (var title in addedTitles) 112 | { 113 | titles.Add(title); 114 | } 115 | 116 | return (skippedItems: addedTitles.Count, ignoredItems: skippedItems.Count); 117 | } 118 | 119 | private static string TrimPublisher(string publisher) 120 | { 121 | // Normalize publisher name 122 | foreach (var trimmedName in Settings.TrimPublishers) 123 | { 124 | if (publisher.StartsWith(trimmedName, StringComparison.OrdinalIgnoreCase)) 125 | { 126 | return trimmedName; 127 | } 128 | } 129 | 130 | return publisher; 131 | } 132 | 133 | // In version 23 and earlier a converted titlefile did not get archived together with the 134 | // main titlefile. So we must trim the converted titles to be consistent with version 24+ 135 | // The trimming is only expensive first time it's run. 136 | public static void TrimConvertedTitles(IDictionary convertedTitles, IDictionary titles) 137 | { 138 | var idsToRemove = new ConcurrentBag(); 139 | 140 | convertedTitles.AsParallel().ForAll(convertedTitle => 141 | { 142 | if (!titles.ContainsKey(convertedTitle.Key)) 143 | { 144 | idsToRemove.Add(convertedTitle.Key); 145 | convertedTitle.Value.Delete(); 146 | } 147 | }); 148 | 149 | foreach (var bookId in idsToRemove) 150 | { 151 | convertedTitles.Remove(bookId); 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Source/AzwConverter/MetadataManager.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using MobiMetadata; 3 | using System.Collections.Concurrent; 4 | 5 | namespace AzwConverter 6 | { 7 | public class MetadataManager 8 | { 9 | public static MobiMetadata.MobiMetadata GetConfiguredMetadata() 10 | { 11 | // Want the image records of course, but not any properties 12 | var pdbHeader = new PDBHead(skipProperties: true); 13 | 14 | // Nothing from this one (PalmDOCHead has no records). 15 | var palmDocHeader = new PalmDOCHead(skipProperties: true); 16 | 17 | // MobiHead: 18 | // Want the exth header, 19 | // fullname, 20 | // idx of first image record, 21 | // idx of last content record 22 | 23 | // EXTHHead: 24 | // Want the publisher, 25 | // the cover record index offset, 26 | // thumbnail record index offset 27 | 28 | return new MobiMetadata.MobiMetadata(pdbHeader, palmDocHeader, throwIfNoExthHeader: true); 29 | } 30 | 31 | private class CacheItem 32 | { 33 | public MobiMetadata.MobiMetadata Metadata { get; set; } 34 | 35 | public IDisposable[] Disposables { get; set; } 36 | } 37 | 38 | private static readonly ConcurrentDictionary _cache = new(); 39 | 40 | public static void CacheMetadata(string bookId, MobiMetadata.MobiMetadata metadata, params IDisposable[] disposables) 41 | { 42 | var item = new CacheItem { Metadata = metadata, Disposables = disposables }; 43 | 44 | if (!_cache.TryAdd(bookId, item)) 45 | { 46 | throw new Exception($"Metadata for book {bookId} is already cached"); 47 | } 48 | } 49 | 50 | public static MobiMetadata.MobiMetadata? GetCachedMetadata(string bookId) 51 | { 52 | return _cache.TryGetValue(bookId, out var item) 53 | ? item.Metadata 54 | : default; 55 | } 56 | 57 | public static void DisposeCachedMetadata(string bookId) 58 | { 59 | if (_cache.TryRemove(bookId, out var item)) 60 | { 61 | DisposeDisposables(item.Disposables); 62 | item.Metadata = null; 63 | } 64 | } 65 | 66 | public static void DisposeDisposables(params IDisposable[] disposables) 67 | { 68 | foreach (var disposable in disposables) 69 | { 70 | disposable.DisposeDontCare(); 71 | } 72 | } 73 | 74 | public static void ThrowIfCacheNotEmpty() 75 | { 76 | if (!_cache.IsEmpty) 77 | { 78 | throw new InvalidDataException("Boo hoo"); 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Source/AzwConverter/Settings.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Compression; 2 | 3 | namespace AzwConverter 4 | { 5 | public sealed class Settings 6 | { 7 | // All properties with a public setter are read from settings file 8 | 9 | public static string[] TrimPublishers { get; set; } 10 | 11 | public static string AzwDir { get; set; } 12 | 13 | public static string TitlesDir { get; set; } 14 | 15 | public static string AnalysisDir { get; set; } 16 | 17 | public static string ConvertedTitlesDirName { get; set; } 18 | 19 | public static string ConvertedTitlesDir { get; private set; } 20 | 21 | public static void SetConvertedTitlesDir(string dir) 22 | { 23 | ConvertedTitlesDir = dir; 24 | } 25 | 26 | public static string CbzDir { get; set; } 27 | 28 | public static bool CbzDirSetBySystem { get; private set; } 29 | 30 | public static void SetCbzDirSetBySystem() 31 | { 32 | CbzDirSetBySystem = true; 33 | } 34 | 35 | public static bool ConvertAllBookTypes { get; set; } 36 | 37 | public static bool SaveCover { get; set; } 38 | /// 39 | /// If this is true SaveCover is also true 40 | /// 41 | public static bool SaveCoverOnly { get; set; } 42 | public static string? SaveCoverDir { get; set; } 43 | 44 | public static int NumberOfThreads { get; set; } 45 | 46 | public static ParallelOptions ParallelOptions { get; private set; } 47 | 48 | public static void SetParallelOptions(ParallelOptions parallelOptions) 49 | { 50 | ParallelOptions = parallelOptions; 51 | } 52 | 53 | public static CompressionLevel CompressionLevel { get; set; } 54 | 55 | public static string NewTitleMarker { get; set; } 56 | public static string UpdatedTitleMarker { get; set; } 57 | 58 | public static string[] AllMarkers { get; private set; } 59 | 60 | public static void SetAllMarkers() 61 | { 62 | AllMarkers = new string[] { NewTitleMarker, UpdatedTitleMarker }; 63 | } 64 | 65 | public static string ArchiveName => "archive.db"; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/AppVersions/App.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.AppVersions 2 | { 3 | public enum App 4 | { 5 | Ghostscript, 6 | SevenZip 7 | } 8 | } -------------------------------------------------------------------------------- /Source/CbzMage.Shared/AppVersions/AppVersion.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.AppVersions 2 | { 3 | public class AppVersion 4 | { 5 | public AppVersion(string exe, Version version) 6 | { 7 | Exe = exe; 8 | 9 | Version = version; 10 | } 11 | 12 | public string Exe { get; } 13 | 14 | public Version Version { get; } 15 | } 16 | } -------------------------------------------------------------------------------- /Source/CbzMage.Shared/AppVersions/AppVersionManager.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Win32; 2 | 3 | namespace CbzMage.Shared.AppVersions 4 | { 5 | public static class AppVersionManager 6 | { 7 | public static List GetInstalledVersionsOf(App app) 8 | { 9 | var map = GetInstalledVersionsOf(new App[] { app }); 10 | return map[app]; 11 | } 12 | 13 | public static Dictionary> GetInstalledVersionsOf(params App[] apps) 14 | { 15 | var appMap = new Dictionary>(); 16 | 17 | var x64 = Environment.Is64BitProcess; 18 | 19 | // 64 bit exe requires 64 bit process. 32 bit exe can run in both 32 bit and 64 bit process 20 | var hklms = new List { RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32) }; 21 | if (x64) 22 | { 23 | hklms.Add(RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)); 24 | } 25 | 26 | foreach (var app in apps) 27 | { 28 | switch (app) 29 | { 30 | case App.Ghostscript: 31 | appMap[App.Ghostscript] = GhostscriptVersion.GetGhostscriptVersions(hklms, x64); 32 | break; 33 | case App.SevenZip: 34 | appMap[App.SevenZip] = SevenZipVersion.GetSevenZipVersions(hklms, x64); 35 | break; 36 | } 37 | } 38 | 39 | hklms.ForEach(hklm => hklm.Dispose()); 40 | 41 | return appMap; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Source/CbzMage.Shared/AppVersions/GhostscriptVersion.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.Helpers; 4 | using Microsoft.Win32; 5 | 6 | namespace CbzMage.Shared.AppVersions 7 | { 8 | public static class GhostscriptVersion 9 | { 10 | private static readonly string[] hklmSubKeyNames = new[] 11 | { 12 | "SOFTWARE\\Artifex Ghostscript\\", 13 | "SOFTWARE\\GPL Ghostscript\\", 14 | }; 15 | 16 | public static List GetGhostscriptVersions(List hklms, bool x64) 17 | { 18 | var versionList = new List(); 19 | 20 | foreach (var hklm in hklms) 21 | { 22 | foreach (var subKeyName in hklmSubKeyNames) 23 | { 24 | using var ghostscriptKey = hklm.OpenSubKey(subKeyName); 25 | if (ghostscriptKey == null) 26 | { 27 | continue; 28 | } 29 | 30 | // Each sub-key represents a version of the installed Ghostscript library 31 | foreach (var versionKey in ghostscriptKey.GetSubKeyNames()) 32 | { 33 | try 34 | { 35 | using var ghostscriptVersion = ghostscriptKey.OpenSubKey(versionKey); 36 | 37 | // Get the Ghostscript native library path 38 | var gsDll = ghostscriptVersion.GetValue("GS_DLL", string.Empty) as string; 39 | 40 | if (!string.IsNullOrEmpty(gsDll) && File.Exists(gsDll)) 41 | { 42 | string exe = null; 43 | 44 | // 64 bit exe requires 64 bit process. 45 | if (x64 && gsDll.EndsWith("gsdll64.dll")) 46 | { 47 | exe = "gswin64c.exe"; 48 | } 49 | 50 | // 32 bit exe can run in both 32 bit and 64 bit process 51 | if (exe == null && gsDll.EndsWith("gsdll32.dll")) 52 | { 53 | exe = "gswin32c.exe"; 54 | } 55 | 56 | if (!string.IsNullOrEmpty(exe)) 57 | { 58 | var bin = Path.GetDirectoryName(gsDll); 59 | exe = Path.Combine(bin, exe); 60 | 61 | if (File.Exists(exe)) 62 | { 63 | var fileVersion = FileVersionInfo.GetVersionInfo(exe); 64 | 65 | var version = new Version(fileVersion.FileVersion); 66 | 67 | versionList.Add(new AppVersion(exe, version)); 68 | } 69 | } 70 | } 71 | } 72 | catch (Exception ex) 73 | { 74 | ProgressReporter.Warning(ex.TypeAndMessage()); 75 | } 76 | } 77 | } 78 | } 79 | 80 | return versionList; 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /Source/CbzMage.Shared/AppVersions/SevenZipVersion.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.Helpers; 4 | using Microsoft.Win32; 5 | 6 | namespace CbzMage.Shared.AppVersions 7 | { 8 | public static class SevenZipVersion 9 | { 10 | public static List GetSevenZipVersions(List hklms, bool x64) 11 | { 12 | var versionList = new List(); 13 | 14 | foreach (var hklm in hklms) 15 | { 16 | using var sevenZipKey = hklm.OpenSubKey("SOFTWARE\\7-Zip\\"); 17 | if (sevenZipKey == null) 18 | { 19 | continue; 20 | } 21 | 22 | try 23 | { 24 | var path = sevenZipKey.GetValue("Path", string.Empty) as string; 25 | 26 | if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) 27 | { 28 | path = sevenZipKey.GetValue(x64 ? "Path64" : "Path32", string.Empty) as string; 29 | 30 | if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) 31 | { 32 | return versionList; 33 | } 34 | } 35 | 36 | var exe = Path.Combine(path, "7z.exe"); 37 | 38 | if (!File.Exists(exe)) 39 | { 40 | return versionList; 41 | } 42 | 43 | var fileVersion = FileVersionInfo.GetVersionInfo(exe); 44 | 45 | var version = new Version(fileVersion.FileVersion); 46 | 47 | versionList.Add(new AppVersion(exe, version)); 48 | } 49 | catch (Exception ex) 50 | { 51 | ProgressReporter.Warning(ex.TypeAndMessage()); 52 | } 53 | } 54 | 55 | return versionList; 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /Source/CbzMage.Shared/BandcampCollector.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | True 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CbzMage.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | True 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CbzMageAction.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared 2 | { 3 | public enum CbzMageAction 4 | { 5 | AzwScan, AzwConvert, AzwAnalyze, PdfConvert, EpubConvert, BlackSteedConvert 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CollectionManager/Collection.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.CollectionManager 2 | { 3 | public class Collection where T : CollectionItem, new() 4 | { 5 | public CollectionDb Db { get; private set; } 6 | 7 | public ItemReader Reader { get; private set; } 8 | 9 | public ItemSyncer Syncer { get; private set; } 10 | 11 | public Collection(string itemsDir, string? processedItemsDirName = null, string? dbName = null) 12 | { 13 | if (!Directory.Exists(itemsDir)) 14 | { 15 | Directory.CreateDirectory(itemsDir); 16 | } 17 | 18 | // Db 19 | 20 | if (string.IsNullOrWhiteSpace(dbName)) 21 | { 22 | dbName = "collection.db"; 23 | } 24 | 25 | var dbPath = Path.Combine(itemsDir, dbName); 26 | Db = new CollectionDb(dbPath); 27 | 28 | // Reader 29 | 30 | if (string.IsNullOrEmpty(processedItemsDirName)) 31 | { 32 | processedItemsDirName = "Processed Items"; 33 | } 34 | 35 | var processedItemsDir = Path.Combine(itemsDir, processedItemsDirName); 36 | Reader = new ItemReader(itemsDir, processedItemsDir, dbName); 37 | 38 | // Syncer 39 | 40 | Syncer = new ItemSyncer(Db, processedItemsDir); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CollectionManager/CollectionDb.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.IO.MemoryMappedFiles; 3 | using System.Text; 4 | using System.Text.Json; 5 | 6 | namespace CbzMage.Shared.CollectionManager 7 | { 8 | public sealed class CollectionDb where T : CollectionItem, new() 9 | { 10 | public string DbFile { get; private set; } 11 | 12 | private readonly ConcurrentDictionary _db; 13 | 14 | private bool _isDirty = false; 15 | 16 | internal CollectionDb(string dbFile) 17 | { 18 | DbFile = dbFile; 19 | _db = new(); 20 | } 21 | 22 | public async Task ReadDbAsync() 23 | { 24 | var dbFileInfo = new FileInfo(DbFile); 25 | 26 | if (dbFileInfo.Exists) 27 | { 28 | using var mappedFile = MemoryMappedFile.CreateFromFile(dbFileInfo.FullName, FileMode.Open); 29 | using var stream = mappedFile.CreateViewStream(); 30 | 31 | var linesData = new byte[dbFileInfo.Length].AsMemory(); 32 | await stream.ReadAsync(linesData); 33 | 34 | var linesString = Encoding.UTF8.GetString(linesData.Span); 35 | if (string.IsNullOrWhiteSpace(linesString)) 36 | { 37 | return; 38 | } 39 | 40 | var lines = linesString.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); 41 | 42 | if (lines.Length == 0) 43 | { 44 | return; 45 | } 46 | 47 | lines.AsParallel().ForAll(line => 48 | { 49 | var item = JsonSerializer.Deserialize(line); 50 | 51 | if (!_db.TryAdd(item.Id, item)) 52 | { 53 | throw new InvalidOperationException($"[{item.Id}] already in archive"); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | public int Count => _db.Count; 60 | 61 | public bool TryGetName(string bookId, out string name) 62 | { 63 | name = null; 64 | 65 | if (_db.TryGetValue(bookId, out var state)) 66 | { 67 | name = state.Name; 68 | } 69 | 70 | return name != null; 71 | } 72 | 73 | public void SetOrCreateName(string itemId, string name) 74 | { 75 | if (!_db.ContainsKey(itemId)) 76 | { 77 | _db[itemId] = new T { Id = itemId, Name = name }; 78 | } 79 | else 80 | { 81 | _db[itemId].Name = name; 82 | } 83 | 84 | _isDirty = true; 85 | } 86 | 87 | public T GetItem(string itemId) => _db[itemId]; 88 | 89 | public void SetItem(string itemId, T item) 90 | { 91 | item.Id = itemId; // Ensure id 92 | 93 | _db[itemId] = item; 94 | _isDirty = true; 95 | } 96 | 97 | public async Task SaveArchiveDbAsync() 98 | { 99 | if (!_isDirty) 100 | { 101 | return; 102 | } 103 | 104 | await File.WriteAllLinesAsync(DbFile, _db.Values.Select(x => JsonSerializer.Serialize(x))); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CollectionManager/CollectionItem.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.CollectionManager 2 | { 3 | public class CollectionItem 4 | { 5 | public string Id { get; set; } 6 | 7 | public string Name { get; set; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CollectionManager/ItemReader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace CbzMage.Shared.CollectionManager 4 | { 5 | public class ItemReader 6 | { 7 | private readonly string _itemsDir; 8 | 9 | private readonly string _processedItemsDir; 10 | 11 | private readonly string _dbName; 12 | 13 | internal ItemReader(string itemsDir, string processedItemsDir, string dbName) 14 | { 15 | _itemsDir = itemsDir; 16 | _processedItemsDir = processedItemsDir; 17 | _dbName = dbName; 18 | } 19 | 20 | public async Task> ReadItemsAsync() 21 | { 22 | return await ReadFilesAsync(_itemsDir, true); 23 | } 24 | 25 | public async Task> ReadProcessedItemsAsync() 26 | { 27 | return await ReadFilesAsync(_processedItemsDir, false); 28 | } 29 | 30 | private async Task> ReadFilesAsync(string directory, bool checkDbName) 31 | { 32 | var dict = new ConcurrentDictionary(); 33 | 34 | var directoryInfo = new DirectoryInfo(directory); 35 | 36 | await Parallel.ForEachAsync(directoryInfo.EnumerateFiles(), async (file, ct) => 37 | { 38 | if (checkDbName && file.Name == _dbName) 39 | { 40 | return; 41 | } 42 | 43 | var bookId = await File.ReadAllTextAsync(file.FullName, ct); 44 | dict[bookId] = file; 45 | }); 46 | 47 | return new Dictionary(dict); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/CollectionManager/ItemSyncer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace CbzMage.Shared.CollectionManager 4 | { 5 | public class ItemSyncer where T : CollectionItem, new() 6 | { 7 | private readonly string _processedItemsDir; 8 | 9 | private readonly CollectionDb _collectionDb; 10 | 11 | internal ItemSyncer(CollectionDb collectionDb, string processedItemsDir) 12 | { 13 | _collectionDb = collectionDb; 14 | 15 | _processedItemsDir = processedItemsDir; 16 | } 17 | 18 | public int SyncAndArchiveItems(IDictionary items, IDictionary processedItems, IDictionary books) 19 | { 20 | var idsToRemove = new ConcurrentBag(); 21 | 22 | items.AsParallel().ForAll(item => 23 | { 24 | var itemId = item.Key; 25 | var itemFile = item.Value; 26 | 27 | _collectionDb.SetOrCreateName(itemId, itemFile.Name); 28 | 29 | // Delete title if no longer in books. 30 | if (!books.ContainsKey(itemId)) 31 | { 32 | idsToRemove.Add(itemId); 33 | 34 | itemFile.Delete(); 35 | 36 | // Also delete the converted title 37 | if (processedItems.TryGetValue(itemId, out var convertedTitle)) 38 | { 39 | convertedTitle.Delete(); 40 | } 41 | } 42 | else 43 | { 44 | // Sync title -> converted title 45 | if (processedItems.TryGetValue(itemId, out var convertedTitleFile) && convertedTitleFile.Name != itemFile.Name) 46 | { 47 | var newConvertedTitleFile = Path.Combine(convertedTitleFile.DirectoryName!, itemFile.Name); 48 | convertedTitleFile.MoveTo(newConvertedTitleFile); 49 | } 50 | } 51 | }); 52 | 53 | // Update current titles 54 | foreach (var bookId in idsToRemove) 55 | { 56 | items.Remove(bookId); 57 | processedItems.Remove(bookId); // This is safe even if title is not converted 58 | } 59 | 60 | return idsToRemove.Count; 61 | } 62 | 63 | public string SyncProcessedItem(string itemFile, FileInfo? convertedItemFile) 64 | { 65 | convertedItemFile?.Delete(); 66 | 67 | var name = Path.GetFileName(itemFile); 68 | var dest = Path.Combine(_processedItemsDir, name); 69 | 70 | File.Copy(itemFile, dest); 71 | File.SetLastWriteTime(dest, DateTime.Now); 72 | 73 | return name; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/DirectoryExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class DirectoryExtensions 4 | { 5 | public static void CreateDirIfNotExists(this string dir) 6 | { 7 | if (!Directory.Exists(dir)) 8 | { 9 | Directory.CreateDirectory(dir); 10 | } 11 | } 12 | 13 | public static void DeleteAndCreateDir(this string dir) 14 | { 15 | DeleteIfExists(dir); 16 | Directory.CreateDirectory(dir); 17 | } 18 | 19 | public static void DeleteIfExists(this string dir) 20 | { 21 | if (Directory.Exists(dir)) 22 | { 23 | Directory.Delete(dir, true); 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/DisposableExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class DisposableExtensions 4 | { 5 | public static void DisposeDontCare(this IDisposable disposable) 6 | { 7 | try 8 | { 9 | disposable?.Dispose(); 10 | } 11 | catch 12 | { 13 | } 14 | } 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class EnumerableExtensions 4 | { 5 | // Stolen from Dapper SqlMapper.cs 6 | public static List AsList(this IEnumerable source) => source == null || source is List ? (List)source : source.ToList(); 7 | 8 | public static string SIfNot1(this int count) => count != 1 ? "s" : string.Empty; 9 | 10 | public static string SIfNot1(this IEnumerable enu) => enu.Count().SIfNot1(); 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/ExceptionExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class ExceptionExtensions 4 | { 5 | public static string TypeAndMessage(this Exception ex) => $"{ex.GetType().Name}: {ex.Message}"; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/FileSystemExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text; 3 | 4 | namespace CbzMage.Shared.Extensions 5 | { 6 | public static class FilesystemExtensions 7 | { 8 | private static readonly char[] _invalidChars = Path.GetInvalidFileNameChars().Union(Path.GetInvalidPathChars()).ToArray(); 9 | 10 | private const int _spaceChar = 32; 11 | 12 | public static string ToFileSystemString(this string str) 13 | { 14 | str = WebUtility.HtmlDecode(str); 15 | 16 | var sb = new StringBuilder(str); 17 | 18 | for (int i = 0, sz = sb.Length; i < sz; i++) 19 | { 20 | var ch = sb[i]; 21 | if (_invalidChars.Contains(ch) || ch != _spaceChar && char.IsWhiteSpace(ch)) 22 | { 23 | sb[i] = ' '; 24 | } 25 | } 26 | 27 | return sb.Replace(" ", " ").Replace(" ", " ").ToString().Trim(); 28 | } 29 | 30 | public static bool IsDirectory(this FileSystemInfo e) => (e.Attributes & FileAttributes.Directory) == FileAttributes.Directory; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/NumberExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class NumberExtensions 4 | { 5 | public static int ToInt(this float f) => Convert.ToInt32(f); 6 | 7 | public static int ToInt(this double d) => Convert.ToInt32(d); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/PageStringExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class PageStringExtensions 4 | { 5 | public static string ToPageNumberString(this int pageNumber) 6 | { 7 | return $"page-{GetPageNumberString(pageNumber)}"; 8 | } 9 | 10 | private static string GetPageNumberString(int pageNumber) => pageNumber.ToString().PadLeft(4, '0'); 11 | 12 | public static string ToPageString(this int pageNumber) 13 | { 14 | return ToPageString(pageNumber, "jpg"); 15 | } 16 | 17 | public static string ToPageString(this int pageNumber, string imageExt) 18 | { 19 | return $"page-{GetPageNumberString(pageNumber)}.{imageExt}"; 20 | } 21 | 22 | public static int ToPageNumber(this string page) 23 | { 24 | var idx = page.IndexOf('-'); 25 | var number = page.Substring(idx + 1, 4); 26 | 27 | return int.Parse(number); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class StringExtensions 4 | { 5 | public static bool EqualsIgnoreCase(this string a, string b) => string.Compare(a, b, ignoreCase: true) == 0; 6 | 7 | public static bool EndsWithIgnoreCase(this string a, string endsWith) => a.EndsWith(endsWith, StringComparison.OrdinalIgnoreCase); 8 | 9 | public static bool StartsWithIgnoreCase(this string a, string endsWith) => a.StartsWith(endsWith, StringComparison.OrdinalIgnoreCase); 10 | 11 | public static bool ContainsIgnoreCase(this string a, string contains) => a.Contains(contains, StringComparison.OrdinalIgnoreCase); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Extensions/TimeSpanExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.Extensions 2 | { 3 | public static class TimeSpanExtensions 4 | { 5 | public static string Mmss(this TimeSpan a) => a.ToString(@"mm\:ss"); 6 | 7 | public static string Hhmmss(this TimeSpan a) => a.ToString(@"hh\:mm\:ss"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Helpers/ProcessRunner.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace CbzMage.Shared.Helpers 4 | { 5 | public class ProcessRunner : IDisposable 6 | { 7 | private readonly List _errorLines = new(); 8 | 9 | private readonly Process _process; 10 | 11 | private readonly ProcessPriorityClass _priorityClass; 12 | 13 | public ProcessRunner(string path, string args = "", string workingDirectory = "", ProcessPriorityClass processPriority = ProcessPriorityClass.Normal, 14 | EventHandler? outputReceived = null) 15 | { 16 | _process = new Process 17 | { 18 | StartInfo = new ProcessStartInfo 19 | { 20 | FileName = path, 21 | Arguments = args, 22 | WorkingDirectory = workingDirectory, 23 | UseShellExecute = false, 24 | CreateNoWindow = true, 25 | RedirectStandardOutput = true, 26 | RedirectStandardError = true, 27 | } 28 | }; 29 | 30 | if (outputReceived != null) 31 | { 32 | _process.OutputDataReceived += (s, e) => outputReceived(s, e); 33 | } 34 | 35 | _process.ErrorDataReceived += (s, e) => OnError(e.Data!); 36 | 37 | _priorityClass = processPriority; 38 | } 39 | 40 | public void Run() 41 | { 42 | _process.Start(); 43 | 44 | if (_priorityClass != ProcessPriorityClass.Normal && !_process.HasExited) 45 | { 46 | try 47 | { 48 | _process.PriorityClass = _priorityClass; 49 | } 50 | catch 51 | { 52 | //This can fail if process has already exited, so ignore any error 53 | } 54 | } 55 | 56 | _process.BeginErrorReadLine(); 57 | } 58 | 59 | /// 60 | /// Get the underlying output stream. Do not touch Process.StandardOutput after doing this. 61 | /// 62 | /// 63 | public Stream GetOutputStream() 64 | { 65 | var stream = _process.StandardOutput.BaseStream; 66 | return stream; 67 | } 68 | 69 | public int RunAndWaitForExit() 70 | { 71 | Run(); 72 | 73 | _process.WaitForExit(); 74 | 75 | return _process.ExitCode; 76 | } 77 | 78 | private void OnError(string line) 79 | { 80 | if (!string.IsNullOrEmpty(line)) 81 | { 82 | _errorLines.Add(line); 83 | } 84 | } 85 | 86 | public List GetStandardErrorLines() 87 | { 88 | return _errorLines; 89 | } 90 | 91 | public int WaitForExitCode() 92 | { 93 | if (!_process.HasExited) 94 | { 95 | _process.WaitForExit(); 96 | } 97 | return _process.ExitCode; 98 | } 99 | 100 | #region Dispose 101 | 102 | private bool disposedValue; 103 | 104 | protected virtual void Dispose(bool disposing) 105 | { 106 | if (!disposedValue) 107 | { 108 | if (disposing) 109 | { 110 | _process.Dispose(); 111 | } 112 | 113 | // TODO: free unmanaged resources (unmanaged objects) and override finalizer 114 | // TODO: set large fields to null 115 | disposedValue = true; 116 | } 117 | } 118 | 119 | // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources 120 | // ~ProcessRunner() 121 | // { 122 | // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 123 | // Dispose(disposing: false); 124 | // } 125 | 126 | public void Dispose() 127 | { 128 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 129 | Dispose(disposing: true); 130 | GC.SuppressFinalize(this); 131 | } 132 | 133 | #endregion 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Helpers/ProgressReporter.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | 3 | namespace CbzMage.Shared.Helpers 4 | { 5 | public class ProgressReporter 6 | { 7 | private readonly object _progressLock = new(); 8 | 9 | private readonly int _total; 10 | 11 | private volatile int _current; 12 | 13 | public ProgressReporter(int total) 14 | { 15 | _total = total; 16 | } 17 | 18 | public void ShowProgress(string message) 19 | { 20 | var current = ++_current; 21 | 22 | var progressPercentage = current / (_total / 100d); 23 | 24 | var convertedProgress = Convert.ToInt32(progressPercentage); 25 | convertedProgress = Math.Min(convertedProgress, 100); 26 | 27 | var progress = $"{message} {convertedProgress}%"; 28 | 29 | lock (_progressLock) 30 | { 31 | Console.CursorLeft = 0; 32 | Console.Write(progress); 33 | } 34 | } 35 | 36 | public void EndProgress() 37 | { 38 | Console.WriteLine(); 39 | } 40 | 41 | public static void ShowMessage(string message) 42 | { 43 | Console.CursorLeft = 0; 44 | Console.Write($"{message}"); 45 | } 46 | 47 | public static void EndMessages() 48 | { 49 | Console.WriteLine(); 50 | } 51 | 52 | public static void DoneOrInfo(string message, int count) 53 | { 54 | if (count > 0) 55 | { 56 | Done(message); 57 | } 58 | else 59 | { 60 | Info(message); 61 | } 62 | } 63 | 64 | public static void Done(string message) 65 | { 66 | Show(message, ConsoleColor.DarkGreen); 67 | } 68 | 69 | public static void Info(string message) 70 | { 71 | Console.WriteLine(message); 72 | } 73 | 74 | public static void Line() 75 | { 76 | Console.WriteLine(); 77 | } 78 | 79 | public static void Warning(string message) 80 | { 81 | Show(message, ConsoleColor.DarkYellow); 82 | } 83 | 84 | public static void DumpWarnings(IEnumerable warningLines) 85 | { 86 | var warnings = string.Join(Environment.NewLine, warningLines); 87 | Warning(warnings); 88 | } 89 | 90 | public static void DumpErrors(IEnumerable errorLines) 91 | { 92 | var errors = string.Join(Environment.NewLine, errorLines); 93 | Error(errors); 94 | } 95 | 96 | public static void Error(string message, Exception ex) 97 | { 98 | var errorMessage = $"{message} {ex.TypeAndMessage()}"; 99 | 100 | #if DEBUG 101 | errorMessage = $"{message}{Environment.NewLine}{ex}"; 102 | #endif 103 | 104 | Error(errorMessage); 105 | } 106 | 107 | public static void Error(string message) 108 | { 109 | Show(message, ConsoleColor.DarkRed); 110 | } 111 | 112 | private static readonly object _showLock = new(); 113 | 114 | private static void Show(string message, ConsoleColor color) 115 | { 116 | lock (_showLock) 117 | { 118 | ConsoleColor? previousColor = null; 119 | 120 | if (color != Console.ForegroundColor) 121 | { 122 | previousColor = Console.ForegroundColor; 123 | Console.ForegroundColor = color; 124 | } 125 | 126 | Console.WriteLine(message); 127 | 128 | if (previousColor.HasValue) 129 | { 130 | Console.ForegroundColor = previousColor.Value; 131 | } 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/IO/AsyncStreams.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.IO 2 | { 3 | public class AsyncStreams 4 | { 5 | public static FileStream AsyncFileReadStream(string filePath) 6 | { 7 | return new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 0, FileOptions.Asynchronous | FileOptions.SequentialScan); 8 | } 9 | 10 | public static FileStream AsyncFileWriteStream(string filePath) 11 | { 12 | return new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite, 0, FileOptions.Asynchronous | FileOptions.SequentialScan); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/AbstractJobQueue.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace CbzMage.Shared.JobQueue 4 | { 5 | public abstract class AbstractJobQueue 6 | { 7 | private class InternalJobWaiter : JobWaiter 8 | { 9 | public void SignalWaitIsOver() 10 | { 11 | _waitingQueue.Add("Stop"); 12 | } 13 | } 14 | 15 | private InternalJobWaiter _jobWaiter; 16 | 17 | protected AbstractJobQueue(int numWorkerThreads = 1) 18 | { 19 | _numWorkerThreads = numWorkerThreads; 20 | } 21 | 22 | protected BlockingCollection> _jobQueue; 23 | 24 | private readonly int _numWorkerThreads; 25 | 26 | private volatile int _numFinishedWorkerThreads = 0; 27 | 28 | protected JobWaiter InitQueueWaiterAndWorkerThreads(bool withWaiter) 29 | { 30 | _jobQueue = new BlockingCollection>(); 31 | 32 | if (withWaiter) 33 | { 34 | _jobWaiter = new InternalJobWaiter(); 35 | } 36 | 37 | for (int i = 0; i < _numWorkerThreads; i++) 38 | { 39 | Task.Factory.StartNew(JobConsumerLoopAsync, TaskCreationOptions.LongRunning); 40 | } 41 | 42 | return _jobWaiter; 43 | } 44 | 45 | public void Stop() 46 | { 47 | _jobQueue.CompleteAdding(); 48 | } 49 | 50 | public void AddJob(IJobConsumer job) 51 | { 52 | if (!_jobQueue.IsAddingCompleted) 53 | { 54 | _jobQueue.Add(job); 55 | } 56 | } 57 | 58 | private async Task JobConsumerLoopAsync() 59 | { 60 | foreach (var job in _jobQueue.GetConsumingEnumerable()) 61 | { 62 | var result = await job.ConsumeAsync(); 63 | 64 | JobExecuted?.Invoke(this, new JobEventArgs(result)); 65 | } 66 | 67 | if (Interlocked.Increment(ref _numFinishedWorkerThreads) == _numWorkerThreads) 68 | { 69 | _jobWaiter?.SignalWaitIsOver(); 70 | 71 | _jobQueue.Dispose(); 72 | } 73 | } 74 | 75 | public event EventHandler> JobExecuted; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/IJobConsumer.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.JobQueue 2 | { 3 | public interface IJobConsumer 4 | { 5 | Task ConsumeAsync(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/IJobProducer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace CbzMage.Shared.JobQueue 4 | { 5 | public interface IJobProducer 6 | { 7 | Task ProduceAsync(BlockingCollection> jobQueue); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/JobEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.JobQueue 2 | { 3 | public class JobEventArgs : EventArgs 4 | { 5 | public JobEventArgs(T result) 6 | { 7 | Result = result; 8 | } 9 | 10 | public string Info { get; set; } 11 | 12 | public Exception Exception { get; set; } 13 | 14 | public T Result { get; private set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/JobExecutor.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.JobQueue 2 | { 3 | public class JobExecutor : AbstractJobQueue 4 | { 5 | public JobExecutor(int numWorkerThreads = 1) : base(numWorkerThreads) 6 | { 7 | } 8 | 9 | public JobWaiter Start(bool withWaiter) 10 | { 11 | return InitQueueWaiterAndWorkerThreads(withWaiter); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/JobProducerConsumer.cs: -------------------------------------------------------------------------------- 1 | namespace CbzMage.Shared.JobQueue 2 | { 3 | public class JobProducerConsumer : AbstractJobQueue 4 | { 5 | public JobProducerConsumer(int numWorkerThreads = 1) : base(numWorkerThreads) 6 | { 7 | } 8 | 9 | public JobWaiter Start(IJobProducer producer, bool withWaiter) 10 | { 11 | var jobWaiter = InitQueueWaiterAndWorkerThreads(withWaiter); 12 | 13 | Task.Factory.StartNew(() => producer.ProduceAsync(_jobQueue), TaskCreationOptions.LongRunning); 14 | 15 | return jobWaiter; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/JobQueue/JobWaiter.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace CbzMage.Shared.JobQueue 4 | { 5 | public abstract class JobWaiter 6 | { 7 | protected readonly BlockingCollection _waitingQueue; 8 | 9 | protected JobWaiter() 10 | { 11 | _waitingQueue = new BlockingCollection(); 12 | } 13 | 14 | public void WaitForJobsToFinish() 15 | { 16 | try 17 | { 18 | _waitingQueue.Take(); 19 | } 20 | finally 21 | { 22 | _waitingQueue.Dispose(); 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/Settings/SharedSettings.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace CbzMage.Shared.Settings 5 | { 6 | public class SharedSettings 7 | { 8 | private const string _defaultSettings = "DefaultSettings"; 9 | 10 | public void CreateSettings(string className, object settingsClass) 11 | { 12 | using IHost host = Host.CreateDefaultBuilder().ConfigureAppConfiguration((hostingContext, config) => 13 | { 14 | config.Sources.Clear(); 15 | 16 | var env = hostingContext.HostingEnvironment; 17 | 18 | config.AddJsonFile($"{_defaultSettings}.json", optional: false, reloadOnChange: false) 19 | .AddJsonFile($"{className}.json", optional: false, reloadOnChange: false) 20 | .AddJsonFile($"{className}.{env.EnvironmentName}.json", true, false); 21 | 22 | var configRoot = config.Build(); 23 | configRoot.Bind(settingsClass); 24 | }).Build(); 25 | } 26 | 27 | public int GetThreadCount(int settingsThreadCount) 28 | { 29 | const double fraction = 0.75; 30 | 31 | const int maxThreads = 8; 32 | const int minThreads = 2; 33 | 34 | if (settingsThreadCount <= 0) 35 | { 36 | var threadCountFraction = Environment.ProcessorCount * fraction; 37 | 38 | var calculatedThreadCount = Convert.ToInt32(threadCountFraction); 39 | 40 | calculatedThreadCount = Math.Min(calculatedThreadCount, maxThreads); 41 | calculatedThreadCount = Math.Max(calculatedThreadCount, minThreads); 42 | 43 | return calculatedThreadCount; 44 | } 45 | 46 | return settingsThreadCount; 47 | } 48 | 49 | private static string ScanAllDirectoriesPattern => $"{Path.DirectorySeparatorChar}**"; 50 | 51 | public static string GetDirectorySearchOption(string directory, out SearchOption searchOption) 52 | { 53 | searchOption = SearchOption.TopDirectoryOnly; 54 | 55 | if (directory.EndsWith(ScanAllDirectoriesPattern)) 56 | { 57 | searchOption = SearchOption.AllDirectories; 58 | directory = directory.Replace(ScanAllDirectoriesPattern, null); 59 | } 60 | 61 | return directory; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Source/CbzMage.Shared/StringCasing/CaseValidation.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace CbzMage.Shared.StringCasing 4 | { 5 | public static class CaseValidation 6 | { 7 | public static bool IsUpperCased(this string str) => !str.Any(char.IsLower); 8 | 9 | public static bool IsLowerCased(this string str) => !str.Any(char.IsUpper); 10 | 11 | public static bool IsRomanNumeral(this string str) 12 | { 13 | // Use flexible matching that catches IIII etc. 14 | // https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch06s09.html 15 | return Regex.IsMatch(str, "^(?=[MDCLXVI])M*(C[MD]|D?C*)(X[CL]|L?X*)(I[XV]|V?I*)$"); 16 | } 17 | 18 | public static string FixCasedString(string str, bool isLowerCased) 19 | { 20 | // Don't "fix" uppercased roman numerals 21 | if (!isLowerCased && str.IsRomanNumeral()) 22 | { 23 | return str; 24 | } 25 | 26 | var chars = str.ToCharArray(); 27 | 28 | bool wordStart = false; 29 | 30 | for (int i = 0, sz = chars.Length; i < sz; i++) 31 | { 32 | if (CheckCase(chars[i])) 33 | { 34 | if (!wordStart) 35 | { 36 | wordStart = true; 37 | if (isLowerCased) 38 | { 39 | chars[i] = char.ToUpper(str[i]); 40 | } 41 | } 42 | else if (!isLowerCased) 43 | { 44 | chars[i] = char.ToLower(str[i]); 45 | } 46 | } 47 | else 48 | { 49 | wordStart = false; 50 | } 51 | } 52 | 53 | return new string(chars); 54 | 55 | bool CheckCase(char ch) => isLowerCased ? char.IsLower(ch) : char.IsUpper(ch); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Source/CbzMage/CbzMage.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Always 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Source/CbzMage/DefaultSettings.json: -------------------------------------------------------------------------------- 1 | // These are the CbzMage default settings. Don't touch anything here, use 2 | // AzwConvertSettings.json or PdfConvertSettings.json to change what you need. 3 | { 4 | "AzwDir": "", 5 | "TitlesDir": "", 6 | "ConvertAllBookTypes": true, 7 | "ConvertedTitlesDirName": "", 8 | "NewTitleMarker": "", 9 | "UpdatedTitleMarker": "", 10 | "TrimPublishers": [ 11 | "Aftershock", 12 | "Archaia", 13 | "Avatar", 14 | "Avery Hill", 15 | "Berger", 16 | "Boom", 17 | "Caliber", 18 | "Cinebook", 19 | "Creator Owned", 20 | "Dark Horse", 21 | "DC", 22 | "Dead Canary", 23 | "Dover", 24 | "Drawn & Quarterly", 25 | "Dynamite", 26 | "Europe", 27 | "Fanbase", 28 | "Fantagraphics", 29 | "Humanoids", 30 | "IDW", 31 | "Image", 32 | "Kodansha", 33 | "Legendary", 34 | "Magnetic", 35 | "Markosia", 36 | "Marvel", 37 | "MAX", 38 | "NBM", 39 | "Soaring Penguin", 40 | "Source Point", 41 | "Strawberry", 42 | "Top Shelf", 43 | "Vault", 44 | "Vertical", 45 | "Viz", 46 | "Wildstorm", 47 | "Yen", 48 | "Z2" 49 | ], 50 | "GhostscriptPath": "", 51 | "JpgQuality": 93, 52 | "MinimumDpi": 300, 53 | "MinimumHeight": 1920, 54 | "MaximumHeight": 3840, 55 | "CbzDir": "", 56 | "SaveCover": false, 57 | "SaveCoverDir": "", 58 | "SaveCoverOnly": false, 59 | "NumberOfThreads": 0 60 | } 61 | -------------------------------------------------------------------------------- /Source/CbzMage/Program.cs: -------------------------------------------------------------------------------- 1 | using AzwConverter.Converter; 2 | using CbzMage.Shared; 3 | using CbzMage.Shared.Helpers; 4 | using EpubConverter; 5 | using PdfConverter; 6 | using System.Runtime.InteropServices; 7 | 8 | namespace CbzMage 9 | { 10 | internal class Program 11 | { 12 | public static string _usage = @" 13 | AzwConvert [or Azw Convert] 14 | Scans AzwDir and converts all unconverted comic books to cbz files. 15 | Specify to convert azw/azw3 files directly. 16 | 17 | AzwScan [or Azw Scan] 18 | Scans AzwDir and creates a .NEW title file for each unconverted comic book. 19 | Specify to scan azw/azw3 files directly. 20 | 21 | PdfConvert [or Pdf Convert] or 22 | Converts one or more pdf comic books to cbz files. 23 | 24 | Commands are case insensitive. 25 | "; 26 | /* 27 | EpubConvert [or Epub Convert] or 28 | Converts one or more epub comic books to cbz files. 29 | */ 30 | 31 | static async Task Main(string[] args) 32 | { 33 | Console.CursorVisible = false; 34 | Console.CancelKeyPress += (_, _) => Console.CursorVisible = true; 35 | 36 | var validAction = false; 37 | CbzMageAction action; 38 | 39 | var actionStr = string.Empty; 40 | var next = 0; 41 | 42 | if (args.Length > next) 43 | { 44 | ParseActionString(); 45 | 46 | if (args.Length > next && !validAction) 47 | { 48 | ParseActionString(); 49 | } 50 | 51 | try 52 | { 53 | if (validAction) 54 | { 55 | var path = args.Length > next ? args[next].Trim() : string.Empty; 56 | 57 | switch (action) 58 | { 59 | case CbzMageAction.AzwScan: 60 | case CbzMageAction.AzwConvert: 61 | if (path.Length > 0) 62 | { 63 | var fileOrDirConverter = new AzwFileOrDirectoryConverter(action, path); 64 | await fileOrDirConverter.ConvertOrScanAsync(); 65 | } 66 | else 67 | { 68 | var azwConverter = new AzwLibraryConverter(action); 69 | await azwConverter.ConvertOrScanAsync(); 70 | } 71 | break; 72 | case CbzMageAction.AzwAnalyze: 73 | { 74 | var azwConverter = new AzwLibraryConverter(action); 75 | await azwConverter.ConvertOrScanAsync(); 76 | } 77 | break; 78 | case CbzMageAction.PdfConvert: 79 | { 80 | var pdfConverter = new PdfFileOrDirectoryConverter(); 81 | pdfConverter.ConvertFileOrDirectory(path!); 82 | } 83 | break; 84 | case CbzMageAction.EpubConvert: 85 | { 86 | var epubConverter = new EpubFileOrDirectoryConverter(); 87 | await epubConverter.ConvertFileOrDirectoryAsync(path); 88 | } 89 | break; 90 | } 91 | } 92 | } 93 | catch (Exception ex) 94 | { 95 | ProgressReporter.Error("CbzMage fatal error.", ex); 96 | } 97 | } 98 | 99 | if (!validAction) 100 | { 101 | ProgressReporter.Info(_usage); 102 | } 103 | 104 | // If this is run as a "gui" let the console hang around 105 | if (ConsoleWillBeDestroyedAtTheEnd()) 106 | { 107 | Console.ReadLine(); 108 | } 109 | 110 | Console.CursorVisible = true; 111 | 112 | void ParseActionString() 113 | { 114 | actionStr += args[next]; 115 | 116 | validAction = Enum.TryParse(actionStr, ignoreCase: true, out action); 117 | 118 | next++; 119 | } 120 | } 121 | 122 | private static bool ConsoleWillBeDestroyedAtTheEnd() 123 | { 124 | if (Environment.OSVersion.Platform != PlatformID.Win32NT) 125 | { 126 | return false; 127 | } 128 | 129 | var processList = new uint[1]; 130 | var processCount = GetConsoleProcessList(processList, 1); 131 | 132 | return processCount == 1; 133 | } 134 | 135 | [DllImport("kernel32.dll", SetLastError = true)] 136 | static extern uint GetConsoleProcessList(uint[] processList, uint processCount); 137 | } 138 | } -------------------------------------------------------------------------------- /Source/EpubConverter/ConverterEngine.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 EpubConverter 8 | { 9 | public class ConverterEngine 10 | { 11 | public void ConvertToCbz(Epub epub, EpubParser epubParser) 12 | { 13 | 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Source/EpubConverter/Epub.cs: -------------------------------------------------------------------------------- 1 | namespace EpubConverter 2 | { 3 | public sealed class Epub 4 | { 5 | public Epub(string path) 6 | { 7 | Path = path; 8 | } 9 | 10 | public string Path { get; private set; } 11 | 12 | public List PageList { get; set; } 13 | 14 | public List ImageList { get; set; } 15 | 16 | public string NavXhtml { get; set; } 17 | 18 | public static List List(params string[] paths) => new(paths.Select(x => new Epub(x))); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/EpubConverter/EpubConvertSettings.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.AppVersions; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.Helpers; 4 | using CbzMage.Shared.Settings; 5 | using System.Diagnostics; 6 | 7 | namespace EpubConverter 8 | { 9 | public class EpubConvertSettings 10 | { 11 | public static Settings Settings => new(); 12 | 13 | private readonly SharedSettings _settingsHelper = new(); 14 | 15 | public void CreateSettings() 16 | { 17 | _settingsHelper.CreateSettings(nameof(EpubConvertSettings), Settings); 18 | 19 | ConfigureSettings(); 20 | } 21 | 22 | private void ConfigureSettings() 23 | { 24 | if (!string.IsNullOrEmpty(Settings.GhostscriptPath)) 25 | { 26 | if (File.Exists(Settings.GhostscriptPath)) 27 | { 28 | var version = FileVersionInfo.GetVersionInfo(Settings.GhostscriptPath).FileVersion; 29 | if (version == null) 30 | { 31 | ProgressReporter.Warning($"{Settings.GhostscriptPath} does not contain any version information."); 32 | } 33 | else 34 | { 35 | var appVersion = new AppVersion(Settings.GhostscriptPath, new Version(version)); 36 | 37 | // Throws if version is not valid 38 | appVersion = GetValidGhostscriptVersion(new List { appVersion }); 39 | 40 | Settings.SetGhostscriptVersion(appVersion.Version); 41 | } 42 | } 43 | } 44 | else if (Environment.OSVersion.Platform == PlatformID.Win32NT) 45 | { 46 | var versionList = AppVersionManager.GetInstalledVersionsOf(App.Ghostscript); 47 | var appVersion = GetValidGhostscriptVersion(versionList); 48 | 49 | Settings.GhostscriptPath = appVersion.Exe; 50 | Settings.SetGhostscriptVersion(appVersion.Version); 51 | } 52 | else 53 | { 54 | Settings.GhostscriptPath = "gs"; 55 | } 56 | 57 | //CbzDir 58 | if (!string.IsNullOrWhiteSpace(Settings.CbzDir)) 59 | { 60 | Settings.CbzDir.CreateDirIfNotExists(); 61 | } 62 | 63 | //SaveCover/SaveCoverOnly 64 | Settings.SaveCoverOnly = Settings.SaveCoverOnly && Settings.SaveCover; 65 | 66 | //SaveCoverDir 67 | if (Settings.SaveCover && !string.IsNullOrWhiteSpace(Settings.SaveCoverDir)) 68 | { 69 | Settings.SaveCoverDir.CreateDirIfNotExists(); 70 | } 71 | else 72 | { 73 | Settings.SaveCoverDir = null; 74 | } 75 | 76 | //MinimumDpi 77 | if (Settings.MinimumDpi <= 0) 78 | { 79 | Settings.MinimumDpi = 300; 80 | } 81 | 82 | //MinimumHeight 83 | if (Settings.MinimumHeight <= 0) 84 | { 85 | Settings.MinimumHeight = 1920; 86 | } 87 | 88 | //MaximumHeight 89 | if (Settings.MaximumHeight <= 0) 90 | { 91 | Settings.MaximumHeight = 3840; 92 | } 93 | 94 | //JpgQuality 95 | if (Settings.JpgQuality <= 0) 96 | { 97 | Settings.JpgQuality = 93; 98 | } 99 | 100 | //NumberOfThreads 101 | Settings.NumberOfThreads = _settingsHelper.GetThreadCount(Settings.NumberOfThreads); 102 | } 103 | 104 | public static AppVersion GetValidGhostscriptVersion(List gsVersions) 105 | { 106 | var gsVersion = gsVersions.OrderByDescending(gs => gs.Version).FirstOrDefault(); 107 | 108 | if (gsVersion == null || gsVersion.Version < Settings.GhostscriptMinVersion) 109 | { 110 | var foundVersion = gsVersion != null 111 | ? $". (found version {gsVersion.Version})" 112 | : string.Empty; 113 | 114 | throw new Exception($"PdfConvert requires Ghostscript version {Settings.GhostscriptMinVersion}+ is installed{foundVersion}"); 115 | 116 | } 117 | return gsVersion!; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Source/EpubConverter/EpubConverter.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Source/EpubConverter/EpubFileOrDirectoryConverter.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using CbzMage.Shared.Helpers; 3 | using CbzMage.Shared.Settings; 4 | using System.Diagnostics; 5 | 6 | namespace EpubConverter 7 | { 8 | public class EpubFileOrDirectoryConverter 9 | { 10 | private int _pagesCount = 0; 11 | 12 | public async Task ConvertFileOrDirectoryAsync(string path) 13 | { 14 | //path = @"C:\System\epub\KonoSuba TRPG [Yen Press][Kobo]"; 15 | //path = @"C:\System\epub\Bye Bye Birdie - Hughes, Shirley"; 16 | path = @"C:\System\epub\isabella1"; 17 | if (Directory.Exists(path)) 18 | { 19 | var epub = new Epub(path); 20 | var parser = new EpubParser(epub); 21 | await parser.ParseAsync(); 22 | return; 23 | } 24 | 25 | 26 | var config = new EpubConvertSettings(); 27 | config.CreateSettings(); 28 | 29 | if (!string.IsNullOrWhiteSpace(Settings.CbzDir)) 30 | { 31 | ProgressReporter.Info($"Cbz backups: {Settings.CbzDir}"); 32 | } 33 | if (Settings.GhostscriptVersion != null) 34 | { 35 | ProgressReporter.Info($"Ghostscript version: {Settings.GhostscriptVersion}"); 36 | } 37 | ProgressReporter.Info($"Ghostscript reader threads: {Settings.NumberOfThreads}"); 38 | ProgressReporter.Info($"Jpq quality: {Settings.JpgQuality}"); 39 | ProgressReporter.Info($"Cbz compression: {Settings.CompressionLevel}"); 40 | 41 | #if DEBUG 42 | ProgressReporter.Info($"{nameof(Settings.WriteBufferSize)}: {Settings.WriteBufferSize}"); 43 | ProgressReporter.Info($"{nameof(Settings.ImageBufferSize)}: {Settings.ImageBufferSize}"); 44 | #endif 45 | 46 | ProgressReporter.Line(); 47 | 48 | var pdfList = InitializeEpubPath(path); 49 | if (!pdfList.Any()) 50 | { 51 | ProgressReporter.Error("No epub files found"); 52 | return; 53 | } 54 | 55 | var stopwatch = Stopwatch.StartNew(); 56 | 57 | var converter = new ConverterEngine(); 58 | pdfList.ForEach(pdf => ConvertPdf(pdf, converter)); 59 | 60 | stopwatch.Stop(); 61 | 62 | var elapsed = stopwatch.Elapsed; 63 | var secsPerPage = elapsed.TotalSeconds / _pagesCount; 64 | 65 | ProgressReporter.Info($"{_pagesCount} pages converted in {elapsed.Hhmmss()} ({secsPerPage:F2} sec/page)"); 66 | } 67 | 68 | private void ConvertPdf(Epub epub, ConverterEngine converter) 69 | { 70 | var stopwatch = Stopwatch.StartNew(); 71 | 72 | // Throws if pdf is encrypted 73 | var epubParser = new EpubParser(epub); 74 | 75 | ProgressReporter.Info(epub.Path); 76 | ProgressReporter.Info($"{epub.PageList.Count} pages"); 77 | 78 | _pagesCount += epub.PageList.Count; 79 | 80 | converter.ConvertToCbz(epub, epubParser); 81 | 82 | stopwatch.Stop(); 83 | 84 | ProgressReporter.Info($"{stopwatch.Elapsed.Mmss()}"); 85 | ProgressReporter.Line(); 86 | } 87 | 88 | private static List InitializeEpubPath(string path) 89 | { 90 | SearchOption searchOption; 91 | 92 | if (string.IsNullOrEmpty(path)) 93 | { 94 | path = Environment.CurrentDirectory; 95 | searchOption = SearchOption.TopDirectoryOnly; 96 | } 97 | else 98 | { 99 | // Must run before before the checks for file/dir existance 100 | path = SharedSettings.GetDirectorySearchOption(path, out searchOption); 101 | } 102 | 103 | if (Directory.Exists(path)) 104 | { 105 | var files = Directory.GetFiles(path, "*.epub", searchOption); 106 | 107 | if (files.Length > 0) 108 | { 109 | return Epub.List(files.ToArray()); 110 | } 111 | } 112 | else if (File.Exists(path) && path.EndsWithIgnoreCase(".epub")) 113 | { 114 | return Epub.List(path); 115 | } 116 | 117 | //Nothing to do 118 | return Epub.List(); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Source/EpubConverter/EpubParser.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using System.Collections.Concurrent; 3 | using System.Text; 4 | 5 | namespace EpubConverter 6 | { 7 | public class EpubParser 8 | { 9 | private readonly Epub _epub; 10 | 11 | public EpubParser(Epub epub) 12 | { 13 | _epub = epub; 14 | } 15 | 16 | public async Task ParseAsync() 17 | { 18 | var allFiles = Directory.GetFiles(_epub.Path, "*", SearchOption.AllDirectories); 19 | 20 | var parallelFiles = allFiles.AsParallel(); 21 | 22 | var nav = FindMetadataFile(parallelFiles, "nav.xhtml", "nav.xhtml"); 23 | PrintMetaFile("nav", nav); 24 | 25 | if (nav != null) 26 | { 27 | _epub.NavXhtml = nav; 28 | } 29 | 30 | var opf = FindMetadataFile(parallelFiles, ".opf", "package.opf"); 31 | PrintMetaFile("opf", opf); 32 | 33 | void PrintMetaFile(string type, string? file) 34 | { 35 | Console.WriteLine($"{type}: {(file == null ? "not found" : file.Replace(_epub.Path, null))}"); 36 | } 37 | 38 | if (opf != null) 39 | { 40 | var opfReader = new OpfReader(); 41 | await opfReader.ReadOpfAsync(opf); 42 | 43 | _epub.PageList = opfReader.PageList; 44 | _epub.ImageList = opfReader.ImageList; 45 | } 46 | else 47 | { 48 | throw new Exception("No opf file"); 49 | 50 | GetFileLists(allFiles, out var xhtmlList, out var jpgList); 51 | 52 | _epub.PageList = xhtmlList; 53 | _epub.ImageList = jpgList; 54 | } 55 | 56 | var pageMap = new ConcurrentDictionary(); 57 | 58 | await Parallel.ForEachAsync(_epub.PageList, async (page, _) => 59 | { 60 | var pageFile = Path.Combine(_epub.Path, page); 61 | 62 | var xhtmlStr = await File.ReadAllTextAsync(pageFile, _); 63 | pageMap.TryAdd(page, xhtmlStr); 64 | }); 65 | 66 | } 67 | 68 | private static string? FindMetadataFile(ParallelQuery files, string endsWith, string defaultName) 69 | { 70 | string? metaFile = null; 71 | var metaFiles = files.Where(f => f.EndsWithIgnoreCase(endsWith)).AsList(); 72 | if (metaFiles.Count > 0) 73 | { 74 | if (metaFiles.Count == 1) 75 | { 76 | metaFile = metaFiles[0]; 77 | } 78 | else 79 | { 80 | metaFile = metaFiles.FirstOrDefault(f => Path.GetFileName(f).EqualsIgnoreCase(defaultName)); 81 | metaFile ??= metaFiles.Select(f => new FileInfo(f)).OrderByDescending(fi => fi.Length).First().FullName; 82 | } 83 | } 84 | 85 | return metaFile; 86 | } 87 | 88 | private static void GetFileLists(string[] allFiles, out List xhtmlList, out List jpgList) 89 | { 90 | xhtmlList = new List(); 91 | jpgList = new List(); 92 | 93 | foreach (var lookup in allFiles.ToLookup(f => Path.GetDirectoryName(f))) 94 | { 95 | var tmpXhtmlList = lookup.Where(f => f.EndsWithIgnoreCase(".xhtml") && !f.EndsWithIgnoreCase("nav.xhtml")).ToList(); 96 | if (tmpXhtmlList.Count > xhtmlList.Count) 97 | { 98 | xhtmlList = tmpXhtmlList; 99 | } 100 | 101 | var tmpJpgList = lookup.Where(f => f.EndsWithIgnoreCase(".jpg") || f.EndsWithIgnoreCase(".jpeg")).ToList(); 102 | if (tmpJpgList.Count > jpgList.Count) 103 | { 104 | jpgList = tmpJpgList; 105 | } 106 | } 107 | 108 | xhtmlList = SortFileList(xhtmlList); 109 | jpgList = SortFileList(jpgList); 110 | } 111 | 112 | private static List SortFileList(List fileList) 113 | { 114 | var sortData = new List<(string file, string baseFile, string? numStr, int? num)>(fileList.Count); 115 | 116 | var numBuilder = new StringBuilder(); 117 | 118 | foreach (var file in fileList) 119 | { 120 | var baseFile = Path.GetFileNameWithoutExtension(file); 121 | 122 | for (var i = baseFile.Length - 1; i >= 0; i--) 123 | { 124 | if (char.IsDigit(baseFile, i)) 125 | { 126 | numBuilder.Insert(0, baseFile[i]); 127 | } 128 | else 129 | { 130 | break; 131 | } 132 | } 133 | 134 | string? numStr = null; 135 | int? num = null; 136 | 137 | if (numBuilder.Length > 0) 138 | { 139 | numStr = numBuilder.ToString(); 140 | numBuilder.Length = 0; // Prepare builder for reuse 141 | 142 | num = int.Parse(numStr); 143 | baseFile = baseFile.Replace(numStr, null); 144 | } 145 | 146 | sortData.Add((file, baseFile, numStr, num)); 147 | } 148 | 149 | var sortedList = new List(fileList.Count); 150 | 151 | foreach (var sort in sortData.ToLookup(s => s.baseFile)) 152 | { 153 | var sortBatch = sort.ToList(); 154 | 155 | // Special case the cover 156 | if (sort.Key.EqualsIgnoreCase("cover") && sortBatch.Count == 1) 157 | { 158 | sortedList.Insert(0, sortBatch[0].file); 159 | continue; 160 | } 161 | 162 | if (sortBatch.Count > 1 && sortBatch[0].numStr != null) 163 | { 164 | var doSort = false; 165 | var numStrLen = sortBatch[0].numStr!.Length; 166 | 167 | for (int i = 1, sz = sortBatch.Count; i < sz; i++) 168 | { 169 | // page1.jpg vs page10.jpg vs page100.jpg 170 | if (sortBatch[i].numStr!.Length != numStrLen) 171 | { 172 | doSort = true; 173 | break; 174 | } 175 | } 176 | 177 | if (doSort) 178 | { 179 | sortBatch = sortBatch.OrderBy(s => s.num).ToList(); 180 | } 181 | } 182 | 183 | sortedList.AddRange(sortBatch.Select(s => s.file)); 184 | } 185 | 186 | // A last attempt to fix cover sorting 187 | if (!sortedList[0].ContainsIgnoreCase("cover")) 188 | { 189 | var swapIdx = -1; 190 | string? potentialCover = null; 191 | 192 | for (int i = 1, sz = sortedList.Count; i < sz; i++) 193 | { 194 | if (sortedList[i].ContainsIgnoreCase("cover")) 195 | { 196 | potentialCover = sortedList[i]; 197 | swapIdx = i; 198 | break; 199 | } 200 | } 201 | 202 | if (swapIdx != -1) 203 | { 204 | sortedList.RemoveAt(swapIdx); 205 | sortedList.Insert(0, potentialCover!); 206 | } 207 | } 208 | 209 | return sortedList; 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Source/EpubConverter/OpfReader.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using CbzMage.Shared.IO; 3 | using System.Xml; 4 | 5 | namespace EpubConverter 6 | { 7 | public class OpfReader 8 | { 9 | public List PageList { get; private set; } 10 | public List ImageList { get; private set; } 11 | 12 | private static XmlReaderSettings XmlReaderSettings => new() 13 | { 14 | IgnoreComments = true, 15 | IgnoreProcessingInstructions = true, 16 | IgnoreWhitespace = true, 17 | Async = true, 18 | }; 19 | 20 | public async Task ReadOpfAsync(string opfFile) 21 | { 22 | using var textReader = new StreamReader(AsyncStreams.AsyncFileReadStream(opfFile)); 23 | var xmlStr = await textReader.ReadToEndAsync(); 24 | 25 | var strReader = new StringReader(xmlStr); 26 | using var xmlReader = XmlReader.Create(strReader, XmlReaderSettings); 27 | 28 | var isManifest = false; 29 | var isSpine = false; 30 | 31 | var pageIdXhtmlMap = new Dictionary(); 32 | var pageList = new List(); 33 | 34 | var imageList = new List(); 35 | 36 | while (await xmlReader.ReadAsync()) 37 | { 38 | if (xmlReader.Name.EqualsIgnoreCase("manifest")) 39 | { 40 | isManifest = xmlReader.NodeType != XmlNodeType.EndElement; 41 | } 42 | else if (xmlReader.Name.EqualsIgnoreCase("spine")) 43 | { 44 | isSpine = xmlReader.NodeType != XmlNodeType.EndElement; 45 | } 46 | else if (isManifest) 47 | { 48 | var mediaType = xmlReader.GetAttribute("media-type"); 49 | if (mediaType != null) 50 | { 51 | var href = xmlReader.GetAttribute("href"); 52 | 53 | if (mediaType.StartsWithIgnoreCase("image/")) 54 | { 55 | if (href != null) 56 | { 57 | imageList.Add(href); 58 | } 59 | } 60 | else if (mediaType.EqualsIgnoreCase("application/xhtml+xml")) 61 | { 62 | var id = xmlReader.GetAttribute("id"); 63 | 64 | if (href != null && id != null) 65 | { 66 | pageIdXhtmlMap.Add(id, href); 67 | } 68 | } 69 | } 70 | } 71 | else if (isSpine) 72 | { 73 | var pageId = xmlReader.GetAttribute("idref"); 74 | var xhtmlHref = pageIdXhtmlMap[pageId!]; // throws if null 75 | 76 | pageList.Add(xhtmlHref); 77 | } 78 | } 79 | 80 | PageList = pageList; 81 | ImageList = imageList; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Source/EpubConverter/Settings.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Compression; 2 | 3 | namespace EpubConverter 4 | { 5 | public sealed class Settings 6 | { 7 | // All properties with a public setter are read from settings file 8 | 9 | public static string CbzDir { get; set; } 10 | 11 | public static bool SaveCover { get; set; } 12 | /// 13 | /// If this is true SaveCover is also true 14 | /// 15 | public static bool SaveCoverOnly { get; set; } 16 | public static string? SaveCoverDir { get; set; } 17 | 18 | public static int MinimumDpi { get; set; } 19 | 20 | public static int MinimumHeight { get; set; } 21 | 22 | public static int MaximumHeight { get; set; } 23 | 24 | public static int JpgQuality { get; set; } 25 | 26 | public static int NumberOfThreads { get; set; } 27 | 28 | public static CompressionLevel CompressionLevel { get; set; } 29 | 30 | public static int ImageBufferSize => 4194304 * 2; 31 | 32 | public static Version GhostscriptMinVersion => new(10, 0); 33 | 34 | public static string GhostscriptPath { get; set; } 35 | 36 | public static Version GhostscriptVersion { get; private set; } 37 | 38 | public static void SetGhostscriptVersion(Version version) 39 | { 40 | GhostscriptVersion = version; 41 | } 42 | 43 | public static int WriteBufferSize => 262144; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Source/PdfConverter/DpiCalculatedEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | public class DpiCalculatedEventArgs : EventArgs 4 | { 5 | public DpiCalculatedEventArgs(int dpi, int width, int height) 6 | { 7 | Dpi = dpi; 8 | 9 | Width = width; 10 | 11 | Height = height; 12 | } 13 | 14 | public int Dpi { get; private set; } 15 | 16 | public int Width { get; private set; } 17 | 18 | public int Height { get; private set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/PdfConverter/DpiCalculator.cs: -------------------------------------------------------------------------------- 1 | using ImageMagick; 2 | using PdfConverter.Ghostscript; 3 | 4 | namespace PdfConverter 5 | { 6 | public class DpiCalculator 7 | { 8 | private readonly GhostscriptPageMachine _ghostScriptPageMachine; 9 | 10 | private readonly int _wantedImageWidth; 11 | 12 | private readonly Pdf _pdf; 13 | 14 | public DpiCalculator(GhostscriptPageMachine ghostScriptPageMachine, Pdf pdf, int wantedImageWidth) 15 | { 16 | _ghostScriptPageMachine = ghostScriptPageMachine; 17 | 18 | _wantedImageWidth = wantedImageWidth; 19 | 20 | _pdf = pdf; 21 | } 22 | 23 | public int CalculateDpi() 24 | { 25 | var dpi = Settings.MinimumDpi; 26 | 27 | if (!TryGetImageWidth(dpi, out var width)) 28 | { 29 | return dpi; 30 | } 31 | 32 | if (width < _wantedImageWidth) 33 | { 34 | dpi = GoingUp(dpi, width, _wantedImageWidth); 35 | } 36 | 37 | return dpi; 38 | } 39 | 40 | private int GoingUp(int dpi, int width, int wantedWidth) 41 | { 42 | var diff = wantedWidth - width; 43 | 44 | // Skip big step calculation if difference is small 45 | if (diff <= 15) 46 | { 47 | var nextWidth = width; 48 | while (nextWidth < wantedWidth) 49 | { 50 | dpi++; 51 | 52 | if (!TryGetImageWidth(dpi, out nextWidth)) 53 | { 54 | return dpi; 55 | } 56 | } 57 | } 58 | else 59 | { 60 | var step = 5; 61 | dpi += step; 62 | 63 | if (!TryGetImageWidth(dpi, out var nextWidth)) 64 | { 65 | return dpi; 66 | } 67 | 68 | // Calculate big step if next width produced a large enough difference 69 | if (nextWidth < wantedWidth && wantedWidth - nextWidth > 15) 70 | { 71 | var nextDiff = wantedWidth - nextWidth; 72 | var bigStep = CalculateBigStep(diff, nextDiff, step); 73 | 74 | dpi = Settings.MinimumDpi + bigStep; 75 | 76 | if (!TryGetImageWidth(dpi, out nextWidth)) 77 | { 78 | return dpi; 79 | } 80 | } 81 | // Go down if big step put us above wanted width 82 | // Go up if after big step we're still below wanted width, or the calculation was skipped 83 | dpi = GoDownAndUp(dpi, nextWidth, wantedWidth); 84 | } 85 | 86 | return dpi; 87 | } 88 | 89 | private int GoDownAndUp(int dpi, int nextWidth, int wantedWidth) 90 | { 91 | while (nextWidth > wantedWidth && nextWidth - wantedWidth > 5) 92 | { 93 | dpi--; 94 | 95 | if (!TryGetImageWidth(dpi, out nextWidth)) 96 | { 97 | return dpi; 98 | } 99 | } 100 | 101 | while (nextWidth < wantedWidth) 102 | { 103 | dpi++; 104 | 105 | if (!TryGetImageWidth(dpi, out nextWidth)) 106 | { 107 | return dpi; 108 | } 109 | } 110 | 111 | return dpi; 112 | } 113 | 114 | private static int CalculateBigStep(int firstDiff, int nextDiff, int usedStep) 115 | { 116 | var interval = (double)firstDiff - nextDiff; 117 | var factor = firstDiff / interval; 118 | 119 | var bigStep = usedStep * factor; 120 | return Convert.ToInt32(bigStep); 121 | } 122 | 123 | private readonly List _warningsOrErrors = new(); 124 | private int _foundErrors = 0; 125 | 126 | public (int foundErrors, List warningsOrErrors) WarningsOrErrors => (_foundErrors, _warningsOrErrors); 127 | 128 | private bool TryGetImageWidth(int dpi, out int width) 129 | { 130 | var imageHandler = new SingleImageDataHandler(); 131 | 132 | using var gsRunner = _ghostScriptPageMachine.StartReadingPages(_pdf, new List { 1 }, dpi, imageHandler); 133 | 134 | var bufferWriter = imageHandler.WaitForImageDate(); 135 | 136 | using var image = new MagickImage(); 137 | image.Ping(bufferWriter.WrittenSpan); 138 | 139 | width = image.Width; 140 | var dpiHeight = image.Height; 141 | 142 | bufferWriter.Close(); 143 | 144 | DpiCalculated?.Invoke(this, new DpiCalculatedEventArgs(dpi, width, dpiHeight)); 145 | 146 | _foundErrors += gsRunner.WaitForExitCode(); 147 | _warningsOrErrors.AddRange(gsRunner.GetStandardErrorLines()); 148 | 149 | // Hard cap at the maximum height 150 | return dpiHeight <= Settings.MaximumHeight; 151 | } 152 | 153 | public event EventHandler DpiCalculated; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Source/PdfConverter/Exceptions/PdfEncryptedException.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter.Exceptions 2 | { 3 | public class PdfEncryptedException : Exception 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Source/PdfConverter/Exceptions/SomethingWentWrongSorryException.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter.Exceptions 2 | { 3 | public class SomethingWentWrongSorryException : ApplicationException 4 | { 5 | public SomethingWentWrongSorryException(string message) 6 | : base($"Sorry: {message}") 7 | { 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Source/PdfConverter/Ghostscript/GhostscriptImageStreamReader.cs: -------------------------------------------------------------------------------- 1 | using PdfConverter.Helpers; 2 | using CbzMage.Shared.Buffers; 3 | 4 | namespace PdfConverter.Ghostscript 5 | { 6 | public class GhostscriptImageStreamReader 7 | { 8 | private static readonly byte[] _pngHeader = new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; // PNG "\x89PNG\x0D\0xA\0x1A\0x0A" 9 | 10 | private readonly IImageDataHandler _imageDatahandler; 11 | 12 | private readonly Stream _stream; 13 | 14 | public GhostscriptImageStreamReader(Stream stream, IImageDataHandler imageDatahandler) 15 | { 16 | _stream = stream; 17 | _imageDatahandler = imageDatahandler; 18 | } 19 | 20 | public void StartReadingImages() 21 | { 22 | Task.Factory.StartNew(ReadGhostscriptOutput, TaskCreationOptions.LongRunning); 23 | } 24 | 25 | private void ReadGhostscriptOutput() 26 | { 27 | try 28 | { 29 | var currentBufferWriter = new ArrayPoolBufferWriter(Settings.ImageBufferSize); 30 | var firstImage = true; 31 | 32 | var offset = 0; 33 | int readCount; 34 | 35 | var pngHeaderSpan = _pngHeader.AsSpan(); 36 | 37 | while (true) 38 | { 39 | var span = currentBufferWriter.GetSpan(Settings.WriteBufferSize); 40 | 41 | readCount = _stream.Read(span); 42 | if (readCount == 0) 43 | { 44 | break; 45 | } 46 | 47 | LogRead(readCount); 48 | currentBufferWriter.Advance(readCount); 49 | 50 | // Image header is found at the start position of a read 51 | if (span.StartsWith(pngHeaderSpan)) 52 | { 53 | // Buffer contains a full image plus the first read of the next 54 | if (!firstImage) 55 | { 56 | // Create next buffer and copy next image bytes into it 57 | var nextBufferWriter = new ArrayPoolBufferWriter(Settings.ImageBufferSize); 58 | 59 | var data = currentBufferWriter.WrittenSpan.Slice(offset, readCount); 60 | var nextSpan = nextBufferWriter.GetSpan(data.Length); 61 | data.CopyTo(nextSpan); 62 | 63 | nextBufferWriter.Advance(data.Length); // Next buffer has first part of next image 64 | currentBufferWriter.Withdraw(data.Length); // Current buffer has current image 65 | 66 | _imageDatahandler.HandleRenderedImageData(currentBufferWriter); 67 | 68 | currentBufferWriter = nextBufferWriter; 69 | 70 | offset = readCount; // We already have readCount bytes in next buffer 71 | } 72 | else 73 | { 74 | // Keep reading if it's the first image 75 | firstImage = false; 76 | offset += readCount; 77 | } 78 | } 79 | else 80 | { 81 | offset += readCount; 82 | } 83 | } 84 | 85 | if (offset > 0) 86 | { 87 | _imageDatahandler.HandleRenderedImageData(currentBufferWriter); 88 | } 89 | 90 | // Signal we're done. 91 | _imageDatahandler.HandleRenderedImageData(null!); 92 | } 93 | finally 94 | { 95 | // Relying on the IDisposable pattern can cause a nullpointerexception 96 | // because the stream is ripped out right under the last read. 97 | _stream.Dispose(); 98 | } 99 | } 100 | 101 | private static void LogRead(int readCount) 102 | { 103 | #if DEBUG 104 | StatsCount.AddStreamRead(readCount); 105 | #endif 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Source/PdfConverter/Ghostscript/GhostscriptPageMachine.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Helpers; 2 | using System.Text; 3 | 4 | namespace PdfConverter.Ghostscript 5 | { 6 | public class GhostscriptPageMachine 7 | { 8 | private static string[] GetSwitches(string pdfFile, string pageList, int dpi) 9 | { 10 | var switches = new[] 11 | { 12 | //"-empty", wut? 13 | // "-dQUIET", handled by -q 14 | // "-dNOSAFER", not needed for gs 10.0 15 | "-dTextAlphaBits=1", // Turn off subsample antialiasing 16 | "-dGraphicsAlphaBits=1", // Turn off subsample antialiasing 17 | "-dUseCropBox", 18 | //"-dBATCH", handled by -o 19 | //"-dNOPAUSE", handled by -o 20 | "-dNOPROMPT", 21 | "-sDEVICE=png16m", 22 | //"-sDEVICE=png16malpha", causes inverted colors on editorial pages in many books 23 | //$"-dMaxBitmap={BufferSize}", this is for X only 24 | $"-sPageList={pageList}", 25 | $"-r{dpi}", 26 | $"-o-", // write image output to stdout 27 | "-q", // Don't write text output to stdout (and set -dQUIET) 28 | $"-f\"{pdfFile}\"", // -f skips a few filename checks 29 | //pdfFile 30 | }; 31 | 32 | return switches; 33 | } 34 | 35 | private static string CreatePageList(List pageNumbers) 36 | { 37 | var sb = new StringBuilder(); 38 | 39 | pageNumbers.ForEach(p => sb.Append(p).Append(',')); 40 | sb.Remove(sb.Length - 1, 1); 41 | 42 | return sb.ToString(); 43 | } 44 | 45 | public ProcessRunner StartReadingPages(Pdf pdf, List pageNumbers, int dpi, IImageDataHandler imageDataHandler) 46 | { 47 | var pageList = CreatePageList(pageNumbers); 48 | 49 | var gsPath = Settings.GhostscriptPath; 50 | var gsSwitches = GetSwitches(pdf.Path, pageList, dpi); 51 | 52 | var parameters = string.Join(' ', gsSwitches); 53 | var gsRunner = new ProcessRunner(gsPath, parameters); 54 | 55 | gsRunner.Run(); 56 | var stream = gsRunner.GetOutputStream(); 57 | 58 | var gsOutputReader = new GhostscriptImageStreamReader(stream, imageDataHandler); 59 | gsOutputReader.StartReadingImages(); 60 | 61 | return gsRunner; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Source/PdfConverter/Ghostscript/SingleImageDataHandler.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | using System.Collections.Concurrent; 3 | 4 | namespace PdfConverter.Ghostscript 5 | { 6 | public class SingleImageDataHandler : IImageDataHandler 7 | { 8 | private readonly BlockingCollection> _queue = new(); 9 | 10 | public ArrayPoolBufferWriter WaitForImageDate() 11 | { 12 | var buffer = _queue.Take(); 13 | 14 | _queue.Dispose(); 15 | 16 | return buffer; 17 | } 18 | 19 | public void HandleRenderedImageData(ArrayPoolBufferWriter image) 20 | { 21 | if (image == null) 22 | { 23 | return; 24 | } 25 | 26 | _queue.Add(image); 27 | } 28 | 29 | public void HandleSavedImageData(ArrayPoolBufferWriter image, string imageExt) 30 | { 31 | throw new NotImplementedException(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Source/PdfConverter/Helpers/StatsCount.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter.Helpers 2 | { 3 | public sealed class StatsCount 4 | { 5 | private static volatile int _largestPng = 0; 6 | private static long _totalPngSize = 0; 7 | 8 | private static volatile int _largestJpg = 0; 9 | private static long _totalJpgSize = 0; 10 | 11 | private static volatile int _largestStreamRead = 0; 12 | private static volatile int _streamReadCount = 0; 13 | 14 | public static void AddStreamRead(int read) 15 | { 16 | _streamReadCount++; 17 | 18 | if (read > _largestStreamRead) 19 | { 20 | _largestStreamRead = read; 21 | } 22 | } 23 | 24 | private static volatile int _totalConversionTime = 0; 25 | private static volatile int _imageConversionCount = 0; 26 | private static volatile int _imageResizeCount = 0; 27 | 28 | public static void AddImageConversion(int ms, bool resize, int png, int jpg) 29 | { 30 | _imageConversionCount++; 31 | 32 | if (resize) 33 | { 34 | _imageResizeCount++; 35 | } 36 | 37 | _totalConversionTime += ms; 38 | 39 | _totalPngSize += png; 40 | 41 | if (png > _largestPng) 42 | { 43 | _largestPng = png; 44 | } 45 | 46 | _totalJpgSize += jpg; 47 | 48 | if (jpg > _largestJpg) 49 | { 50 | _largestJpg = jpg; 51 | } 52 | } 53 | 54 | public static void ShowStats() 55 | { 56 | if (_streamReadCount > 0) 57 | { 58 | Console.WriteLine($"Stream reads: {_streamReadCount} Largest read: {_largestStreamRead} ({nameof(Settings.WriteBufferSize)}: {Settings.WriteBufferSize})"); 59 | } 60 | 61 | if (_imageConversionCount > 0) 62 | { 63 | Console.WriteLine($"Image conversions: {_imageConversionCount} (resizes: {_imageResizeCount}) Average ms: {_totalConversionTime / _imageConversionCount}"); 64 | Console.WriteLine($"Largest Png: {_largestPng} Average: {_totalPngSize / _imageConversionCount}"); 65 | Console.WriteLine($"Largest Jpg: {_largestJpg} Average: {_totalJpgSize / _imageConversionCount} ({nameof(Settings.ImageBufferSize)}: {Settings.ImageBufferSize})"); 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Source/PdfConverter/IImageDataHandler.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | 3 | namespace PdfConverter 4 | { 5 | public interface IImageDataHandler 6 | { 7 | // From Ghostscript 8 | void HandleRenderedImageData(ArrayPoolBufferWriter image); 9 | 10 | // From IText 11 | void HandleSavedImageData(ArrayPoolBufferWriter image, string imageExt); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Source/PdfConverter/ImageExt.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | /* itext code: 4 | return IdentifyImageType() switch 5 | { 6 | ImageType.PNG => "png", 7 | ImageType.JPEG => "jpg", 8 | ImageType.JPEG2000 => "jp2", 9 | ImageType.TIFF => "tif", 10 | ImageType.JBIG2 => "jbig2", 11 | _ => throw new InvalidOperationException("Should have never happened. This type of image is not allowed for ImageXObject"), 12 | }; 13 | */ 14 | internal class ImageExt 15 | { 16 | public const string Jpg = "jpg"; 17 | 18 | public const string Png = "png"; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/PdfConverter/ImageProducer/AbstractImageProducer.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter.ImageProducer 2 | { 3 | public abstract class AbstractImageProducer 4 | { 5 | public Pdf Pdf { get; private set; } 6 | 7 | public List PageList { get; private set; } 8 | 9 | protected bool IsStarted { get; set; } 10 | 11 | public AbstractImageProducer(Pdf pdf, List pageList) 12 | { 13 | Pdf = pdf; 14 | PageList = pageList; 15 | } 16 | 17 | protected void EnsureStarted() 18 | { 19 | if (!IsStarted) 20 | { 21 | throw new InvalidOperationException("Start() not called!"); 22 | } 23 | } 24 | 25 | public abstract void Start(IImageDataHandler imageDataHandler); 26 | 27 | public abstract ICollection GetErrors(); 28 | 29 | public abstract int WaitForExit(); 30 | 31 | public abstract void Dispose(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Source/PdfConverter/ImageProducer/GhostScriptImageProducer.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Helpers; 2 | using PdfConverter.Ghostscript; 3 | 4 | namespace PdfConverter.ImageProducer 5 | { 6 | public class GhostScriptImageProducer : AbstractImageProducer 7 | { 8 | private readonly int _dpi; 9 | 10 | public GhostScriptImageProducer(Pdf pdf, List pageList, int dpi) : base(pdf, pageList) 11 | { 12 | _dpi = dpi; 13 | } 14 | 15 | private ProcessRunner _gsRunner; 16 | 17 | public override void Start(IImageDataHandler imageDataHandler) 18 | { 19 | var pageMachine = new GhostscriptPageMachine(); 20 | _gsRunner = pageMachine.StartReadingPages(Pdf, PageList, _dpi, imageDataHandler); 21 | 22 | IsStarted = true; 23 | } 24 | 25 | public override ICollection GetErrors() 26 | { 27 | EnsureStarted(); 28 | return _gsRunner.GetStandardErrorLines(); 29 | } 30 | 31 | public override int WaitForExit() 32 | { 33 | EnsureStarted(); 34 | return _gsRunner.WaitForExitCode(); 35 | } 36 | 37 | public override void Dispose() 38 | { 39 | EnsureStarted(); 40 | _gsRunner.Dispose(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Source/PdfConverter/ImageProducer/ITextImageProducer.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | 3 | namespace PdfConverter.ImageProducer 4 | { 5 | public class ITextImageProducer : AbstractImageProducer 6 | { 7 | public ITextImageProducer(Pdf pdf, List pageNumbers) : base(pdf, pageNumbers) 8 | { 9 | } 10 | 11 | private PdfImageParser _pdfImageParser; 12 | 13 | public override void Start(IImageDataHandler imageDataHandler) 14 | { 15 | _pdfImageParser = new PdfImageParser(Pdf); 16 | _pdfImageParser.SavePdfImages(PageList, imageDataHandler); 17 | 18 | IsStarted = true; 19 | } 20 | 21 | public override ICollection GetErrors() 22 | { 23 | EnsureStarted(); 24 | return _pdfImageParser.GetImageParserErrors().Select(e => e.TypeAndMessage()).ToList(); 25 | } 26 | 27 | public override int WaitForExit() 28 | { 29 | EnsureStarted(); 30 | return _pdfImageParser.GetImageParserErrors().Count; 31 | } 32 | 33 | public override void Dispose() 34 | { 35 | EnsureStarted(); 36 | _pdfImageParser.Dispose(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Source/PdfConverter/Jobs/ImageCompressorJob.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | using CbzMage.Shared.Helpers; 3 | using CbzMage.Shared.JobQueue; 4 | using System.IO.Compression; 5 | 6 | namespace PdfConverter.Jobs 7 | { 8 | public class ImageCompressorJob : IJobConsumer> 9 | { 10 | private readonly ZipArchive? _compressor; 11 | 12 | private readonly List<(string page, ArrayPoolBufferWriter image)> _imageList; 13 | 14 | private readonly ProgressReporter _progressReporter; 15 | 16 | private readonly string? _coverFile; 17 | 18 | public ImageCompressorJob(ZipArchive? compressor, List<(string, ArrayPoolBufferWriter)> imageList, ProgressReporter progressReporter, string? coverFile = null) 19 | { 20 | _compressor = compressor; 21 | 22 | _imageList = imageList; 23 | 24 | _progressReporter = progressReporter; 25 | 26 | _coverFile = coverFile; 27 | } 28 | 29 | public async Task> ConsumeAsync() 30 | { 31 | var firstPage = true; 32 | 33 | foreach (var (page, bufferWriter) in _imageList) 34 | { 35 | var imageData = bufferWriter.WrittenMemory; 36 | 37 | if (firstPage) 38 | { 39 | if (_coverFile != null) 40 | { 41 | using var coverStream = new FileStream(_coverFile, FileMode.Create); 42 | await coverStream.WriteAsync(imageData); 43 | 44 | if (_compressor == null) 45 | { 46 | _progressReporter.ShowProgress($"Saved {page}"); 47 | } 48 | } 49 | firstPage = false; 50 | } 51 | 52 | if (_compressor != null) 53 | { 54 | var entry = _compressor.CreateEntry(page); 55 | 56 | using var cbzStream = entry.Open(); 57 | await cbzStream.WriteAsync(imageData); 58 | 59 | _progressReporter.ShowProgress($"Converted {page}"); 60 | } 61 | 62 | bufferWriter.Close(); 63 | } 64 | 65 | return _imageList.Select(x => x.page); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Source/PdfConverter/Jobs/ImageConverterJob.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.JobQueue; 4 | using ImageMagick; 5 | using PdfConverter.Helpers; 6 | using System.Diagnostics; 7 | 8 | namespace PdfConverter.Jobs 9 | { 10 | public class ImageConverterJob : IJobConsumer<(int pageNumber, ArrayPoolBufferWriter imageData, string imageExt)> 11 | { 12 | private readonly int _pageNumber; 13 | 14 | private readonly ArrayPoolBufferWriter _bufferWriter; 15 | 16 | private string _imageExt; 17 | 18 | private readonly int? _resizeHeight; 19 | 20 | public string SaveDir { get; set; } 21 | 22 | public ImageConverterJob(int pageNumber, ArrayPoolBufferWriter bufferWriter, string imageExt, int? resizeHeight) 23 | { 24 | _pageNumber = pageNumber; 25 | 26 | _bufferWriter = bufferWriter; 27 | 28 | _imageExt = imageExt; 29 | 30 | _resizeHeight = resizeHeight; 31 | } 32 | 33 | public Task<(int pageNumber, ArrayPoolBufferWriter imageData, string imageExt)> ConsumeAsync() 34 | { 35 | 36 | #if DEBUG 37 | var stopwatch = new Stopwatch(); 38 | stopwatch.Start(); 39 | #endif 40 | 41 | var pngSize = _bufferWriter.WrittenCount; 42 | using var image = new MagickImage(_bufferWriter.WrittenSpan); 43 | 44 | switch (_imageExt) 45 | { 46 | case ImageExt.Png: 47 | image.Format = MagickFormat.Png; 48 | image.Quality = 100; 49 | break; 50 | default: 51 | _imageExt = ImageExt.Jpg; 52 | 53 | // Produce baseline jpgs with no subsampling. 54 | image.Format = MagickFormat.Jpg; 55 | image.Quality = Settings.JpgQuality; 56 | break; 57 | } 58 | 59 | var resized = false; 60 | if (_resizeHeight.HasValue && image.Height > _resizeHeight.Value) 61 | { 62 | resized = true; 63 | 64 | image.Resize(new MagickGeometry 65 | { 66 | Greater = true, 67 | Less = false, 68 | Height = _resizeHeight.Value 69 | }); 70 | } 71 | 72 | // Reuse buffer for the converted image 73 | _bufferWriter.Reset(); 74 | image.Write(_bufferWriter); 75 | 76 | var jpgSize = _bufferWriter.WrittenCount; 77 | 78 | if (!string.IsNullOrEmpty(SaveDir)) 79 | { 80 | var page = _pageNumber.ToPageString(_imageExt); 81 | var pageFile = Path.Combine(SaveDir, page); 82 | 83 | File.WriteAllBytes(pageFile, _bufferWriter.WrittenSpan.ToArray()); 84 | } 85 | 86 | #if DEBUG 87 | stopwatch.Stop(); 88 | StatsCount.AddImageConversion((int)stopwatch.ElapsedMilliseconds, resized, pngSize, jpgSize); 89 | #endif 90 | return Task.FromResult((_pageNumber, _bufferWriter, _imageExt)); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Source/PdfConverter/PageChunker.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | public class PageChunker 4 | { 5 | public static List[] CreatePageLists(int numberOfPages, int numberOfChunks) 6 | { 7 | var pageLists = new List[numberOfChunks]; 8 | 9 | for (var i = 0; i < numberOfChunks; i++) 10 | { 11 | pageLists[i] = new List(); 12 | } 13 | 14 | for (var i = 0; i < numberOfPages; i++) 15 | { 16 | var page = i + 1; 17 | var index = i % numberOfChunks; 18 | 19 | pageLists[index].Add(page); 20 | } 21 | 22 | return pageLists; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Source/PdfConverter/PageCompressor.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.Helpers; 4 | using CbzMage.Shared.JobQueue; 5 | using PdfConverter.Jobs; 6 | using System.Collections.Concurrent; 7 | using System.IO.Compression; 8 | 9 | namespace PdfConverter 10 | { 11 | public class PageCompressor 12 | { 13 | private readonly Pdf _pdf; 14 | 15 | private readonly ConcurrentQueue _pageNumbers; 16 | 17 | // pageNumber, (imageData, imageExt) 18 | private readonly ConcurrentDictionary imageData, string imageExt)> _convertedPages; 19 | 20 | private readonly JobExecutor> _compressorExecutor; 21 | 22 | private readonly JobWaiter _jobWaiter; 23 | 24 | private readonly string _cbzFile; 25 | 26 | private readonly ZipArchive? _compressor; 27 | 28 | private readonly string? _coverFile; 29 | 30 | private bool _addedJob = false; 31 | 32 | private int _nextPageNumber; 33 | 34 | private readonly ProgressReporter _progressReporter; 35 | 36 | public PageCompressor(Pdf pdf, ConcurrentDictionary imageData, string imageExt)> convertedPages) 37 | { 38 | _pdf = pdf; 39 | _convertedPages = convertedPages; 40 | 41 | _pageNumbers = new ConcurrentQueue(Enumerable.Range(1, pdf.PageCount)); 42 | _pageNumbers.TryDequeue(out _nextPageNumber); 43 | 44 | _compressorExecutor = new JobExecutor>(); 45 | _compressorExecutor.JobExecuted += (s, e) => OnImagesCompressed(e); 46 | 47 | _jobWaiter = _compressorExecutor.Start(withWaiter: true); 48 | 49 | _cbzFile = CreateCbzFile(); 50 | _compressor = CreateCompressor(); 51 | _coverFile = CreateCoverFile(); 52 | 53 | _progressReporter = new ProgressReporter(pdf.PageCount); 54 | } 55 | 56 | private string CreateCbzFile() 57 | { 58 | var cbzFile = Path.ChangeExtension(_pdf.Path, ".cbz"); 59 | 60 | if (!string.IsNullOrEmpty(Settings.CbzDir)) 61 | { 62 | cbzFile = Path.Combine(Settings.CbzDir, Path.GetFileName(cbzFile)); 63 | } 64 | 65 | return cbzFile; 66 | } 67 | 68 | private ZipArchive? CreateCompressor() 69 | { 70 | if (Settings.SaveCoverOnly) 71 | { 72 | return null; 73 | } 74 | 75 | File.Delete(_cbzFile); 76 | 77 | ProgressReporter.Done(_cbzFile); 78 | 79 | return ZipFile.Open(_cbzFile, ZipArchiveMode.Create); 80 | } 81 | 82 | private string? CreateCoverFile() 83 | { 84 | if (!Settings.SaveCover) 85 | { 86 | return null; 87 | } 88 | 89 | var coverFile = _cbzFile; // The .cbz extension is changed in AddCompressorJob 90 | 91 | if (!string.IsNullOrEmpty(Settings.SaveCoverDir)) 92 | { 93 | coverFile = Path.Combine(Settings.SaveCoverDir, Path.GetFileName(coverFile)); 94 | } 95 | 96 | if (Settings.SaveCoverOnly) 97 | { 98 | ProgressReporter.Done(coverFile); 99 | } 100 | 101 | return coverFile; 102 | } 103 | 104 | public void WaitForPagesCompressed() 105 | { 106 | _jobWaiter.WaitForJobsToFinish(); 107 | _compressor?.Dispose(); 108 | } 109 | 110 | public void OnPageConverted(PageConvertedEventArgs _) 111 | { 112 | if (!_addedJob) 113 | { 114 | _addedJob = AddCompressorJob(); 115 | } 116 | } 117 | 118 | public void SignalAllPagesConverted() 119 | { 120 | AddCompressorJob(); 121 | 122 | _compressorExecutor.Stop(); 123 | } 124 | 125 | private void OnImagesCompressed(JobEventArgs> eventArgs) 126 | { 127 | PagesCompressed?.Invoke(this, new PagesCompressedEventArgs(eventArgs.Result)); 128 | 129 | _addedJob = AddCompressorJob(); 130 | } 131 | 132 | public event EventHandler PagesCompressed; 133 | 134 | private bool AddCompressorJob() 135 | { 136 | var firstPage = _nextPageNumber == 1; 137 | 138 | var imageList = new List<(string page, ArrayPoolBufferWriter imageData)>(); 139 | 140 | string coverFile = null; 141 | 142 | while (_convertedPages.TryRemove(_nextPageNumber, out var imageInfo)) 143 | { 144 | var page = _nextPageNumber.ToPageString(imageInfo.imageExt); 145 | 146 | imageList.Add((page, imageInfo.imageData)); 147 | 148 | if (firstPage) 149 | { 150 | if (_coverFile != null) 151 | { 152 | coverFile = Path.ChangeExtension(_coverFile, imageInfo.imageExt); 153 | } 154 | firstPage = false; 155 | } 156 | 157 | _pageNumbers.TryDequeue(out _nextPageNumber); 158 | } 159 | 160 | if (imageList.Count > 0) 161 | { 162 | var job = new ImageCompressorJob(_compressor, imageList, _progressReporter, coverFile); 163 | _compressorExecutor.AddJob(job); 164 | 165 | return true; 166 | } 167 | return false; 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Source/PdfConverter/PageConvertedEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | public class PageConvertedEventArgs : EventArgs 4 | { 5 | public PageConvertedEventArgs(int pageNumber) 6 | { 7 | PageNumber = pageNumber; 8 | } 9 | 10 | public int PageNumber { get; private set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Source/PdfConverter/PageConverter.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.JobQueue; 4 | using PdfConverter.Exceptions; 5 | using PdfConverter.Jobs; 6 | using System.Collections.Concurrent; 7 | 8 | namespace PdfConverter 9 | { 10 | public class PageConverter : IImageDataHandler 11 | { 12 | private readonly JobExecutor<(int pageNumber, ArrayPoolBufferWriter imageData, string imageExt)> _converterExecutor; 13 | private readonly JobWaiter _jobWaiter; 14 | 15 | private readonly Queue _pageQueue; 16 | private readonly ConcurrentDictionary imageData, string imageExt)> _convertedPages; 17 | 18 | private readonly int? _resizeHeight; 19 | 20 | public PageConverter(Queue pageQueue, ConcurrentDictionary imageData, string imageExt)> convertedPages, int? resizeHeight) 21 | { 22 | _pageQueue = pageQueue; 23 | 24 | _convertedPages = convertedPages; 25 | 26 | _resizeHeight = resizeHeight; 27 | 28 | _converterExecutor = new JobExecutor<(int pageNumber, ArrayPoolBufferWriter imageData, string imageExt)>(); 29 | _converterExecutor.JobExecuted += (s, e) => OnImageConverted(e); 30 | 31 | _jobWaiter = _converterExecutor.Start(withWaiter: true); 32 | } 33 | 34 | public void WaitForPagesConverted() => _jobWaiter.WaitForJobsToFinish(); 35 | 36 | // Handle png/jpg (possible other types) imagedata from Itext saving original pdf images. 37 | public void HandleSavedImageData(ArrayPoolBufferWriter bufferWriter, string imageExt) 38 | { 39 | if (bufferWriter == null) 40 | { 41 | _converterExecutor.Stop(); 42 | return; 43 | } 44 | 45 | var pageNumber = _pageQueue.Dequeue(); 46 | 47 | // It makes no sense to convert jpg images 48 | if (imageExt == ImageExt.Jpg) 49 | { 50 | OnImageConverted(new JobEventArgs<(int pageNumber, ArrayPoolBufferWriter imageData, string imageExt)>((pageNumber, bufferWriter, imageExt))); 51 | return; 52 | } 53 | 54 | // But it does makes sense to recompress png images as much as possible. 55 | // Converter logic is png -> recompress, everything else -> convert to jpg. 56 | var job = new ImageConverterJob(pageNumber, bufferWriter, imageExt, null); 57 | _converterExecutor.AddJob(job); 58 | } 59 | 60 | // Handle png imagedata from Ghostscript rendering a pdf. 61 | public void HandleRenderedImageData(ArrayPoolBufferWriter bufferWriter) 62 | { 63 | if (bufferWriter == null) 64 | { 65 | _converterExecutor.Stop(); 66 | return; 67 | } 68 | 69 | var pageNumber = _pageQueue.Dequeue(); 70 | 71 | // Tell converter to convert to jpg and resize if needed. 72 | var job = new ImageConverterJob(pageNumber, bufferWriter, ImageExt.Jpg, _resizeHeight); 73 | _converterExecutor.AddJob(job); 74 | } 75 | 76 | private void OnImageConverted(JobEventArgs<(int pageNumber, ArrayPoolBufferWriter imageData, string imageExt)> eventArgs) 77 | { 78 | var (pageNumber, imageData, imageExt) = eventArgs.Result; 79 | 80 | if (!_convertedPages.TryAdd(pageNumber, (imageData, imageExt))) 81 | { 82 | throw new SomethingWentWrongSorryException($"{pageNumber.ToPageString()} already converted?"); 83 | } 84 | 85 | PageConverted?.Invoke(this, new PageConvertedEventArgs(pageNumber)); 86 | } 87 | 88 | public event EventHandler PageConverted; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Source/PdfConverter/PageParsedEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | public class PageParsedEventArgs : EventArgs 4 | { 5 | public PageParsedEventArgs(int currentPage) 6 | { 7 | CurrentPage = currentPage; 8 | } 9 | 10 | public int CurrentPage { get; private set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Source/PdfConverter/PagesCompressedEventArgs.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | public class PagesCompressedEventArgs : EventArgs 4 | { 5 | public PagesCompressedEventArgs(IEnumerable pages) 6 | { 7 | Pages = pages; 8 | } 9 | 10 | public IEnumerable Pages { get; private set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Source/PdfConverter/Pdf.cs: -------------------------------------------------------------------------------- 1 | namespace PdfConverter 2 | { 3 | public sealed class Pdf 4 | { 5 | public Pdf(string path) 6 | { 7 | Path = path; 8 | } 9 | 10 | public string Path { get; private set; } 11 | 12 | public int PageCount { get; set; } 13 | 14 | public int ImageCount { get; set; } 15 | 16 | public static List List(params string[] paths) => new(paths.Select(x => new Pdf(x))); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Source/PdfConverter/PdfConvertSettings.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.AppVersions; 2 | using CbzMage.Shared.Extensions; 3 | using CbzMage.Shared.Helpers; 4 | using CbzMage.Shared.Settings; 5 | using System.Diagnostics; 6 | 7 | namespace PdfConverter 8 | { 9 | public class PdfConvertSettings 10 | { 11 | public static Settings Settings => new(); 12 | 13 | private readonly SharedSettings _settingsHelper = new(); 14 | 15 | public void CreateSettings() 16 | { 17 | _settingsHelper.CreateSettings(nameof(PdfConvertSettings), Settings); 18 | 19 | ConfigureSettings(); 20 | } 21 | 22 | private void ConfigureSettings() 23 | { 24 | if (!string.IsNullOrEmpty(Settings.GhostscriptPath)) 25 | { 26 | if (File.Exists(Settings.GhostscriptPath)) 27 | { 28 | var version = FileVersionInfo.GetVersionInfo(Settings.GhostscriptPath).FileVersion; 29 | if (version == null) 30 | { 31 | ProgressReporter.Warning($"{Settings.GhostscriptPath} does not contain any version information."); 32 | } 33 | else 34 | { 35 | var appVersion = new AppVersion(Settings.GhostscriptPath, new Version(version)); 36 | 37 | // Throws if version is not valid 38 | appVersion = GetValidGhostscriptVersion(new List { appVersion }); 39 | 40 | Settings.SetGhostscriptVersion(appVersion.Version); 41 | } 42 | } 43 | } 44 | else if (Environment.OSVersion.Platform == PlatformID.Win32NT) 45 | { 46 | var versionList = AppVersionManager.GetInstalledVersionsOf(App.Ghostscript); 47 | var appVersion = GetValidGhostscriptVersion(versionList); 48 | 49 | Settings.GhostscriptPath = appVersion.Exe; 50 | Settings.SetGhostscriptVersion(appVersion.Version); 51 | } 52 | else 53 | { 54 | Settings.GhostscriptPath = "gs"; 55 | } 56 | 57 | // CbzDir 58 | if (!string.IsNullOrWhiteSpace(Settings.CbzDir)) 59 | { 60 | Settings.CbzDir.CreateDirIfNotExists(); 61 | } 62 | 63 | // SaveCover/SaveCoverOnly 64 | Settings.SaveCoverOnly = Settings.SaveCoverOnly && Settings.SaveCover; 65 | 66 | // SaveCoverDir 67 | if (Settings.SaveCover && !string.IsNullOrWhiteSpace(Settings.SaveCoverDir)) 68 | { 69 | Settings.SaveCoverDir.CreateDirIfNotExists(); 70 | } 71 | else 72 | { 73 | Settings.SaveCoverDir = null; 74 | } 75 | 76 | // MinimumDpi 77 | if (Settings.MinimumDpi <= 0) 78 | { 79 | Settings.MinimumDpi = 300; 80 | } 81 | 82 | // MinimumHeight 83 | if (Settings.MinimumHeight <= 0) 84 | { 85 | Settings.MinimumHeight = 1920; 86 | } 87 | 88 | // MaximumHeight 89 | if (Settings.MaximumHeight <= 0) 90 | { 91 | Settings.MaximumHeight = 3840; 92 | } 93 | 94 | // JpgQuality 95 | if (Settings.JpgQuality <= 0) 96 | { 97 | Settings.JpgQuality = 93; 98 | } 99 | 100 | // NumberOfThreads 101 | Settings.NumberOfThreads = _settingsHelper.GetThreadCount(Settings.NumberOfThreads); 102 | } 103 | 104 | public static AppVersion GetValidGhostscriptVersion(List gsVersions) 105 | { 106 | var gsVersion = gsVersions.OrderByDescending(gs => gs.Version).FirstOrDefault(); 107 | 108 | if (gsVersion == null || gsVersion.Version < Settings.GhostscriptMinVersion) 109 | { 110 | var foundVersion = gsVersion != null 111 | ? $". (found version {gsVersion.Version})" 112 | : string.Empty; 113 | 114 | throw new Exception($"PdfConvert requires Ghostscript version {Settings.GhostscriptMinVersion}+ is installed{foundVersion}"); 115 | 116 | } 117 | return gsVersion!; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Source/PdfConverter/PdfConvertSettings.json: -------------------------------------------------------------------------------- 1 | // Punction is important! Don't remove quotes, colons, commas or square brackets 2 | // (or otherwise change the structure). Back slashes in directory paths must be 3 | // quoted like this: "C:\\System\\Apps\\Ghostscript\\gswin64c.exe" 4 | { 5 | // 6 | // Settings recognized by the PdfConvert command. 7 | // 8 | 9 | // PdfConvert requires Ghostscript version 10 or later. If you don't specify the 10 | // path to the Ghostscript excutable, CbzMage will try to detect a valid 11 | // installation on Windows. On Linux/macOS it will simply try to use "gs". 12 | "GhostscriptPath": "", 13 | 14 | // CbzDir is where your cbz backups are created. This can be placed anywhere 15 | // you want. If you specify nothing the default for PdfConvert is to create the 16 | // cbz file alongside the original pdf. 17 | "CbzDir": "", 18 | 19 | // SaveCover true or false. Save a copy of the coverimage alongside the cbz file 20 | // when converting a title. The default value is false, don't save the cover. 21 | "SaveCover": false, 22 | 23 | // If you would like to save the coverimages in their own directory instead of 24 | // alongside the cbz file you can specify the directory here. 25 | "SaveCoverDir": "", 26 | 27 | // SaveCoverOnly true or false. Use this if you just want to save coverimages, 28 | // no cbz files. Note: SaveCover must also be true for SaveCoverOnly to work. 29 | "SaveCoverOnly": false, 30 | 31 | // This determines the quality of jpg images in the cbz file. You want this as 32 | // high as possible (90+). The default quality quality is 93 (95 creates images 33 | // that are 15% larger. 98 creates images that are 50% larger). 34 | "JpgQuality": 93, 35 | 36 | // MinimumDpi determines the minimum quality of the pages Ghostscript reads from 37 | // the pdf. It's not recommended to set this lower than 300, and setting it higher 38 | // can cause the cbz files to grow very large. In other words, you probably 39 | // shouldn't touch this. 40 | "MinimumDpi": 300, 41 | 42 | // Sometimes reading pages using the MinimumDpi setting will create images that 43 | // are much larger than in the source pdf. CbzMage will detect this and scale the 44 | // images down to prevent the cbz file from growing too large - but it will not 45 | // create images smaller than the MinimumHeight setting. Default is 1920 (HD). 46 | "MinimumHeight": 1920, 47 | 48 | // MaximumHeight is a hard cap at imagezize, again to prevent cbz files from 49 | // growing impossibly large. The default is 3840 (Ultra HD). 50 | "MaximumHeight": 3840, 51 | 52 | // Speed up conversion by doing it in parallel. Set this to 0 to have CbzMage try 53 | // to figure out a good value. If memory usage is too high try setting this to a 54 | // lower value than the calculated. 55 | "NumberOfThreads": 0, 56 | 57 | // Compression level for the cbz file. Valid options are "Fastest", "Optimal" or 58 | // "NoCompression". The default is "Fastest" because jpg files in the cbz file are 59 | // already compressed so "Optimal" gives very little difference in archive size. 60 | "CompressionLevel": "Fastest" 61 | } 62 | -------------------------------------------------------------------------------- /Source/PdfConverter/PdfConverter.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Library 5 | net6.0 6 | enable 7 | enable 8 | True 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Always 28 | 29 | 30 | Always 31 | 32 | 33 | Always 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Source/PdfConverter/PdfFileOrDirectoryConverter.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Extensions; 2 | using CbzMage.Shared.Helpers; 3 | using CbzMage.Shared.Settings; 4 | using PdfConverter.Exceptions; 5 | using PdfConverter.Helpers; 6 | using System.Diagnostics; 7 | 8 | namespace PdfConverter 9 | { 10 | public class PdfFileOrDirectoryConverter 11 | { 12 | private int _pagesCount = 0; 13 | 14 | public void ConvertFileOrDirectory(string path) 15 | { 16 | var config = new PdfConvertSettings(); 17 | config.CreateSettings(); 18 | 19 | if (!string.IsNullOrWhiteSpace(Settings.CbzDir)) 20 | { 21 | ProgressReporter.Info($"Cbz backups: {Settings.CbzDir}"); 22 | } 23 | if (Settings.GhostscriptVersion != null) 24 | { 25 | ProgressReporter.Info($"Ghostscript version: {Settings.GhostscriptVersion}"); 26 | } 27 | ProgressReporter.Info($"Ghostscript reader threads: {Settings.NumberOfThreads}"); 28 | ProgressReporter.Info($"Jpq quality: {Settings.JpgQuality}"); 29 | ProgressReporter.Info($"Cbz compression: {Settings.CompressionLevel}"); 30 | 31 | #if DEBUG 32 | ProgressReporter.Info($"{nameof(Settings.WriteBufferSize)}: {Settings.WriteBufferSize}"); 33 | ProgressReporter.Info($"{nameof(Settings.ImageBufferSize)}: {Settings.ImageBufferSize}"); 34 | #endif 35 | 36 | ProgressReporter.Line(); 37 | 38 | var pdfList = InitializePdfPath(path); 39 | if (!pdfList.Any()) 40 | { 41 | ProgressReporter.Error("No pdf files found"); 42 | return; 43 | } 44 | 45 | var stopwatch = Stopwatch.StartNew(); 46 | 47 | var converter = new ConverterEngine(); 48 | pdfList.ForEach(pdf => ConvertPdf(pdf, converter)); 49 | 50 | #if DEBUG 51 | StatsCount.ShowStats(); 52 | ProgressReporter.Line(); 53 | #endif 54 | 55 | stopwatch.Stop(); 56 | 57 | var elapsed = stopwatch.Elapsed; 58 | var secsPerPage = elapsed.TotalSeconds / _pagesCount; 59 | 60 | ProgressReporter.Info($"{_pagesCount} pages converted in {elapsed.Hhmmss()} ({secsPerPage:F2} sec/page)"); 61 | } 62 | 63 | private void ConvertPdf(Pdf pdf, ConverterEngine converter) 64 | { 65 | var stopwatch = Stopwatch.StartNew(); 66 | 67 | ProgressReporter.Info(pdf.Path); 68 | 69 | try 70 | { 71 | using var pdfParser = new PdfImageParser(pdf); 72 | 73 | ProgressReporter.Info($"{pdf.PageCount} pages"); 74 | _pagesCount += pdf.PageCount; 75 | 76 | converter.ConvertToCbz(pdf, pdfParser); 77 | } 78 | catch (PdfEncryptedException e) 79 | { 80 | ProgressReporter.Error($"Error reading [{pdf.Path}] pdf is encrypted"); 81 | } 82 | catch (Exception e) 83 | { 84 | ProgressReporter.Error($"Error reading [{pdf.Path}] {e.TypeAndMessage()}"); 85 | } 86 | 87 | stopwatch.Stop(); 88 | ProgressReporter.Info($"{stopwatch.Elapsed.Mmss()}"); 89 | 90 | ProgressReporter.Line(); 91 | } 92 | 93 | private static List InitializePdfPath(string path) 94 | { 95 | SearchOption searchOption; 96 | 97 | if (string.IsNullOrEmpty(path)) 98 | { 99 | path = Environment.CurrentDirectory; 100 | searchOption = SearchOption.TopDirectoryOnly; 101 | } 102 | else 103 | { 104 | // Must run before before the checks for file/dir existance 105 | path = SharedSettings.GetDirectorySearchOption(path, out searchOption); 106 | } 107 | 108 | if (Directory.Exists(path)) 109 | { 110 | var files = Directory.GetFiles(path, "*.pdf", searchOption); 111 | 112 | if (files.Length > 0) 113 | { 114 | return Pdf.List(files.ToArray()); 115 | } 116 | } 117 | else if (File.Exists(path) && path.EndsWithIgnoreCase(".pdf")) 118 | { 119 | return Pdf.List(path); 120 | } 121 | 122 | // Nothing to do 123 | return Pdf.List(); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Source/PdfConverter/PdfImageParser.cs: -------------------------------------------------------------------------------- 1 | using CbzMage.Shared.Buffers; 2 | using CbzMage.Shared.Extensions; 3 | using iText.Kernel.Pdf; 4 | using iText.Kernel.Pdf.Canvas.Parser; 5 | using iText.Kernel.Pdf.Canvas.Parser.Data; 6 | using iText.Kernel.Pdf.Canvas.Parser.Listener; 7 | using PdfConverter.Exceptions; 8 | using System.Buffers; 9 | 10 | namespace PdfConverter 11 | { 12 | public class PdfImageParser : IEventListener, IDisposable 13 | { 14 | // Largest image on a given page 15 | private readonly Dictionary _imageMap; 16 | 17 | private readonly List _imageParserErrors; 18 | 19 | private readonly Pdf _pdfComic; 20 | 21 | private readonly PdfReader _pdfReader; 22 | private readonly PdfDocument _pdfDoc; 23 | 24 | private int _pageNumber; 25 | private int _imageCount; 26 | 27 | private bool _pdfContainsRenderedText = false; 28 | 29 | private ICollection _supportedEvents; 30 | 31 | private enum ImageMode 32 | { 33 | Parse, Save 34 | } 35 | 36 | private ImageMode _imageMode; 37 | 38 | public PdfImageParser(Pdf pdfComic) 39 | { 40 | _pdfComic = pdfComic; 41 | 42 | _pdfReader = new PdfReader(_pdfComic.Path); 43 | _pdfDoc = new PdfDocument(_pdfReader); 44 | 45 | if (_pdfReader.IsEncrypted()) 46 | { 47 | throw new PdfEncryptedException(); 48 | } 49 | 50 | _pdfComic.PageCount = _pdfDoc.GetNumberOfPages(); 51 | 52 | _imageParserErrors = new List(); 53 | 54 | _imageMap = new Dictionary(); 55 | } 56 | 57 | public List<(int width, int height, int count)> ParseImages() 58 | { 59 | _supportedEvents = new[] { EventType.RENDER_IMAGE }; 60 | 61 | _imageMode = ImageMode.Parse; 62 | 63 | if (_pdfComic.PageCount == 0) 64 | { 65 | throw new ApplicationException("Comic pageCount is 0"); 66 | } 67 | 68 | var pdfDocParser = new PdfDocumentContentParser(_pdfDoc); 69 | 70 | for (_pageNumber = 1; _pageNumber <= _pdfComic.PageCount; _pageNumber++) 71 | { 72 | pdfDocParser.ProcessContent(_pageNumber, this); 73 | 74 | // Handle pages with no images 75 | if (!_imageMap.ContainsKey(_pageNumber)) 76 | { 77 | _imageMap[_pageNumber] = (0, 0); 78 | } 79 | 80 | PageParsed?.Invoke(this, new PageParsedEventArgs(_pageNumber)); 81 | } 82 | _pdfComic.ImageCount = _imageCount; 83 | 84 | if (_imageMap.Count != _pdfComic.PageCount) 85 | { 86 | throw new ApplicationException($"{nameof(_imageMap)} is {_imageMap.Count} should be {_pdfComic.PageCount}"); 87 | } 88 | 89 | var imageSizesMap = BuildImageSizesMap(); 90 | var sortedImagesList = imageSizesMap.Values.OrderByDescending(x => x.count).AsList(); 91 | 92 | var pageSum = sortedImagesList.Sum(i => i.count); 93 | if (pageSum != _pdfComic.PageCount) 94 | { 95 | throw new ApplicationException($"{nameof(sortedImagesList)} pageSum {pageSum} should be {_pdfComic.PageCount}"); 96 | } 97 | 98 | return sortedImagesList; 99 | } 100 | 101 | public List GetImageParserErrors() => _imageParserErrors; 102 | 103 | private Dictionary BuildImageSizesMap() 104 | { 105 | var imageSizesMap = new Dictionary(); 106 | 107 | foreach (var (width, height) in _imageMap.Values) 108 | { 109 | var key = $"{width} x {height}"; 110 | 111 | var count = imageSizesMap.TryGetValue(key, out var existingImageSize) 112 | ? existingImageSize.count + 1 113 | : 1; 114 | 115 | imageSizesMap[key] = (width, height, count); 116 | } 117 | 118 | return imageSizesMap; 119 | } 120 | 121 | public event EventHandler PageParsed; 122 | 123 | public void EventOccurred(IEventData data, EventType type) 124 | { 125 | switch (type) 126 | { 127 | case EventType.RENDER_IMAGE: 128 | if (_imageMode == ImageMode.Save) 129 | { 130 | SaveImage((ImageRenderInfo)data); 131 | } 132 | else 133 | { 134 | ParseImage((ImageRenderInfo)data); 135 | } 136 | break; 137 | case EventType.RENDER_TEXT: 138 | _pdfContainsRenderedText = true; 139 | break; 140 | } 141 | } 142 | 143 | private void ParseImage(ImageRenderInfo renderInfo) 144 | { 145 | try 146 | { 147 | var imageObject = renderInfo.GetImage(); 148 | 149 | var newWidth = Convert.ToInt32(imageObject.GetWidth()); 150 | var newHeight = Convert.ToInt32(imageObject.GetHeight()); 151 | 152 | // We want the largest image on any given page. 153 | if (!_imageMap.TryGetValue(_pageNumber, out var page) || (newWidth * newHeight > page.width * page.height)) 154 | { 155 | _imageMap[_pageNumber] = (newWidth, newHeight); 156 | } 157 | 158 | _imageCount++; 159 | } 160 | catch (Exception ex) 161 | { 162 | _imageParserErrors.Add(ex); 163 | } 164 | } 165 | 166 | private void SaveImage(ImageRenderInfo renderInfo) 167 | { 168 | try 169 | { 170 | var imageObject = renderInfo.GetImage(); 171 | 172 | var imageBytes = imageObject.GetImageBytes(decoded: true); 173 | var imageExt = imageObject.IdentifyImageFileExtension(); 174 | 175 | var bufferWriter = new ArrayPoolBufferWriter(); 176 | bufferWriter.Write(imageBytes); 177 | 178 | _imageDataHandler.HandleSavedImageData(bufferWriter, imageExt); 179 | } 180 | catch (Exception ex) 181 | { 182 | _imageParserErrors.Add(ex); 183 | } 184 | } 185 | 186 | public ICollection GetSupportedEvents() => _supportedEvents; 187 | 188 | public bool DetectRenderedText() 189 | { 190 | _supportedEvents = new[] { EventType.RENDER_TEXT }; 191 | 192 | var pdfDocParser = new PdfDocumentContentParser(_pdfDoc); 193 | 194 | for (_pageNumber = 1; _pageNumber <= _pdfComic.PageCount; _pageNumber++) 195 | { 196 | pdfDocParser.ProcessContent(_pageNumber, this); 197 | 198 | if (_pdfContainsRenderedText) 199 | { 200 | return true; 201 | } 202 | } 203 | 204 | return false; 205 | } 206 | 207 | private IImageDataHandler _imageDataHandler; 208 | 209 | public void SavePdfImages(List pageList, IImageDataHandler imageDataHandler) 210 | { 211 | _imageDataHandler = imageDataHandler; 212 | 213 | _supportedEvents = new[] { EventType.RENDER_IMAGE }; 214 | 215 | _imageMode = ImageMode.Save; 216 | 217 | var pdfDocParser = new PdfDocumentContentParser(_pdfDoc); 218 | 219 | for (int i = 0, sz = pageList.Count; i < sz; i++) 220 | { 221 | _pageNumber = pageList[i]; 222 | 223 | pdfDocParser.ProcessContent(_pageNumber, this); 224 | } 225 | 226 | imageDataHandler.HandleSavedImageData(null!, "bye"); 227 | } 228 | 229 | 230 | #region Dispose 231 | 232 | private bool disposedValue; 233 | 234 | protected virtual void Dispose(bool disposing) 235 | { 236 | if (!disposedValue) 237 | { 238 | if (disposing) 239 | { 240 | _pdfReader?.Close(); 241 | _pdfDoc?.Close(); 242 | } 243 | 244 | // TODO: free unmanaged resources (unmanaged objects) and override finalizer 245 | // TODO: set large fields to null 246 | disposedValue = true; 247 | } 248 | } 249 | 250 | // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources 251 | // ~PdfImageParser() 252 | // { 253 | // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 254 | // Dispose(disposing: false); 255 | // } 256 | 257 | public void Dispose() 258 | { 259 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 260 | Dispose(disposing: true); 261 | GC.SuppressFinalize(this); 262 | } 263 | 264 | #endregion 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /Source/PdfConverter/Settings.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Compression; 2 | 3 | namespace PdfConverter 4 | { 5 | public sealed class Settings 6 | { 7 | // All properties with a public setter are read from settings file 8 | 9 | public static string CbzDir { get; set; } 10 | 11 | public static bool SaveCover { get; set; } 12 | /// 13 | /// If this is true SaveCover is also true 14 | /// 15 | public static bool SaveCoverOnly { get; set; } 16 | public static string? SaveCoverDir { get; set; } 17 | 18 | public static int MinimumDpi { get; set; } 19 | 20 | public static int MinimumHeight { get; set; } 21 | 22 | public static int MaximumHeight { get; set; } 23 | 24 | public static int JpgQuality { get; set; } 25 | 26 | public static int NumberOfThreads { get; set; } 27 | 28 | public static CompressionLevel CompressionLevel { get; set; } 29 | 30 | public static int ImageBufferSize => 4194304 * 2; 31 | 32 | public static Version GhostscriptMinVersion => new(10, 0); 33 | 34 | public static string GhostscriptPath { get; set; } 35 | 36 | public static Version GhostscriptVersion { get; private set; } 37 | 38 | public static void SetGhostscriptVersion(Version version) 39 | { 40 | GhostscriptVersion = version; 41 | } 42 | 43 | public static int WriteBufferSize => 262144; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Source/Publish.bat: -------------------------------------------------------------------------------- 1 | echo off 2 | set targets=CbzMage_Win CbzMage_Linux CbzMage_macOS 3 | set publish=dotnet publish -c Release -p:PublishProfile= 4 | set zip="C:\Program Files\7-Zip\7z.exe" a -tzip 5 | cd ..\Publish 6 | for %%v in (*.minor) do set oldminor=%%~nv 7 | set /a newminor=oldminor + 1 8 | set /a oldestminor=newminor - 5 9 | move %oldminor%.minor %newminor%.minor >nul: 10 | for %%v in (*.major) do set major=%%~nv 11 | set oldversion=%major%.%oldminor% 12 | set newversion=%major%.%newminor% 13 | echo Version: %newversion% 14 | for %%t in (%targets%) do if exist %%t rmdir /s /q %%t 15 | cd ..\Source\CbzMage 16 | for %%t in (%targets%) do ( 17 | echo Publish %%t 18 | %publish%%%t >nul: 19 | ) 20 | cd ..\..\Publish 21 | for %%t in (%targets%) do call :create_target %%t 22 | del CbzMage%major%.%oldestminor%_*.zip 23 | cd ..\Source 24 | echo Done 25 | pause 26 | exit /b 27 | :create_target 28 | setlocal enabledelayedexpansion 29 | set base=%1 30 | del %base%\*.pdb 31 | del %base%\*.development.json 32 | set old=!base:_=%oldversion%_! 33 | if exist %old% rmdir /s /q %old% 34 | set new=!base:_=%newversion%_! 35 | if exist %new% rmdir /s /q %new% 36 | move %base% %new% >nul: 37 | echo Create %new%.zip 38 | if exist %new%.zip del %new%.zip 39 | %zip% %new%.zip %new% >nul: 40 | -------------------------------------------------------------------------------- /Source/ReadmeExternalProjects.txt: -------------------------------------------------------------------------------- 1 | CbzMage azw conversion requires the MobiMetadata library. It expects to find the library 2 | project in a folder named "CbzMageExternal" outside of the CbzMage solution folder. 3 | 4 | So create this folder and checkout https://github.com/ToofDerling/MobiMetadata.git there 5 | (as "MobiMetadata") and you should be good to go. --------------------------------------------------------------------------------