AuthorBuddy/Services/ExportService.cs

426 lines
16 KiB
C#
Raw Permalink Normal View History

2026-05-31 19:31:27 +02:00
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using AuthorBuddy.Web.Models;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace AuthorBuddy.Web.Services;
public partial class ExportService : IExportService
{
private readonly IProjectService _projectService;
public ExportService(IProjectService projectService)
{
_projectService = projectService;
QuestPDF.Settings.License = LicenseType.Community;
}
public async Task<byte[]> ExportAsync(int projectId, List<string> filePaths, ExportFormat format)
{
var project = await _projectService.GetProjectAsync(projectId);
var projectName = project?.Name ?? "Projekt";
var files = new List<(string name, string content)>();
foreach (var path in filePaths)
{
if (File.Exists(path))
{
var content = await File.ReadAllTextAsync(path, Encoding.UTF8);
var name = Path.GetFileName(path);
files.Add((name, content));
}
}
return format switch
{
ExportFormat.Html => GenerateHtml(projectName, files),
ExportFormat.Markdown => GenerateMarkdown(projectName, files),
ExportFormat.Zip => GenerateZip(files),
ExportFormat.Docx => GenerateDocx(projectName, files),
ExportFormat.Pdf => GeneratePdf(projectName, files),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
private static byte[] GenerateHtml(string projectName, List<(string name, string content)> files)
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"de\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{EscapeHtml(projectName)} Export</title>");
sb.AppendLine("<style>");
sb.AppendLine(GetHtmlCss());
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine($"<header class=\"doc-header\"><h1>{EscapeHtml(projectName)}</h1><p class=\"doc-date\">Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}</p></header>");
foreach (var (name, content) in files)
{
sb.AppendLine($"<section class=\"doc-section\"><h2>{EscapeHtml(name)}</h2><div class=\"doc-content\">{RenderMarkdownAsHtml(content)}</div></section>");
}
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] GenerateMarkdown(string projectName, List<(string name, string content)> files)
{
var sb = new StringBuilder();
sb.AppendLine($"# {projectName}");
sb.AppendLine();
sb.AppendLine($"> Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}");
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
foreach (var (name, content) in files)
{
sb.AppendLine($"## {name}");
sb.AppendLine();
sb.AppendLine(content.TrimEnd());
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] GenerateZip(List<(string name, string content)> files)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var (name, content) in files)
{
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
}
return ms.ToArray();
}
private static byte[] GenerateDocx(string projectName, List<(string name, string content)> files)
{
using var ms = new MemoryStream();
using var doc = WordprocessingDocument.Create(ms, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new Body());
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold()), new Text(projectName)))
{ ParagraphProperties = new ParagraphProperties(new Justification { Val = JustificationValues.Center }) });
body.AppendChild(new Paragraph(
new Run(new RunProperties(new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "666666" }, new FontSize { Val = "20" }),
new Text($"Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}"))));
foreach (var (name, content) in files)
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "32" }), new Text(name))
));
var lines = content.Split('\n');
foreach (var raw in lines)
{
var line = raw.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line))
{
body.AppendChild(new Paragraph());
continue;
}
if (line.StartsWith("### "))
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "28" }), new Text(line[4..]))
));
}
else if (line.StartsWith("## "))
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "32" }), new Text(line[3..]))
));
}
else if (line.StartsWith("# "))
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "36" }), new Text(line[2..]))
));
}
else if (line.StartsWith("- ") || line.StartsWith("* "))
{
body.AppendChild(new Paragraph(
new Run(new Text($" • {line[2..]}"))
));
}
else if (line.Length > 2 && char.IsDigit(line[0]) && line[1] == '.' && line[2] == ' ')
{
body.AppendChild(new Paragraph(
new Run(new Text($" {line}"))
));
}
else
{
body.AppendChild(new Paragraph(
new Run(new Text(line))
));
}
}
}
doc.Save();
return ms.ToArray();
}
private static byte[] GeneratePdf(string projectName, List<(string name, string content)> files)
{
var doc = QuestPDF.Fluent.Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Times New Roman"));
page.Header().Column(col =>
{
col.Item().AlignCenter().Text(projectName).Bold().FontSize(22);
col.Item().AlignCenter().Text($"Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}").FontSize(10).FontColor(Colors.Grey.Medium);
});
page.Content().Column(col =>
{
foreach (var (name, content) in files)
{
col.Item().PaddingTop(12).Text(name).Bold().FontSize(16);
col.Item().PaddingLeft(4).Element(c => RenderMarkdownToPdf(c, content));
}
});
page.Footer().AlignCenter().Text(x =>
{
x.Span("Seite ").FontSize(9);
x.CurrentPageNumber().FontSize(9);
});
});
});
using var ms = new MemoryStream();
doc.GeneratePdf(ms);
return ms.ToArray();
}
private static void RenderMarkdownToPdf(IContainer container, string md)
{
var lines = md.Split('\n');
container.Column(col =>
{
foreach (var raw in lines)
{
var line = raw.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line))
{
col.Item().Height(8);
continue;
}
if (line.StartsWith("### "))
{
col.Item().PaddingTop(6).Text(line[4..]).Bold().FontSize(13);
}
else if (line.StartsWith("## "))
{
col.Item().PaddingTop(8).Text(line[3..]).Bold().FontSize(15);
}
else if (line.StartsWith("# "))
{
col.Item().PaddingTop(10).Text(line[2..]).Bold().FontSize(17);
}
else if (line.StartsWith("- ") || line.StartsWith("* "))
{
col.Item().PaddingLeft(12).Text($" • {line[2..]}").FontSize(11);
}
else if (line is "---" or "***" or "___")
{
col.Item().Height(1).Background(Colors.Grey.Lighten2);
}
else if (line.Length > 2 && char.IsDigit(line[0]) && line[1] == '.' && line[2] == ' ')
{
col.Item().PaddingLeft(12).Text($" {line}").FontSize(11);
}
else
{
col.Item().Text(line).FontSize(11);
}
}
});
}
private static string EscapeHtml(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private static string RenderMarkdownAsHtml(string md)
{
var lines = md.Split('\n');
var sb = new StringBuilder();
var inList = false;
foreach (var raw in lines)
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("###### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h6>{EscapeHtml(line[7..])}</h6>");
continue;
}
if (line.StartsWith("##### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h5>{EscapeHtml(line[6..])}</h5>");
continue;
}
if (line.StartsWith("#### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h4>{EscapeHtml(line[5..])}</h4>");
continue;
}
if (line.StartsWith("### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h3>{EscapeHtml(line[4..])}</h3>");
continue;
}
if (line.StartsWith("## "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h2>{EscapeHtml(line[3..])}</h2>");
continue;
}
if (line.StartsWith("# "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h1>{EscapeHtml(line[2..])}</h1>");
continue;
}
if (line.StartsWith("- ") || line.StartsWith("* "))
{
if (!inList)
{
inList = true;
sb.AppendLine("<ul>");
}
sb.AppendLine($"<li>{RenderInline(line[2..])}</li>");
continue;
}
if (line.Length > 2 && char.IsDigit(line[0]) && (line[1] == '.' || line[1] == ')') && line[2] == ' ')
{
if (!inList)
{
inList = true;
sb.AppendLine("<ol>");
}
sb.AppendLine($"<li>{RenderInline(line[3..])}</li>");
continue;
}
if (line is "---" or "***" or "___")
{
CloseList(ref inList, sb);
sb.AppendLine("<hr>");
continue;
}
if (string.IsNullOrWhiteSpace(line))
{
CloseList(ref inList, sb);
continue;
}
CloseList(ref inList, sb);
sb.AppendLine($"<p>{RenderInline(line)}</p>");
}
CloseList(ref inList, sb);
return sb.ToString();
}
private static void CloseList(ref bool inList, StringBuilder sb)
{
if (inList)
{
sb.AppendLine(sb[sb.Length - 1] == '>' ? "</ol>" : "</ul>");
inList = false;
}
}
private static string RenderInline(string text)
{
var result = EscapeHtml(text);
result = BoldRegex().Replace(result, "<strong>$1</strong>");
result = ItalicRegex().Replace(result, "<em>$1</em>");
result = UnderlineRegex().Replace(result, "<u>$1</u>");
result = CodeRegex().Replace(result, "<code>$1</code>");
result = LinkRegex().Replace(result, "<a href=\"$2\">$1</a>");
return result;
}
private static string GetHtmlCss()
{
return """
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Georgia, 'Times New Roman', serif; font-size: 12pt; line-height: 1.6; color: #222; max-width: 210mm; margin: 0 auto; padding: 20mm 30mm; background: #fff; }
.doc-header { text-align: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #333; }
.doc-header h1 { font-size: 22pt; margin-bottom: 4px; }
.doc-date { font-size: 10pt; color: #666; }
.doc-section { margin-bottom: 25px; page-break-before: auto; }
.doc-section h2 { font-size: 16pt; margin-bottom: 10px; padding-bottom: 3px; border-bottom: 1px solid #ccc; color: #333; }
.doc-content h1 { font-size: 18pt; margin: 15px 0 8px; }
.doc-content h2 { font-size: 15pt; margin: 12px 0 6px; }
.doc-content h3 { font-size: 13pt; margin: 10px 0 5px; }
.doc-content h4 { font-size: 12pt; margin: 8px 0 4px; }
.doc-content h5 { font-size: 11pt; margin: 6px 0 3px; }
.doc-content h6 { font-size: 10pt; margin: 6px 0 3px; color: #555; }
.doc-content p { margin: 6px 0; text-align: justify; }
.doc-content ul, .doc-content ol { margin: 6px 0 6px 20px; }
.doc-content li { margin: 2px 0; }
.doc-content hr { margin: 15px 0; border: none; border-top: 1px solid #ccc; }
.doc-content code { font-family: 'Consolas', 'Courier New', monospace; font-size: 10pt; background: #f4f4f4; padding: 1px 4px; border-radius: 2px; }
.doc-content a { color: #1a73e8; text-decoration: underline; }
.doc-content strong { font-weight: bold; }
.doc-content em { font-style: italic; }
@media print { body { padding: 0; } .doc-section { page-break-before: always; } .doc-section:first-of-type { page-break-before: auto; } }
""";
}
[GeneratedRegex(@"\*\*(.+?)\*\*")]
private static partial Regex BoldRegex();
[GeneratedRegex(@"\*(.+?)\*")]
private static partial Regex ItalicRegex();
[GeneratedRegex(@"<u>(.+?)</u>")]
private static partial Regex UnderlineRegex();
[GeneratedRegex(@"`(.+?)`")]
private static partial Regex CodeRegex();
[GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")]
private static partial Regex LinkRegex();
}