FF-254 - added comic book nodes

This commit is contained in:
John Andrews
2022-08-02 21:52:45 +12:00
parent 1c268a0343
commit d467e24a18
16 changed files with 695 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>FileFlows.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<FileVersion>0.9.4.168</FileVersion>
<ProductVersion>0.9.4.168</ProductVersion>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<PublishTrimmed>true</PublishTrimmed>
<Company>FileFlows</Company>
<Authors>John Andrews</Authors>
<Product>Comic Nodes</Product>
<PackageProjectUrl>https://fileflows.com/</PackageProjectUrl>
<Description>Nodes for processing comic books (cbr, cbz, pdf etc)</Description>
</PropertyGroup>
<ItemGroup>
<None Remove="ComicNodes.en.json" />
</ItemGroup>
<ItemGroup>
<Content Include="ComicNodes.en.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup Condition=" '$(Configuration)' == 'Debug'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.8" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Docnet.Core" Version="2.3.1" />
<PackageReference Include="SharpCompress" Version="0.32.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta15" />
</ItemGroup>
<ItemGroup>
<Reference Include="FileFlows.Plugin">
<HintPath>..\FileFlows.Plugin.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
{
"Flow": {
"Parts": {
"ComicConverter": {
"Outputs": {
"1": "Comic was converted and saved as temporary file",
"2": "Comic was already in desired format"
},
"Description": "Converts a comic to a different comic book format",
"Fields": {
"Format": "Format",
"Format-Help": "The format to convert the comic into"
}
},
"ComicExtractor": {
"Outputs": {
"1": "Comic was extracted"
},
"Description": "Extracts all files from a comic book format and saves them to them to a specific folder",
"Fields": {
"DestinationPath": "Destination Path",
"DestinationPath-Help": "The folder to save the extract comic book files to"
}
}
}
}
}

View File

@@ -0,0 +1,83 @@
using System.ComponentModel;
namespace FileFlows.ComicNodes.Comics;
public class ComicConverter: Node
{
public override int Inputs => 1;
public override int Outputs => 2;
public override FlowElementType Type => FlowElementType.Process;
public override string Icon => "fas fa-book";
public override bool FailureNode => true;
[DefaultValue("cbz")]
[Select(nameof(FormatOptions), 1)]
public string Format { get; set; } = string.Empty;
private static List<ListOption> _FormatOptions;
public static List<ListOption> FormatOptions
{
get
{
if (_FormatOptions == null)
{
_FormatOptions = new List<ListOption>
{
new ListOption { Label = "CBZ", Value = "cbz"},
//new ListOption { Label = "CB7", Value = "cb7"},
new ListOption { Label = "PDF", Value = "pdf" }
};
}
return _FormatOptions;
}
}
public override int Execute(NodeParameters args)
{
string currentFormat = new FileInfo(args.WorkingFile).Extension;
if (string.IsNullOrEmpty(currentFormat))
{
args.Logger?.ELog("Could not detect format for: " + args.WorkingFile);
return -1;
}
if(currentFormat[0] == '.')
currentFormat = currentFormat[1..]; // remove the dot
currentFormat = currentFormat.ToLower();
if(currentFormat == Format)
{
args.Logger?.ILog($"Already in the target format of '{Format}'");
return 2;
}
string destinationPath = Path.Combine(args.TempPath, Guid.NewGuid().ToString());
Directory.CreateDirectory(destinationPath);
if (Helpers.ComicExtractor.Extract(args, args.WorkingFile, destinationPath, halfProgress: true) == false)
return -1;
string newFile = CreateComic(args, destinationPath, this.Format);
args.SetWorkingFile(newFile);
return 1;
}
private string CreateComic(NodeParameters args, string directory, string format)
{
string file = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + format);
args.Logger?.ILog("Creating comic: " + file);
if (format == "cbz")
Helpers.ZipHelper.Compress(args, directory, file);
//else if (format == "cb7")
// Helpers.SevenZipHelper.Compress(args, directory, file + ".7z");
else if (format == "pdf")
Helpers.PdfHelper.Create(args, directory, file);
else
throw new Exception("Unknown format:" + format);
Directory.Delete(directory, true);
args.Logger?.ILog("Created comic: " + file);
args.Logger?.ILog("Deleted temporary extraction directory: " + directory);
return file;
}
}

