Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/#8 #11

Merged
merged 9 commits into from
Mar 10, 2024
Merged
8 changes: 8 additions & 0 deletions Epub/KoeBook.Epub/Contracts/Services/IEpubCreateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using KoeBook.Epub.Models;

namespace KoeBook.Epub.Contracts.Services;

public interface IEpubCreateService
{
ValueTask<bool> TryCreateEpubAsync(EpubDocument epubDocument, string tmpDirectory, CancellationToken ct);
}
17 changes: 0 additions & 17 deletions Epub/KoeBook.Epub/EpubCreateHelper.cs

This file was deleted.

273 changes: 10 additions & 263 deletions Epub/KoeBook.Epub/Models/EpubDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ namespace KoeBook.Epub.Models;

public class EpubDocument(string title, string author, string coverFilePath, Guid id)
{
readonly string _containerXml = """
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/book.opf" media-type="application/oebps-package+xml" />
</rootfiles>
</container>
""";

public string Title { get; set; } = title;
public string Author { get; set; } = author;

Expand All @@ -23,17 +14,17 @@ public class EpubDocument(string title, string author, string coverFilePath, Gui
public Guid Id { get; } = id;

public List<CssClass> CssClasses { get; set; } = [
new CssClass("-epub-media-overlay-active", """
.-epub-media-overlay-active *{
background-color: yellow;
color: black !important;
}
"""),
new CssClass("-epub-media-overlay-active", """
.-epub-media-overlay-active *{
background-color: yellow;
color: black !important;
}
"""),
new CssClass("-epub-media-overlay-unactive", """
.-epub-media-overlay-unactive * {
color: gray;
}
"""),
.-epub-media-overlay-unactive * {
color: gray;
}
"""),
];
public List<Chapter> Chapters { get; set; } = [];

Expand Down Expand Up @@ -63,248 +54,4 @@ internal void EnsureParagraph(int chapterIndex, int sectionIndex)
if (Chapters[chapterIndex].Sections[sectionIndex].Elements.Count == 0)
Chapters[chapterIndex].Sections[sectionIndex].Elements.Add(new Paragraph());
}

public string CreateNavXhtml()
{
var builder = new StringBuilder($"""
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<meta charset="UTF-8"/>
<title>{Title}</title>
</head>
<body>
<nav epub:type="toc" id="toc">
<ol>

""");
if (Chapters.Count == 1 && Chapters[0].Title == null)
{
for (var i = 0; i < Chapters[0].Sections.Count; i++)
{
builder.AppendLine($"""
<li>
<a href="{Chapters[0].Sections[i].Id}.xhtml#s_{Chapters[0].Sections[i].Id}_p0">{Chapters[0].Sections[i].Title}</a>
</li>
""");
}
}
else
{
for (var i = 0; i < Chapters.Count; i++)
{
builder.AppendLine($"""
<li>
<span>{Chapters[i].Title}</span>
<ol>
""");
for (var j = 0; j < Chapters[i].Sections.Count; j++)
{
builder.AppendLine($"""
<li>
<a href="{Chapters[i].Sections[j].Id}.xhtml#s_{Chapters[i].Sections[j].Id}_p0">{Chapters[i].Sections[j].Title}</a>
</li>
""");
}
builder.AppendLine($"""
</ol>
</li>
""");
}
}
builder.AppendLine($"""
</ol>
</nav>
</body>
</html>
""");
return builder.ToString();
}

public string CreateCssText()
{
var builder = new StringBuilder();
foreach (var cssClass in CssClasses)
{
builder.AppendLine(cssClass.Text);
}
return builder.ToString();
}

public string CreateOpf()
{
var builder = new StringBuilder($"""
<package unique-identifier="pub-id" version="3.0" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title id="title">{Title}</dc:title>
<dc:creator id="creator">{Author}</dc:creator>
<meta refines="#creator" property="role" scheme="marc:relators">aut</meta>
<dc:identifier id="pub-id">urn:uuid:{Guid.NewGuid()}</dc:identifier>
<dc:language>ja</dc:language>
<dc:date>{DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)}</dc:date>
<meta property="dcterms:modified">{DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)}</meta>
<meta property="media:active-class">-epub-media-overlay-active</meta>
<meta property="media:playback-active-class">-epub-media-overlay-unactive</meta>

""");

var totalTime = TimeSpan.Zero;
for (var i = 0; i < Chapters.Count; i++)
{
for (var j = 0; j < Chapters[i].Sections.Count; j++)
{
var time = Chapters[i].Sections[j].GetTotalTime();
totalTime += time;
builder.AppendLine($"""
<meta property="media:duration" refines="#smil_{i}_{j}">{time}</meta>
""");
}
}
builder.AppendLine($"""
<meta property="media:duration">{totalTime}</meta>
</metadata>
<manifest>
<item id="css" media-type="text/css" href="style.css"/>
<item id="cover" href="{Path.GetFileName(CoverFilePath)}" properties="cover-image" media-type="{EpubCreateHelper.GetImagesMediaType(CoverFilePath)}" />
<item id="nav" href="nav.xhtml" properties="nav" media-type="application/xhtml+xml" />
""");

for (var i = 0; i < Chapters.Count; i++)
{
for (var j = 0; j < Chapters[i].Sections.Count; j++)
{
builder.AppendLine($"""
<item id="section_{i}_{j}" href="{Chapters[i].Sections[j].Id}.xhtml" media-type="application/xhtml+xml" media-overlay="smil_{i}_{j}" />
<item id="smil_{i}_{j}" href="{Chapters[i].Sections[j].Id}_audio.smil" media-type="application/smil+xml" />
""");
for (var k = 0; k < Chapters[i].Sections[j].Elements.Count; k++)
{
var element = Chapters[i].Sections[j].Elements[k];
if (element is Paragraph para && para.Audio != null)
{
builder.AppendLine(@$" <item id=""audio_{i}_{j}_{k}"" href=""{Chapters[i].Sections[j].Id}_p{k}.mp3"" media-type=""audio/mpeg"" />");
}
else if (element is Picture pic && File.Exists(pic.PictureFilePath))
{
builder.AppendLine(@$" <item id=""img_{i}_{j}_{k}"" href=""{Chapters[i].Sections[j].Id}_p{k}{Path.GetExtension(pic.PictureFilePath)}"" media-type=""{EpubCreateHelper.GetImagesMediaType(pic.PictureFilePath)}"" />");
}
}
}
}

builder.AppendLine($"""
</manifest>
<spine page-progression-direction="ltr">
""");

for (var i = 0; i < Chapters.Count; i++)
{
for (var j = 0; j < Chapters[i].Sections.Count; j++)
{
builder.AppendLine($"""
<itemref idref="section_{i}_{j}" id="itemref_{i}_{j}" />
""");
}
}

builder.AppendLine($"""
</spine>
</package>
""");
return builder.ToString();
}

public async Task<bool> TryCreateEpubAsync(string tmpDirectory, string name, CancellationToken ct)
{
if (!File.Exists(CoverFilePath))
{
throw new FileNotFoundException("指定されたカバーファイルが存在しません", CoverFilePath);
}
try
{
using var fs = File.Create(Path.Combine(tmpDirectory, $"{name}.epub"));
using var archive = new ZipArchive(fs, ZipArchiveMode.Create);

var mimeTypeEntry = archive.CreateEntry("mimetype", CompressionLevel.NoCompression);
using (var mimeTypeStream = new StreamWriter(mimeTypeEntry.Open()))
{
await mimeTypeStream.WriteAsync("application/epub+zip").ConfigureAwait(false);
await mimeTypeStream.FlushAsync(ct).ConfigureAwait(false);
}

var containerEntry = archive.CreateEntry("META-INF/container.xml");
using (var containerStream = new StreamWriter(containerEntry.Open()))
{
await containerStream.WriteLineAsync(_containerXml).ConfigureAwait(false);
await containerStream.FlushAsync(ct).ConfigureAwait(false);
}

archive.CreateEntryFromFile(CoverFilePath, $"OEBPS/{Path.GetFileName(CoverFilePath)}");

var cssEntry = archive.CreateEntry("OEBPS/style.css");
using (var cssStream = new StreamWriter(cssEntry.Open()))
{
await cssStream.WriteLineAsync(CreateCssText()).ConfigureAwait(false);
await cssStream.FlushAsync(ct).ConfigureAwait(false);
}

var navEntry = archive.CreateEntry("OEBPS/nav.xhtml");
using (var navStream = new StreamWriter(navEntry.Open()))
{
await navStream.WriteLineAsync(CreateNavXhtml()).ConfigureAwait(false);
await navStream.FlushAsync(ct).ConfigureAwait(false);
}

var opfEntry = archive.CreateEntry("OEBPS/book.opf");
using (var opfStream = new StreamWriter(opfEntry.Open()))
{
await opfStream.WriteLineAsync(CreateOpf()).ConfigureAwait(false);
await opfStream.FlushAsync(ct).ConfigureAwait(false);
}

for (var i = 0; i < Chapters.Count; i++)
{
for (var j = 0; j < Chapters[i].Sections.Count; j++)
{
var sectionXhtmlEntry = archive.CreateEntry($"OEBPS/{Chapters[i].Sections[j].Id}.xhtml");
using (var sectionXhtmlStream = new StreamWriter(sectionXhtmlEntry.Open()))
{
await sectionXhtmlStream.WriteLineAsync(Chapters[i].Sections[j].CreateSectionXhtml()).ConfigureAwait(false);
await sectionXhtmlStream.FlushAsync(ct).ConfigureAwait(false);
}
var sectionSmilEntry = archive.CreateEntry($"OEBPS/{Chapters[i].Sections[j].Id}_audio.smil");
using (var sectionSmilStream = new StreamWriter(sectionSmilEntry.Open()))
{
await sectionSmilStream.WriteLineAsync(Chapters[i].Sections[j].CreateSectionSmil()).ConfigureAwait(false);
await sectionSmilStream.FlushAsync(ct).ConfigureAwait(false);
}
for (var k = 0; k < Chapters[i].Sections[j].Elements.Count; k++)
{
var element = Chapters[i].Sections[j].Elements[k];
if (element is Paragraph para && para.Audio != null)
{
var audioEntry = archive.CreateEntry($"OEBPS/{Chapters[i].Sections[j].Id}_p{k}.mp3");
using var audioStream = para.Audio.GetStream();
using var audioEntryStream = audioEntry.Open();
await audioStream.CopyToAsync(audioEntryStream, ct).ConfigureAwait(false);
await audioEntryStream.FlushAsync(ct).ConfigureAwait(false);
}
else if (element is Picture pic && File.Exists(pic.PictureFilePath))
{
archive.CreateEntryFromFile(pic.PictureFilePath, $"OEBPS/{Chapters[i].Sections[j].Id}_p{k}{Path.GetExtension(pic.PictureFilePath)}");
}
}
}
}
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return false;
}
}
}
67 changes: 0 additions & 67 deletions Epub/KoeBook.Epub/Models/Section.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,73 +8,6 @@ public sealed class Section(string title)
public string Title { get; set; } = title;
public List<Element> Elements { get; set; } = [];

public string CreateSectionXhtml()
{
var builder = new StringBuilder($"""
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja" lang="ja">
<head>
<link rel="stylesheet" type="text/css" media="all" href="style.css"/>
<title>{Title}</title>
</head>
<body>
""");

for (var i = 0; i < Elements.Count; i++)
{
if (Elements[i] is Paragraph para)
{
builder.AppendLine($"""
<p id="s_{Id}_p{i}" {(para.ClassName != null ? $"class=\"{para.ClassName}\"" : "")}>
{para.Text}
</p>
""");
}
else if (Elements[i] is Picture pic && File.Exists(pic.PictureFilePath))
{
builder.AppendLine($"""
<p id="s_{Id}_p{i}" {(pic.ClassName != null ? $"class=\"{pic.ClassName}\"" : "")}>
<img src="{Path.GetFileName(pic.PictureFilePath)}"
</p>
""");
}
}

builder.AppendLine("""
</body>
</html>
""");
return builder.ToString();
}

public string CreateSectionSmil()
{
var builder = new StringBuilder($"""
<?xml version="1.0" encoding="UTF-8"?>
<smil xmlns="http://www.w3.org/ns/SMIL" version="3.0">
<body>
""");

for (var i = 0; i < Elements.Count; i++)
{
if (Elements[i] is Paragraph para && para.Audio != null)
{
builder.AppendLine($"""
<par id="s_{Id}_p{i}_audio" {(para.ClassName != null ? $"class=\"{para.ClassName}\"" : "")}>
<text src="{Id}.xhtml#s_{Id}_p{i}" />
<audio clipBegin="0s" clipEnd="{para.Audio?.TotalTime}" src="{Id}_p{i}.mp3"/>
</par>
""");
}
}

builder.AppendLine("""
</body>
</smil>
""");
return builder.ToString();
}

public TimeSpan GetTotalTime()
{
var time = TimeSpan.Zero;
Expand Down
Loading
Loading