diff --git a/src/Everything2Everything.Core/Converters/DocumentProvider.cs b/src/Everything2Everything.Core/Converters/DocumentProvider.cs index 0be40b6..26c001f 100644 --- a/src/Everything2Everything.Core/Converters/DocumentProvider.cs +++ b/src/Everything2Everything.Core/Converters/DocumentProvider.cs @@ -17,17 +17,22 @@ namespace Everything2Everything.Core.Converters; // txt ↔ md → trivial copy public sealed class DocumentProvider : IConverterProvider { - private static readonly string[] Inputs = - { ".html", ".htm", ".hwp", ".hwpx", ".docx", ".doc", ".md", ".markdown", ".txt" }; - - private static readonly string[] Outputs = { ".html", ".docx", ".md", ".txt" }; + private static readonly string[] OfficeInputs = { ".docx", ".doc", ".hwp", ".hwpx" }; + private static readonly string[] OfficeOutputs = { ".html", ".docx", ".md", ".txt" }; + private static readonly string[] TextInputsToDocx = { ".html", ".htm", ".md", ".markdown", ".txt" }; public ProviderCapability Capability { get; } = new( Id: "document", - DisplayName: "문서 텍스트 변환 (HTML/HWP/DOCX/MD/TXT)", - SupportedConversions: ProviderCapability.PairsFromMatrix(Inputs, Outputs), + DisplayName: "문서 텍스트 변환 (DOCX/HWP/오피스)", + SupportedConversions: ProviderCapability.PairsFromMatrix(OfficeInputs, OfficeOutputs) + .Concat(ProviderCapability.PairsFromMatrix(TextInputsToDocx, new[] { ".docx" })) + .Concat(new[] + { + new ConversionPair(".html", ".txt", LossClass.Recode), + new ConversionPair(".htm", ".txt", LossClass.Recode), + }).ToList(), Status: ProviderStatus.RequiresExternal, - Summary: "HTML·HWP·DOCX·Markdown·TXT 사이의 양방향 텍스트 변환 (LibreOffice + Markdig + ReverseMarkdown).", + Summary: "DOCX·HWP 문서를 HTML/DOCX/MD/TXT로 변환하거나 텍스트를 DOCX로 저장합니다 (LibreOffice 엔진).", ExternalDependencies: new[] { new ExternalDependency( diff --git a/src/Everything2Everything.Core/Converters/HtmlProvider.cs b/src/Everything2Everything.Core/Converters/HtmlProvider.cs index 9db4759..a5ebf50 100644 --- a/src/Everything2Everything.Core/Converters/HtmlProvider.cs +++ b/src/Everything2Everything.Core/Converters/HtmlProvider.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Threading; using System.Windows; +using System.Windows.Interop; using System.Windows.Threading; using Everything2Everything.Core.Providers; using ImageMagick; @@ -165,7 +166,25 @@ public sealed class HtmlProvider : IConverterProvider try { var dispatcher = Dispatcher.CurrentDispatcher; - _ = RunCaptureOnDispatcher(capture, dispatcher, sourcePath, options, progress, cancellationToken, tcs); + SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher)); + + dispatcher.InvokeAsync(async () => + { + try + { + var bytes = await RunCaptureOnDispatcher(capture, sourcePath, options, progress, cancellationToken); + tcs.TrySetResult(bytes); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + finally + { + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + }); + Dispatcher.Run(); } catch (Exception ex) @@ -181,16 +200,15 @@ public sealed class HtmlProvider : IConverterProvider return tcs.Task; } - private static async Task RunCaptureOnDispatcher( + private static async Task RunCaptureOnDispatcher( Func?, Task> capture, - Dispatcher dispatcher, string sourcePath, ConvertOptions options, IProgress? progress, - CancellationToken cancellationToken, - TaskCompletionSource tcs) + CancellationToken cancellationToken) { CoreWebView2Controller? controller = null; + HwndSource? hwndSource = null; try { var userDataFolder = Path.Combine( @@ -198,15 +216,23 @@ public sealed class HtmlProvider : IConverterProvider "Everything2Everything", "WebView2"); Directory.CreateDirectory(userDataFolder); - var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true); + var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder); progress?.Report(0.15); - controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true); - int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280; int height = options.HtmlRender.ViewportHeight ?? 720; + + var parameters = new HwndSourceParameters("WebView2HiddenHost") + { + WindowStyle = 0x800000, + Width = width, + Height = height, + }; + hwndSource = new HwndSource(parameters); + + controller = await env.CreateCoreWebView2ControllerAsync(hwndSource.Handle); controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height); - controller.IsVisible = false; + controller.IsVisible = true; var web = controller.CoreWebView2; progress?.Report(0.3); @@ -217,36 +243,31 @@ public sealed class HtmlProvider : IConverterProvider { web.NavigationCompleted -= navHandler!; if (e.IsSuccess) navTcs.TrySetResult(true); - else navTcs.TrySetException(new InvalidOperationException( - $"내비게이션 실패: {e.WebErrorStatus}")); + else navTcs.TrySetException(new InvalidOperationException($"내비게이션 실패: {e.WebErrorStatus}")); }; web.NavigationCompleted += navHandler; - var fileUri = new Uri(sourcePath).AbsoluteUri; + var fileUri = new Uri(Path.GetFullPath(sourcePath)).AbsoluteUri; web.Navigate(fileUri); using (cancellationToken.Register(() => navTcs.TrySetCanceled())) { - await navTcs.Task.ConfigureAwait(true); + await navTcs.Task; } progress?.Report(0.5); if (options.HtmlRender.WaitMilliseconds > 0) - await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken).ConfigureAwait(true); + await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken); progress?.Report(0.65); - var bytes = await capture(web, progress).ConfigureAwait(true); - tcs.TrySetResult(bytes); - } - catch (Exception ex) - { - tcs.TrySetException(ex); + var bytes = await capture(web, progress); + return bytes; } finally { try { controller?.Close(); } catch { } - dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + try { hwndSource?.Dispose(); } catch { } } } diff --git a/src/Everything2Everything.Core/Converters/MarkdownProvider.cs b/src/Everything2Everything.Core/Converters/MarkdownProvider.cs new file mode 100644 index 0000000..3af441f --- /dev/null +++ b/src/Everything2Everything.Core/Converters/MarkdownProvider.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core.Providers; +using Markdig; + +namespace Everything2Everything.Core.Converters; + +public sealed class MarkdownProvider : IConverterProvider +{ + private static readonly string[] MdInputs = { ".html", ".htm", ".md", ".markdown", ".txt" }; + + public ProviderCapability Capability { get; } = new( + Id: "markdown", + DisplayName: "마크다운 / HTML 텍스트", + SupportedConversions: new[] + { + new ConversionPair(".html", ".md", LossClass.Recode), + new ConversionPair(".htm", ".md", LossClass.Recode), + new ConversionPair(".md", ".html", LossClass.Recode), + new ConversionPair(".markdown", ".html", LossClass.Recode), + new ConversionPair(".md", ".txt", LossClass.Container), + new ConversionPair(".txt", ".md", LossClass.Container), + new ConversionPair(".txt", ".html", LossClass.Recode), + }, + Status: ProviderStatus.Available, + Summary: "HTML·Markdown·TXT 사이의 순수 .NET 양방향 변환 (Markdig + ReverseMarkdown).", + ExternalDependencies: Array.Empty(), + RoadmapNote: null); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.Ready); + + public async Task ConvertAsync( + string sourcePath, + string outputDirectory, + string outputExtension, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var inExt = Path.GetExtension(sourcePath).ToLowerInvariant(); + var outExt = ConversionPair.Normalize(outputExtension); + var baseName = Path.GetFileNameWithoutExtension(sourcePath); + var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision); + + if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision)) + return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); + + progress?.Report(0.1); + + try + { + if (inExt is ".html" or ".htm" && outExt == ".md") + { + await HtmlToMdAsync(sourcePath, outPath, cancellationToken).ConfigureAwait(false); + } + else if (inExt is ".md" or ".markdown" && outExt == ".html") + { + await MdToHtmlAsync(sourcePath, outPath, cancellationToken).ConfigureAwait(false); + } + else if (inExt == ".txt" && outExt == ".html") + { + await TxtToHtmlAsync(sourcePath, outPath, cancellationToken).ConfigureAwait(false); + } + else if ((inExt is ".md" or ".markdown" && outExt == ".txt") || (inExt == ".txt" && outExt == ".md")) + { + await Task.Run(() => File.Copy(sourcePath, outPath, overwrite: true), cancellationToken).ConfigureAwait(false); + } + else + { + return ConvertResult.Fail(sourcePath, $"지원하지 않는 변환: {inExt} → {outExt}"); + } + + progress?.Report(1.0); + return ConvertResult.Ok(sourcePath, new[] { outPath }); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return ConvertResult.Fail(sourcePath, $"변환 실패: {ex.Message}", ex); + } + } + + private static async Task MdToHtmlAsync(string mdPath, string htmlPath, CancellationToken ct) + { + var md = await File.ReadAllTextAsync(mdPath, Encoding.UTF8, ct).ConfigureAwait(false); + var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + var body = Markdown.ToHtml(md, pipeline); + var fullHtml = $"{Path.GetFileNameWithoutExtension(mdPath)}{body}"; + await File.WriteAllTextAsync(htmlPath, fullHtml, Encoding.UTF8, ct).ConfigureAwait(false); + } + + private static async Task HtmlToMdAsync(string htmlPath, string mdPath, CancellationToken ct) + { + var html = await File.ReadAllTextAsync(htmlPath, Encoding.UTF8, ct).ConfigureAwait(false); + var converter = new ReverseMarkdown.Converter(new ReverseMarkdown.Config + { + UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.PassThrough, + GithubFlavored = true, + RemoveComments = true, + SmartHrefHandling = true, + }); + var md = converter.Convert(html); + await File.WriteAllTextAsync(mdPath, md, Encoding.UTF8, ct).ConfigureAwait(false); + } + + private static async Task TxtToHtmlAsync(string txtPath, string htmlPath, CancellationToken ct) + { + var txt = await File.ReadAllTextAsync(txtPath, Encoding.UTF8, ct).ConfigureAwait(false); + var encoded = System.Net.WebUtility.HtmlEncode(txt).Replace("\n", "
\n"); + var fullHtml = $"{Path.GetFileNameWithoutExtension(txtPath)}
{encoded}
"; + await File.WriteAllTextAsync(htmlPath, fullHtml, Encoding.UTF8, ct).ConfigureAwait(false); + } +} diff --git a/src/Everything2Everything.Tests/HtmlEndToEndAllOutputsTests.cs b/src/Everything2Everything.Tests/HtmlEndToEndAllOutputsTests.cs new file mode 100644 index 0000000..4d27682 --- /dev/null +++ b/src/Everything2Everything.Tests/HtmlEndToEndAllOutputsTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Everything2Everything.Core.Providers; +using Xunit; +using Xunit.Abstractions; + +namespace Everything2Everything.Tests; + +public class HtmlEndToEndAllOutputsTests +{ + private readonly ITestOutputHelper _output; + + public HtmlEndToEndAllOutputsTests(ITestOutputHelper output) + { + _output = output; + } + + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "e2e_html_all_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Theory] + [InlineData(".pdf")] + [InlineData(".png")] + [InlineData(".jpg")] + [InlineData(".webp")] + [InlineData(".avif")] + [InlineData(".bmp")] + [InlineData(".tiff")] + [InlineData(".gif")] + public async Task Html_To_ImagesAndPdf_Via_Engine_Succeeds(string targetExt) + { + var dir = TempDir(); + var html = Path.Combine(dir, "sample.html"); + await File.WriteAllTextAsync(html, "

Test Page

Paragraph content

"); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var opt = new ConvertOptions + { + OutputLocation = OutputLocation.Custom, + CustomOutputDirectory = dir + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await engine.ConvertOneAsync(html, targetExt, opt, null, cts.Token); + + _output.WriteLine($"Convert .html -> {targetExt}: Status={result.Status}, Msg={result.Message}"); + Assert.Equal(ConvertStatus.Success, result.Status); + Assert.NotEmpty(result.OutputPaths); + Assert.True(File.Exists(result.OutputPaths[0])); + Assert.True(new FileInfo(result.OutputPaths[0]).Length > 0); + } + + [Fact] + public async Task Html_To_Markdown_Via_Engine_TestsAvailability() + { + var dir = TempDir(); + var html = Path.Combine(dir, "sample.html"); + await File.WriteAllTextAsync(html, "

Markdown Title

Bold text

"); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var opt = new ConvertOptions + { + OutputLocation = OutputLocation.Custom, + CustomOutputDirectory = dir + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var result = await engine.ConvertOneAsync(html, ".md", opt, null, cts.Token); + + _output.WriteLine($"Convert .html -> .md: Status={result.Status}, Msg={result.Message}"); + } +} diff --git a/src/Everything2Everything.Tests/HtmlProviderTests.cs b/src/Everything2Everything.Tests/HtmlProviderTests.cs new file mode 100644 index 0000000..bdc152c --- /dev/null +++ b/src/Everything2Everything.Tests/HtmlProviderTests.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Everything2Everything.Core.Providers; +using Xunit; + +namespace Everything2Everything.Tests; + +public class HtmlProviderTests +{ + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "e2e_html_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Fact] + public async Task HtmlProvider_ConvertAsync_HtmlToPdf_Succeeds() + { + var dir = TempDir(); + var html = Path.Combine(dir, "test.html"); + await File.WriteAllTextAsync(html, "

Hello HTML Test

Testing HTML to PDF

"); + + var provider = new HtmlProvider(); + var availability = await provider.CheckAvailabilityAsync(); + if (!availability.IsReady) return; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await provider.ConvertAsync(html, dir, ".pdf", new ConvertOptions(), null, cts.Token); + + Assert.Equal(ConvertStatus.Success, result.Status); + Assert.NotEmpty(result.OutputPaths); + Assert.True(File.Exists(result.OutputPaths[0])); + Assert.True(new FileInfo(result.OutputPaths[0]).Length > 0); + } + + [Fact] + public async Task HtmlProvider_ConvertAsync_HtmlToPng_Succeeds() + { + var dir = TempDir(); + var html = Path.Combine(dir, "test.html"); + await File.WriteAllTextAsync(html, "

Hello HTML Test

Testing HTML to PNG

"); + + var provider = new HtmlProvider(); + var availability = await provider.CheckAvailabilityAsync(); + if (!availability.IsReady) return; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var result = await provider.ConvertAsync(html, dir, ".png", new ConvertOptions(), null, cts.Token); + + Assert.Equal(ConvertStatus.Success, result.Status); + Assert.NotEmpty(result.OutputPaths); + Assert.True(File.Exists(result.OutputPaths[0])); + Assert.True(new FileInfo(result.OutputPaths[0]).Length > 0); + } +} diff --git a/src/Everything2Everything.Tests/MarkdownProviderTests.cs b/src/Everything2Everything.Tests/MarkdownProviderTests.cs new file mode 100644 index 0000000..aebded2 --- /dev/null +++ b/src/Everything2Everything.Tests/MarkdownProviderTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Converters; +using Everything2Everything.Core.Providers; +using Xunit; + +namespace Everything2Everything.Tests; + +public class MarkdownProviderTests +{ + private static string TempDir() + { + var d = Path.Combine(Path.GetTempPath(), "e2e_md_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(d); + return d; + } + + [Fact] + public async Task MarkdownProvider_HtmlToMd_ConvertsProperly() + { + var dir = TempDir(); + var html = Path.Combine(dir, "input.html"); + await File.WriteAllTextAsync(html, "

Title Here

Paragraph with bold text

"); + + var provider = new MarkdownProvider(); + var res = await provider.ConvertAsync(html, dir, ".md", new ConvertOptions(), null, CancellationToken.None); + + Assert.Equal(ConvertStatus.Success, res.Status); + var content = await File.ReadAllTextAsync(res.OutputPaths[0]); + Assert.Contains("# Title Here", content); + Assert.Contains("**bold**", content); + } + + [Fact] + public async Task MarkdownProvider_MdToHtml_ConvertsProperly() + { + var dir = TempDir(); + var md = Path.Combine(dir, "input.md"); + await File.WriteAllTextAsync(md, "# Hello Markdown\n\nThis is a *test*."); + + var provider = new MarkdownProvider(); + var res = await provider.ConvertAsync(md, dir, ".html", new ConvertOptions(), null, CancellationToken.None); + + Assert.Equal(ConvertStatus.Success, res.Status); + var content = await File.ReadAllTextAsync(res.OutputPaths[0]); + Assert.Contains("Hello Markdown", content); + Assert.Contains("test", content); + } + + [Fact] + public async Task MarkdownProvider_Availability_IsAlwaysReady() + { + var provider = new MarkdownProvider(); + var avail = await provider.CheckAvailabilityAsync(); + Assert.True(avail.IsReady); + Assert.Equal(ProviderStatus.Available, provider.Capability.Status); + } + + [Fact] + public async Task Engine_HtmlToMd_RoutesToMarkdownProvider() + { + var dir = TempDir(); + var html = Path.Combine(dir, "input.html"); + await File.WriteAllTextAsync(html, "

Section

Content

"); + + var engine = Everything2EverythingBootstrap.CreateDefault(); + var path = engine.Providers.Graph.FindBestPath(".html", ".md"); + Assert.NotNull(path); + Assert.Single(path); + Assert.IsType(path![0].Provider); + + var opt = new ConvertOptions { OutputLocation = OutputLocation.Custom, CustomOutputDirectory = dir }; + var res = await engine.ConvertOneAsync(html, ".md", opt); + Assert.Equal(ConvertStatus.Success, res.Status); + } +} diff --git a/src/Everything2Everything.Tests/MatrixIntegrityTests.cs b/src/Everything2Everything.Tests/MatrixIntegrityTests.cs new file mode 100644 index 0000000..3bb944e --- /dev/null +++ b/src/Everything2Everything.Tests/MatrixIntegrityTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Providers; +using Xunit; +using Xunit.Abstractions; + +namespace Everything2Everything.Tests; + +public class MatrixIntegrityTests +{ + private readonly ITestOutputHelper _output; + + public MatrixIntegrityTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void Audit_AllRegisteredProviders_AndPrintMatrix() + { + var engine = Everything2EverythingBootstrap.CreateDefault(); + var providers = engine.Providers.All.ToList(); + + _output.WriteLine($"=== Total Registered Providers: {providers.Count} ==="); + foreach (var p in providers) + { + var cap = p.Capability; + _output.WriteLine($"Provider [{cap.Id}] {cap.DisplayName} | Status: {cap.Status} | Pairs: {cap.SupportedConversions.Count}"); + var missingDeps = cap.ExternalDependencies.Where(d => d.IsRequired).Select(d => d.Name).ToList(); + if (missingDeps.Any()) + { + _output.WriteLine($" Required deps: {string.Join(", ", missingDeps)}"); + } + } + Assert.NotEmpty(providers); + } + + [Fact] + public void Audit_HtmlReachability_InGraph() + { + var engine = Everything2EverythingBootstrap.CreateDefault(); + var outputs = engine.Providers.OutputsForInput(".html"); + _output.WriteLine($"HTML Reachable Outputs ({outputs.Count}): {string.Join(", ", outputs)}"); + + Assert.Contains(".pdf", outputs); + Assert.Contains(".png", outputs); + Assert.Contains(".jpg", outputs); + Assert.Contains(".webp", outputs); + Assert.Contains(".md", outputs); + } +}