View File

@@ -0,0 +1,29 @@
namespace FileFlows.ComicNodes.Comics;
public class ComicExtractor : Node
{
public override int Inputs => 1;
public override int Outputs => 1;
public override FlowElementType Type => FlowElementType.Process;
public override string Icon => "fas fa-file-pdf";
[Required]
[Folder(1)]
public string DestinationPath { get; set; }
public override int Execute(NodeParameters args)
{
string dest = args.ReplaceVariables(DestinationPath, true);
dest = dest.Replace("\\", Path.DirectorySeparatorChar.ToString());
dest = dest.Replace("/", Path.DirectorySeparatorChar.ToString());
if (string.IsNullOrEmpty(dest))
{
args.Logger?.ELog("No destination specified");
args.Result = NodeResult.Failure;
return -1;
}
Helpers.ComicExtractor.Extract(args, args.WorkingFile, dest, halfProgress: false);
return 1;
}
}

View File

@@ -0,0 +1,6 @@
namespace FileFlows.Comic;
internal static class ExtensionMethods
{
public static string? EmptyAsNull(this string str) => str == string.Empty ? null : str;
}

View File

@@ -0,0 +1,5 @@
global using System;
global using System.Text;
global using System.ComponentModel.DataAnnotations;
global using FileFlows.Plugin;
global using FileFlows.Plugin.Attributes;

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace FileFlows.ComicNodes.Helpers
{
internal class ComicExtractor
{
internal static bool Extract(NodeParameters args, string file, string destinationPath, bool halfProgress)
{
string currentFormat = new FileInfo(args.WorkingFile).Extension;
if (string.IsNullOrEmpty(currentFormat))
{
args.Logger?.ELog("Could not detect format for: " + args.WorkingFile);
return false;
}
if (currentFormat[0] == '.')
currentFormat = currentFormat[1..]; // remove the dot
currentFormat = currentFormat.ToLower();
Directory.CreateDirectory(destinationPath);
args.Logger?.ILog("Extracting comic pages to: " + destinationPath);
if (currentFormat == "pdf")
PdfHelper.Extract(args, args.WorkingFile, destinationPath, "page", halfProgress: halfProgress);
else if (currentFormat == "cbz")
ZipHelper.Extract(args, args.WorkingFile, destinationPath, halfProgress: halfProgress);
else if (currentFormat == "cb7" || currentFormat == "cbr" || currentFormat == "gz" || currentFormat == "bz2")
GenericExtractor.Extract(args, args.WorkingFile, destinationPath, halfProgress: halfProgress);
else
throw new Exception("Unknown format:" + currentFormat);
return true;
}
}
}

View File

@@ -0,0 +1,28 @@
using SharpCompress.Archives;
using System;
using System.IO.Compression;
using System.Text.RegularExpressions;
namespace FileFlows.ComicNodes.Helpers;
internal class GenericExtractor
{
/// <summary>
/// Uncompresses a folder
/// </summary>
/// <param name="args">the node paratemers</param>
/// <param name="workingFile">the file to extract</param>
/// <param name="destinationPath">the location to extract to</param>
/// <param name="halfProgress">if the NodeParameter.PartPercentageUpdate should end at 50%</param>
internal static void Extract(NodeParameters args, string workingFile, string destinationPath, bool halfProgress = true)
{
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 0);
ArchiveFactory.WriteToDirectory(workingFile, destinationPath);
PageNameHelper.FixPageNames(destinationPath);
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 100);
}
}

View File

