1
0
Fork 0

fix(converters): fix HtmlProvider WebView2 thread crash and add MarkdownProvider for pure .NET conversions

This commit is contained in:
Yun Chan 2026-09-08 04:16:48 +09:00
parent 63d50c165d
commit 4e5285e27c
7 changed files with 447 additions and 28 deletions

View file

@ -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(

View file

@ -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<byte[]> RunCaptureOnDispatcher(
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
Dispatcher dispatcher,
string sourcePath,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken,
TaskCompletionSource<byte[]> 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 { }
}
}

View file

@ -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<ExternalDependency>(),
RoadmapNote: null);
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(ProviderAvailability.Ready);
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? 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 = $"<!DOCTYPE html><html lang=\"ko\"><head><meta charset=\"utf-8\"><title>{Path.GetFileNameWithoutExtension(mdPath)}</title></head><body>{body}</body></html>";
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", "<br/>\n");
var fullHtml = $"<!DOCTYPE html><html lang=\"ko\"><head><meta charset=\"utf-8\"><title>{Path.GetFileNameWithoutExtension(txtPath)}</title></head><body><pre style=\"font-family: 'Segoe UI', sans-serif; white-space: pre-wrap;\">{encoded}</pre></body></html>";
await File.WriteAllTextAsync(htmlPath, fullHtml, Encoding.UTF8, ct).ConfigureAwait(false);
}
}

View file

@ -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, "<html><body><h1 style='color:red;'>Test Page</h1><p>Paragraph content</p></body></html>");
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, "<html><body><h1>Markdown Title</h1><p>Bold <b>text</b></p></body></html>");
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}");
}
}

View file

@ -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, "<html><body><h1>Hello HTML Test</h1><p>Testing HTML to PDF</p></body></html>");
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, "<html><body><h1>Hello HTML Test</h1><p>Testing HTML to PNG</p></body></html>");
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);
}
}

View file

@ -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, "<h1>Title Here</h1><p>Paragraph with <strong>bold</strong> text</p>");
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</h1>", content);
Assert.Contains("<em>test</em>", 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, "<h2>Section</h2><p>Content</p>");
var engine = Everything2EverythingBootstrap.CreateDefault();
var path = engine.Providers.Graph.FindBestPath(".html", ".md");
Assert.NotNull(path);
Assert.Single(path);
Assert.IsType<MarkdownProvider>(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);
}
}

View file

@ -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);
}
}