@@ -0,0 +1,20 @@
using System.Text.RegularExpressions;
namespace FileFlows.ComicNodes.Helpers;
internal class PageNameHelper
{
internal static void FixPageNames(string directory)
{
var files = new DirectoryInfo(directory).GetFiles();
foreach (var file in files)
{
var numMatch = Regex.Match(file.Name, @"[\d]+");
if (numMatch.Success == false)
continue;
// ensure any file that is stupidly name eg page1.jpg, page2.jpg, page10.jpg, page11.jpg etc is ordered correctly
file.MoveTo(Path.Combine(directory, file.Name.Replace(numMatch.Value, int.Parse(numMatch.Value).ToString(new string('0', files.Length.ToString().Length)))));
}
}
}

View File

@@ -0,0 +1,118 @@
using Docnet.Core;
using Docnet.Core.Editors;
using Docnet.Core.Models;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Text.RegularExpressions;
namespace FileFlows.ComicNodes.Helpers;
internal class PdfHelper
{
public static void Extract(NodeParameters args, string pdfFile, string destinationDirectory, string filePrefix, bool halfProgress = true)
{
using var library = DocLib.Instance;
using var docReader = library.GetDocReader(pdfFile, new PageDimensions(1080, 1920));
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 0);
int pageCount = docReader.GetPageCount();
for (int i = 1; i < pageCount; i++)
{
using var pageReader = docReader.GetPageReader(i);
var rawBytes = pageReader.GetImage();
var width = pageReader.GetPageWidth();
var height = pageReader.GetPageHeight();
using var image = Image.LoadPixelData<Bgra32>(rawBytes, width, height);
string file = Path.Combine(destinationDirectory, filePrefix + "-" + i.ToString(new String('0', pageCount.ToString().Length)) + ".png");
image.SaveAsPng(file);
if (args?.PartPercentageUpdate != null)
{
float percent = (i / pageCount) * 100f;
if (halfProgress)
percent = (percent / 2);
args?.PartPercentageUpdate(percent);
}
}
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 0);
}
/// <summary>
/// Creates a PDF from images
/// </summary>
/// <param name="args">the NodeParameters</param>
/// <param name="directory">the directory to of images</param>
/// <param name="output">the output file of the pdf</param>
/// <param name="halfProgress">if the NodePArameter.PartPercentageUpdate should start at 50%</param>
internal static void Create(NodeParameters args, string directory, string output, bool halfProgress = true)
{
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 0);
var rgxImages = new Regex(@"\.(jpeg|jpg|jpe|png|webp)$");
var files = Directory.GetFiles(directory).Where(x => rgxImages.IsMatch(x)).ToArray();
List<JpegImage> images = new List<JpegImage>();
for(int i = 0; i < files.Length; i++)
{
var file = files[i];
var format = Image.DetectFormat(file);
var info = Image.Identify(file);
if (file.ToLower().EndsWith(".png"))
{
var img = Image.Load(file);
using var memoryStream = new MemoryStream();
img.SaveAsJpeg(memoryStream);
var jpeg = new JpegImage
{
Bytes = memoryStream.ToArray(),
Width = info.Width,
Height = info.Height
};
images.Add(jpeg);
}
else if(file.ToLower().EndsWith(".webp"))
{
var img = Image.Load(file);
using var memoryStream = new MemoryStream();
img.SaveAsJpeg(memoryStream);
var jpeg = new JpegImage
{
Bytes = memoryStream.ToArray(),
Width = info.Width,
Height = info.Height
};
images.Add(jpeg);
}
else // jpeg
{
var jpeg = new JpegImage
{
Bytes = File.ReadAllBytes(file),
Width = info.Width,
Height = info.Height
};
images.Add(jpeg);
}
if (args?.PartPercentageUpdate != null)
{
float percent = (i / ((float)files.Length)) * 100f;
if (halfProgress)
percent = 50 + (percent / 2);
args?.PartPercentageUpdate(percent);
}
}
var bytes = DocLib.Instance.JpegToPdf(images);
File.WriteAllBytes(output, bytes);
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(100);
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.IO.Compression;
namespace FileFlows.ComicNodes.Helpers;
internal class ZipHelper
{
/// <summary>
/// Zips a folder to a file
/// </summary>
/// <param name="args">the NodeParameters</param>
/// <param name="directory">the directory to zip</param>
/// <param name="output">the output file of the zip</param>
/// <param name="pattern">the file pattern to include in the zip</param>
/// <param name="allDirectories">If all directories should be included or just the top most</param>
/// <param name="halfProgress">if the NodePArameter.PartPercentageUpdate should start at 50%</param>
internal static void Compress(NodeParameters args, string directory, string output, string pattern = "*.*", bool allDirectories = false, bool halfProgress = true)
{
var dir = new DirectoryInfo(directory);
var files = dir.GetFiles(pattern, allDirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
using FileStream fs = new FileStream(output, FileMode.Create);
using ZipArchive arch = new ZipArchive(fs, ZipArchiveMode.Create);
if(args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 0);
float current = 0;
float count = files.Length;
foreach (var file in files)
{
++count;
string relative = file.FullName.Substring(dir.FullName.Length + 1);
try
{
arch.CreateEntryFromFile(file.FullName, relative, CompressionLevel.SmallestSize);
}
catch (Exception ex)
{
args.Logger?.WLog("Failed to add file to zip: " + file.FullName + " => " + ex.Message);
}
if (args?.PartPercentageUpdate != null)
{
float percent = (current / count) * 100f;
if (halfProgress)
percent = 50 + (percent / 2);
args?.PartPercentageUpdate(percent);
}
}
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(100);
}
internal static void Extract(NodeParameters args, string workingFile, string destinationPath, bool halfProgress = true)
{
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 0);
ZipFile.ExtractToDirectory(workingFile, destinationPath);
PageNameHelper.FixPageNames(destinationPath);
if (args?.PartPercentageUpdate != null)
args?.PartPercentageUpdate(halfProgress ? 50 : 100);
}
}

12
ComicNodes/Plugin.cs Normal file
View File

@@ -0,0 +1,12 @@
namespace FileFlows.Comic;
public class Plugin : FileFlows.Plugin.IPlugin
{
public Guid Uid => new Guid("3664da0a-b531-47b9-bdc8-e8368d9746ce");
public string Name => "Comic Nodes";
public string MinimumVersion => "0.9.0.1487";
public void Init()
{
}
}

View File

@@ -0,0 +1,72 @@
#if(DEBUG)
using FileFlows.ComicNodes.Comics;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace FileFlows.Comic.Tests;
[TestClass]
public class ComicTests
{
[TestMethod]
public void Comic_Pdf_To_Cbz()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\fp1.pdf", logger, false, string.Empty);
args.TempPath = @"D:\comics\temp";
var node = new ComicConverter();
node.Format = "cbz";
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
[TestMethod]
public void Comic_Cbz_To_Pdf()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\mb.cbz", logger, false, string.Empty);
args.TempPath = @"D:\comics\temp";
var node = new ComicConverter();
node.Format = "pdf";
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
[TestMethod]
public void Comic_Cb7_To_Cbz()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\cb7.cb7", logger, false, string.Empty);
args.TempPath = @"D:\comics\temp";
var node = new ComicConverter();
node.Format = "cbz";
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
[TestMethod]
public void Comic_Cbr_To_Cbz()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\bm001.cbr", logger, false, string.Empty);
args.TempPath = @"D:\comics\temp";
var node = new ComicConverter();
node.Format = "cbz";
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
}
#endif

View File

@@ -0,0 +1,80 @@
#if(DEBUG)
using FileFlows.ComicNodes.Comics;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace FileFlows.Comic.Tests;
[TestClass]
public class ExtractTests
{
[TestMethod]
public void Extract_Pdf()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\fp1.pdf", logger, false, string.Empty);
var node = new ComicExtractor();
node.DestinationPath = @"D:\comics\converted\pdf";
if (Directory.Exists(node.DestinationPath))
Directory.Delete(node.DestinationPath, true);
Directory.CreateDirectory(node.DestinationPath);
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
[TestMethod]
public void Extract_Cbr()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\bm001.cbr", logger, false, string.Empty);
var node = new ComicExtractor();
node.DestinationPath = @"D:\comics\converted\cbr";
if (Directory.Exists(node.DestinationPath))
Directory.Delete(node.DestinationPath, true);
Directory.CreateDirectory(node.DestinationPath);
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
[TestMethod]
public void Extract_Cbz()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\mb.cbz", logger, false, string.Empty);
var node = new ComicExtractor();
node.DestinationPath = @"D:\comics\converted\cbz";
if (Directory.Exists(node.DestinationPath))
Directory.Delete(node.DestinationPath, true);
Directory.CreateDirectory(node.DestinationPath);
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
[TestMethod]
public void Extract_Cb7()
{
var logger = new TestLogger();
var args = new NodeParameters(@"D:\comics\testfiles\cb7.cb7", logger, false, string.Empty);
var node = new ComicExtractor();
node.DestinationPath = @"D:\comics\converted\cb7";
if(Directory.Exists(node.DestinationPath))
Directory.Delete(node.DestinationPath, true);
Directory.CreateDirectory(node.DestinationPath);
int result = node.Execute(args);
string log = logger.ToString();
Assert.AreEqual(1, result);
}
}
#endif

View File

@@ -0,0 +1,59 @@
#if(DEBUG)
namespace FileFlows.Comic.Tests;
using FileFlows.Plugin;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
internal class TestLogger : ILogger
{
private List<string> Messages = new List<string>();
public void DLog(params object[] args) => Log("DBUG", args);
public void ELog(params object[] args) => Log("ERRR", args);
public void ILog(params object[] args) => Log("INFO", args);
public void WLog(params object[] args) => Log("WARN", args);
private void Log(string type, object[] args)
{
if (args == null || args.Length == 0)
return;
string message = type + " -> " +
string.Join(", ", args.Select(x =>
x == null ? "null" :
x.GetType().IsPrimitive || x is string ? x.ToString() :
System.Text.Json.JsonSerializer.Serialize(x)));
Messages.Add(message);
}
public bool Contains(string message)
{
if (string.IsNullOrWhiteSpace(message))
return false;
string log = string.Join(Environment.NewLine, Messages);
return log.Contains(message);
}
public override string ToString()
{
return String.Join(Environment.NewLine, this.Messages.ToArray());
}
public string GetTail(int length = 50)
{
if (length <= 0)
length = 50;
if (Messages.Count <= length)
return string.Join(Environment.NewLine, Messages);
return string.Join(Environment.NewLine, Messages.TakeLast(length));
}
}
#endif

View File

@@ -33,6 +33,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VideoLegacyNodes", "VideoLe
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudioNodes", "AudioNodes\AudioNodes.csproj", "{600204C7-94F1-4793-9D02-D836A1B15B60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ComicNodes", "ComicNodes\ComicNodes.csproj", "{45568FCB-00FF-4AEA-AA43-DC569D667B04}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -99,6 +101,10 @@ Global
{600204C7-94F1-4793-9D02-D836A1B15B60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{600204C7-94F1-4793-9D02-D836A1B15B60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{600204C7-94F1-4793-9D02-D836A1B15B60}.Release|Any CPU.Build.0 = Release|Any CPU
{45568FCB-00FF-4AEA-AA43-DC569D667B04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45568FCB-00FF-4AEA-AA43-DC569D667B04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45568FCB-00FF-4AEA-AA43-DC569D667B04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45568FCB-00FF-4AEA-AA43-DC569D667B04}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE