1
0
Fork 0

Compare commits

..

No commits in common. "main" and "v1.0.21" have entirely different histories.

13 changed files with 76 additions and 711 deletions

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com"> <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" IgnorableNamespaces="uap rescap desktop desktop4 desktop5 com">
<Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.22.0" ProcessorArchitecture="x64" /> <Identity Name="Everything2Everything.YunChan" Publisher="CN=Everything2EverythingDev" Version="1.0.21.0" ProcessorArchitecture="x64" />
<Properties> <Properties>
<DisplayName>Everything2Everything</DisplayName> <DisplayName>Everything2Everything</DisplayName>
<PublisherDisplayName>YunChan</PublisherDisplayName> <PublisherDisplayName>YunChan</PublisherDisplayName>

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<Version>1.0.22</Version> <Version>1.0.21</Version>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework> <TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -35,29 +35,6 @@
<Resource Include="Assets\**\*.png" /> <Resource Include="Assets\**\*.png" />
</ItemGroup> </ItemGroup>
<!-- Windows 11 모던 1차 컨텍스트 메뉴용 Shell 확장 DLL 및 매니페스트 번들 -->
<ItemGroup>
<None Include="..\Everything2Everything.Shell\x64\Release\Everything2Everything.Shell.dll" Condition="Exists('..\Everything2Everything.Shell\x64\Release\Everything2Everything.Shell.dll')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
<None Include="..\..\packaging\Layout\Everything2Everything.Shell.dll" Condition="!Exists('..\Everything2Everything.Shell\x64\Release\Everything2Everything.Shell.dll') and Exists('..\..\packaging\Layout\Everything2Everything.Shell.dll')">
<Link>Everything2Everything.Shell.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
<None Include="..\..\packaging\Package.appxmanifest" Condition="Exists('..\..\packaging\Package.appxmanifest')">
<Link>AppxManifest.xml</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
<None Include="..\..\packaging\Assets\**\*" Condition="Exists('..\..\packaging\Assets')">
<Link>Assets\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project> </Project>
@ -67,4 +44,3 @@

View file

@ -10,69 +10,6 @@ public static class ContextMenuRegistrar
private const string MainLabel = "Everything2Everything으로 변환"; private const string MainLabel = "Everything2Everything으로 변환";
private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu."; private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu.";
public const string ClassicContextMenuOverrideKey = @"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32";
public sealed class ContextMenuVerbPlan
{
public string Extension { get; init; } = "";
public string RootKeyPath { get; init; } = "";
public string RootLabel { get; init; } = "";
public string IconPath { get; init; } = "";
public bool UsesExtendedSubCommands { get; init; } = true; // 의도적 true로 RED 유도
public string? SubCommandsValue { get; init; } = null;
public IReadOnlyList<ContextMenuItemPlan> Items { get; init; } = Array.Empty<ContextMenuItemPlan>();
}
public sealed class ContextMenuItemPlan
{
public string SubKeyPath { get; init; } = "";
public string VerbName { get; init; } = "";
public string Label { get; init; } = "";
public string IconPath { get; init; } = "";
public string Command { get; init; } = "";
}
public static ContextMenuVerbPlan BuildVerbPlan(ConversionEngine engine, string ext, string exePath)
{
var availableOutputs = GetAvailableOutputs(engine, ext);
var icon = exePath + ",0";
var rootKeyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}";
var items = new List<ContextMenuItemPlan>();
foreach (var (outExt, outLabel, sortPrefix) in availableOutputs)
{
var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}";
var cliExt = outExt.TrimStart('.');
items.Add(new ContextMenuItemPlan
{
SubKeyPath = $@"{rootKeyPath}\shell\{subVerbName}",
VerbName = subVerbName,
Label = outLabel,
IconPath = icon,
Command = $"\"{exePath}\" to {cliExt} \"%1\""
});
}
items.Add(new ContextMenuItemPlan
{
SubKeyPath = $@"{rootKeyPath}\shell\98_dialog",
VerbName = "98_dialog",
Label = "변환… (옵션 선택)",
IconPath = icon,
Command = $"\"{exePath}\" dialog \"%1\""
});
return new ContextMenuVerbPlan
{
Extension = ext,
RootKeyPath = rootKeyPath,
RootLabel = MainLabel,
IconPath = icon,
UsesExtendedSubCommands = false, // Shift 불필요: 마우스 일반 우클릭으로 즉시 펼쳐짐
SubCommandsValue = "",
Items = items
};
}
private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs = private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs =
{ {
@ -107,16 +44,16 @@ public static class ContextMenuRegistrar
public static void Register(ConversionEngine engine) public static void Register(ConversionEngine engine)
{ {
var exe = GetAppExecutablePath(); var exe = GetAppExecutablePath();
var icon = exe + ",0";
foreach (var ext in CollectInputExtensions(engine)) foreach (var ext in CollectInputExtensions(engine))
{ {
var plan = BuildVerbPlan(engine, ext, exe); var availableOutputs = GetAvailableOutputs(engine, ext);
if (plan.Items.Count == 0) continue; if (availableOutputs.Count == 0) continue;
WriteCascade(plan, ext); WriteCascade(ext, exe, icon, availableOutputs);
} }
TryRegisterWindows11SparsePackage();
NotifyShell(); NotifyShell();
} }
@ -129,44 +66,9 @@ public static class ContextMenuRegistrar
DeleteVerb(ext, "Everything2Everything.Dialog"); DeleteVerb(ext, "Everything2Everything.Dialog");
DeleteSubmenuTree(ext); DeleteSubmenuTree(ext);
} }
TryUnregisterWindows11SparsePackage();
NotifyShell(); NotifyShell();
} }
public static bool IsClassicContextMenuEnabled()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(ClassicContextMenuOverrideKey);
return key != null;
}
catch
{
return false;
}
}
public static void SetClassicContextMenuEnabled(bool enable)
{
try
{
if (enable)
{
using var key = Registry.CurrentUser.CreateSubKey(ClassicContextMenuOverrideKey, writable: true);
key?.SetValue(null, "", RegistryValueKind.String);
}
else
{
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}", throwOnMissingSubKey: false);
}
NotifyShell();
}
catch
{
}
}
private static IEnumerable<string> CollectInputExtensions(ConversionEngine engine) private static IEnumerable<string> CollectInputExtensions(ConversionEngine engine)
{ {
return engine.Providers.Implemented return engine.Providers.Implemented
@ -177,25 +79,28 @@ public static class ContextMenuRegistrar
.Distinct(); .Distinct();
} }
private static void WriteCascade(ContextMenuVerbPlan plan, string ext) private static void WriteCascade(
string ext,
string exe,
string icon,
IReadOnlyList<(string Ext, string Label, string SortPrefix)> availableOutputs)
{ {
// 1. 루트 키 등록 (Shift 없는 일반 우클릭에서 즉시 서브메뉴 노출) var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.');
using (var verbKey = Registry.CurrentUser.CreateSubKey(plan.RootKeyPath, writable: true)
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {plan.RootKeyPath}"))
{
verbKey.SetValue(null, plan.RootLabel, RegistryValueKind.String);
verbKey.SetValue("MUIVerb", plan.RootLabel, RegistryValueKind.String);
verbKey.SetValue("Icon", plan.IconPath, RegistryValueKind.String);
verbKey.SetValue("SubCommands", plan.SubCommandsValue ?? "", RegistryValueKind.String);
// 구버전에서 Shift 키를 강제하던 ExtendedSubCommandsKey 제거 var verbPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}";
try { verbKey.DeleteValue("ExtendedSubCommandsKey", throwOnMissingValue: false); } catch { } using (var verbKey = Registry.CurrentUser.CreateSubKey(verbPath, writable: true)
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {verbPath}"))
{
verbKey.SetValue(null, MainLabel, RegistryValueKind.String);
verbKey.SetValue("MUIVerb", MainLabel, RegistryValueKind.String);
verbKey.SetValue("Icon", icon, RegistryValueKind.String);
verbKey.SetValue("SubCommands", "", RegistryValueKind.String);
verbKey.SetValue("ExtendedSubCommandsKey", submenuKeyName, RegistryValueKind.String);
try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { } try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { }
} }
// 2. 루트 직하위 shell 키 정리 및 서브메뉴 항목 등록 var submenuShellPath = $@"Software\Classes\{submenuKeyName}\shell";
var shellRootPath = $@"{plan.RootKeyPath}\shell"; using (var existing = Registry.CurrentUser.OpenSubKey(submenuShellPath, writable: true))
using (var existing = Registry.CurrentUser.OpenSubKey(shellRootPath, writable: true))
{ {
if (existing is not null) if (existing is not null)
{ {
@ -206,22 +111,31 @@ public static class ContextMenuRegistrar
} }
} }
foreach (var item in plan.Items) foreach (var (outExt, outLabel, sortPrefix) in availableOutputs)
{ {
using var key = Registry.CurrentUser.CreateSubKey(item.SubKeyPath, writable: true) var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}";
?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {item.SubKeyPath}"); var cliExt = outExt.TrimStart('.');
WriteSubmenuItem(submenuKeyName, subVerbName, outLabel, icon,
key.SetValue(null, item.Label, RegistryValueKind.String); $"\"{exe}\" to {cliExt} \"%1\"");
key.SetValue("MUIVerb", item.Label, RegistryValueKind.String);
key.SetValue("Icon", item.IconPath, RegistryValueKind.String);
using var commandKey = key.CreateSubKey("command", writable: true)
?? throw new InvalidOperationException("command 하위 키 생성 실패");
commandKey.SetValue(null, item.Command, RegistryValueKind.String);
} }
// 3. 구버전 독립 SubMenu 키 정리 (이전 버전 찌꺼기 제거) WriteSubmenuItem(submenuKeyName, "98_dialog", "변환… (옵션 선택)", icon,
DeleteSubmenuTree(ext); $"\"{exe}\" dialog \"%1\"");
}
private static void WriteSubmenuItem(string submenuKeyName, string verbName, string label, string icon, string command)
{
var path = $@"Software\Classes\{submenuKeyName}\shell\{verbName}";
using var key = Registry.CurrentUser.CreateSubKey(path, writable: true)
?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {path}");
key.SetValue(null, label, RegistryValueKind.String);
key.SetValue("MUIVerb", label, RegistryValueKind.String);
key.SetValue("Icon", icon, RegistryValueKind.String);
using var commandKey = key.CreateSubKey("command", writable: true)
?? throw new InvalidOperationException("command 하위 키 생성 실패");
commandKey.SetValue(null, command, RegistryValueKind.String);
} }
private static void DeleteVerb(string ext, string verb) private static void DeleteVerb(string ext, string verb)
@ -258,70 +172,6 @@ public static class ContextMenuRegistrar
+ Path.DirectorySeparatorChar + "Everything2Everything.exe"; + Path.DirectorySeparatorChar + "Everything2Everything.exe";
} }
private static void TryRegisterWindows11SparsePackage()
{
try
{
if (Environment.OSVersion.Version.Build < 22000) return;
var manifestPath = FindAppxManifestPath();
if (string.IsNullOrEmpty(manifestPath) || !File.Exists(manifestPath)) return;
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"Add-AppxPackage -Register '{manifestPath}'\"",
CreateNoWindow = true,
UseShellExecute = false
};
using var proc = System.Diagnostics.Process.Start(psi);
proc?.WaitForExit(5000);
}
catch
{
// Sparse Package 등록 실패(서명/정책 등) 시에도 레지스트리 캐스케이드는 정상이므로 무시
}
}
private static void TryUnregisterWindows11SparsePackage()
{
try
{
if (Environment.OSVersion.Version.Build < 22000) return;
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = "-NoProfile -NonInteractive -WindowStyle Hidden -Command \"Get-AppxPackage -Name '*Everything2Everything*' | Remove-AppxPackage\"",
CreateNoWindow = true,
UseShellExecute = false
};
using var proc = System.Diagnostics.Process.Start(psi);
proc?.WaitForExit(5000);
}
catch
{
}
}
public static string? FindAppxManifestPath()
{
var baseDir = AppContext.BaseDirectory;
var candidates = new[]
{
Path.Combine(baseDir, "AppxManifest.xml"),
Path.Combine(baseDir, "Package.appxmanifest"),
Path.Combine(baseDir, "..", "packaging", "Package.appxmanifest"),
Path.Combine(baseDir, "..", "..", "packaging", "Package.appxmanifest")
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate)) return Path.GetFullPath(candidate);
}
return null;
}
private static void NotifyShell() private static void NotifyShell()
{ {
try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); } try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }

View file

@ -1213,7 +1213,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
{ {
ContextMenuRegistrar.Register(_engine); ContextMenuRegistrar.Register(_engine);
MessageBox.Show(this, MessageBox.Show(this,
"컨텍스트 메뉴를 성공적으로 등록했습니다.\n파일 우클릭 시 \"Everything2Everything으로 변환\" 메뉴가 즉시 표시됩니다.", "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".",
"Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information); "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information);
} }
catch (Exception ex) catch (Exception ex)

View file

@ -17,22 +17,17 @@ namespace Everything2Everything.Core.Converters;
// txt ↔ md → trivial copy // txt ↔ md → trivial copy
public sealed class DocumentProvider : IConverterProvider public sealed class DocumentProvider : IConverterProvider
{ {
private static readonly string[] OfficeInputs = { ".docx", ".doc", ".hwp", ".hwpx" }; private static readonly string[] Inputs =
private static readonly string[] OfficeOutputs = { ".html", ".docx", ".md", ".txt" }; { ".html", ".htm", ".hwp", ".hwpx", ".docx", ".doc", ".md", ".markdown", ".txt" };
private static readonly string[] TextInputsToDocx = { ".html", ".htm", ".md", ".markdown", ".txt" };
private static readonly string[] Outputs = { ".html", ".docx", ".md", ".txt" };
public ProviderCapability Capability { get; } = new( public ProviderCapability Capability { get; } = new(
Id: "document", Id: "document",
DisplayName: "문서 텍스트 변환 (DOCX/HWP/오피스)", DisplayName: "문서 텍스트 변환 (HTML/HWP/DOCX/MD/TXT)",
SupportedConversions: ProviderCapability.PairsFromMatrix(OfficeInputs, OfficeOutputs) SupportedConversions: ProviderCapability.PairsFromMatrix(Inputs, Outputs),
.Concat(ProviderCapability.PairsFromMatrix(TextInputsToDocx, new[] { ".docx" }))
.Concat(new[]
{
new ConversionPair(".html", ".txt", LossClass.Recode),
new ConversionPair(".htm", ".txt", LossClass.Recode),
}).ToList(),
Status: ProviderStatus.RequiresExternal, Status: ProviderStatus.RequiresExternal,
Summary: "DOCX·HWP 문서를 HTML/DOCX/MD/TXT로 변환하거나 텍스트를 DOCX로 저장합니다 (LibreOffice 엔진).", Summary: "HTML·HWP·DOCX·Markdown·TXT 사이의 양방향 텍스트 변환 (LibreOffice + Markdig + ReverseMarkdown).",
ExternalDependencies: new[] ExternalDependencies: new[]
{ {
new ExternalDependency( new ExternalDependency(

View file

@ -1,7 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Windows; using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading; using System.Windows.Threading;
using Everything2Everything.Core.Providers; using Everything2Everything.Core.Providers;
using ImageMagick; using ImageMagick;
@ -166,25 +165,7 @@ public sealed class HtmlProvider : IConverterProvider
try try
{ {
var dispatcher = Dispatcher.CurrentDispatcher; var dispatcher = Dispatcher.CurrentDispatcher;
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher)); _ = RunCaptureOnDispatcher(capture, dispatcher, sourcePath, options, progress, cancellationToken, tcs);
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(); Dispatcher.Run();
} }
catch (Exception ex) catch (Exception ex)
@ -200,15 +181,16 @@ public sealed class HtmlProvider : IConverterProvider
return tcs.Task; return tcs.Task;
} }
private static async Task<byte[]> RunCaptureOnDispatcher( private static async Task RunCaptureOnDispatcher(
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture, Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
Dispatcher dispatcher,
string sourcePath, string sourcePath,
ConvertOptions options, ConvertOptions options,
IProgress<double>? progress, IProgress<double>? progress,
CancellationToken cancellationToken) CancellationToken cancellationToken,
TaskCompletionSource<byte[]> tcs)
{ {
CoreWebView2Controller? controller = null; CoreWebView2Controller? controller = null;
HwndSource? hwndSource = null;
try try
{ {
var userDataFolder = Path.Combine( var userDataFolder = Path.Combine(
@ -216,23 +198,15 @@ public sealed class HtmlProvider : IConverterProvider
"Everything2Everything", "WebView2"); "Everything2Everything", "WebView2");
Directory.CreateDirectory(userDataFolder); Directory.CreateDirectory(userDataFolder);
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder); var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
progress?.Report(0.15); progress?.Report(0.15);
controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280; int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280;
int height = options.HtmlRender.ViewportHeight ?? 720; 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.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
controller.IsVisible = true; controller.IsVisible = false;
var web = controller.CoreWebView2; var web = controller.CoreWebView2;
progress?.Report(0.3); progress?.Report(0.3);
@ -243,31 +217,36 @@ public sealed class HtmlProvider : IConverterProvider
{ {
web.NavigationCompleted -= navHandler!; web.NavigationCompleted -= navHandler!;
if (e.IsSuccess) navTcs.TrySetResult(true); if (e.IsSuccess) navTcs.TrySetResult(true);
else navTcs.TrySetException(new InvalidOperationException($"내비게이션 실패: {e.WebErrorStatus}")); else navTcs.TrySetException(new InvalidOperationException(
$"내비게이션 실패: {e.WebErrorStatus}"));
}; };
web.NavigationCompleted += navHandler; web.NavigationCompleted += navHandler;
var fileUri = new Uri(Path.GetFullPath(sourcePath)).AbsoluteUri; var fileUri = new Uri(sourcePath).AbsoluteUri;
web.Navigate(fileUri); web.Navigate(fileUri);
using (cancellationToken.Register(() => navTcs.TrySetCanceled())) using (cancellationToken.Register(() => navTcs.TrySetCanceled()))
{ {
await navTcs.Task; await navTcs.Task.ConfigureAwait(true);
} }
progress?.Report(0.5); progress?.Report(0.5);
if (options.HtmlRender.WaitMilliseconds > 0) if (options.HtmlRender.WaitMilliseconds > 0)
await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken); await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken).ConfigureAwait(true);
progress?.Report(0.65); progress?.Report(0.65);
var bytes = await capture(web, progress); var bytes = await capture(web, progress).ConfigureAwait(true);
return bytes; tcs.TrySetResult(bytes);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
} }
finally finally
{ {
try { controller?.Close(); } catch { } try { controller?.Close(); } catch { }
try { hwndSource?.Dispose(); } catch { } dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
} }
} }

View file

@ -1,117 +0,0 @@
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

@ -56,44 +56,4 @@ public class ContextMenuRegistrarTests
Assert.DoesNotContain(outputs, o => o.Ext == ".gif"); Assert.DoesNotContain(outputs, o => o.Ext == ".gif");
Assert.DoesNotContain(outputs, o => o.Ext == ".mp4"); Assert.DoesNotContain(outputs, o => o.Ext == ".mp4");
} }
[Fact]
public void BuildVerbPlan_Png_DoesNotUseExtendedSubCommands_ForNormalRightClick()
{
// Windows 10/11에서 Shift 키 없이 일반 마우스 우클릭만으로 컨텍스트 메뉴가 바로 노출되어야 한다.
// ExtendedSubCommandsKey가 사용되면 Shift를 누를 때만 서브메뉴가 나오는 심각한 버그가 발생한다.
var engine = CreateEngine();
var plan = ContextMenuRegistrar.BuildVerbPlan(engine, ".png", @"C:\Apps\E2E\Everything2Everything.exe");
Assert.False(plan.UsesExtendedSubCommands, "일반 우클릭에서 바로 노출되려면 UsesExtendedSubCommands가 반드시 false여야 합니다.");
Assert.Equal("", plan.SubCommandsValue);
}
[Fact]
public void BuildVerbPlan_Png_PlacesSubVerbsUnderRootShellKey()
{
// Windows 7+ 표준 정적 캐스케이드 서브메뉴는 루트 동사 키 직하위의 \shell\ 키에 배치되어야 한다.
var engine = CreateEngine();
var plan = ContextMenuRegistrar.BuildVerbPlan(engine, ".png", @"C:\Apps\E2E\Everything2Everything.exe");
Assert.NotEmpty(plan.Items);
var expectedPrefix = @"Software\Classes\SystemFileAssociations\.png\shell\Everything2Everything\shell\";
foreach (var item in plan.Items)
{
Assert.StartsWith(expectedPrefix, item.SubKeyPath);
}
// 대화상자 선택 메뉴(98_dialog)가 포함되어 있는지 검증
var dialogItem = plan.Items.FirstOrDefault(i => i.VerbName.EndsWith("dialog"));
Assert.NotNull(dialogItem);
Assert.Contains("dialog", dialogItem.Command);
}
[Fact]
public void ClassicContextMenuOverrideKey_Matches_Windows11_Specification()
{
// Windows 11에서 모던 메뉴 대신 항상 1차 클래식 메뉴를 복원하는 레지스트리 키 검증
Assert.Equal(@"Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32",
ContextMenuRegistrar.ClassicContextMenuOverrideKey);
}
} }

View file

@ -22,8 +22,7 @@ public class DependencyInjectionTests
using var sp = services.BuildServiceProvider(); using var sp = services.BuildServiceProvider();
var providers = sp.GetServices<IConverterProvider>().ToList(); var providers = sp.GetServices<IConverterProvider>().ToList();
Assert.Equal(15, providers.Count); // MarkdownProvider 추가로 총 15개 Provider Assert.Equal(14, providers.Count); // 하드코딩 14개와 동일 — Scrutor 누락/초과 방어
Assert.NotNull(sp.GetRequiredService<MarkdownProvider>());
Assert.NotNull(sp.GetRequiredService<ProviderRegistry>()); Assert.NotNull(sp.GetRequiredService<ProviderRegistry>());
Assert.NotNull(sp.GetRequiredService<ConversionEngine>()); Assert.NotNull(sp.GetRequiredService<ConversionEngine>());
@ -43,7 +42,6 @@ public class DependencyInjectionTests
Assert.NotNull(sp.GetRequiredService<OcrProvider>()); // ← PdfProvider 주입 Assert.NotNull(sp.GetRequiredService<OcrProvider>()); // ← PdfProvider 주입
Assert.NotNull(sp.GetRequiredService<HwpxProvider>()); // ← PdfProvider 주입 Assert.NotNull(sp.GetRequiredService<HwpxProvider>()); // ← PdfProvider 주입
Assert.NotNull(sp.GetRequiredService<LlmProvider>()); // ← ISettingsStore 주입 Assert.NotNull(sp.GetRequiredService<LlmProvider>()); // ← ISettingsStore 주입
Assert.NotNull(sp.GetRequiredService<MarkdownProvider>());
// AsSelfWithInterfaces: 구체 타입과 인터페이스가 동일 싱글턴 인스턴스를 공유. // AsSelfWithInterfaces: 구체 타입과 인터페이스가 동일 싱글턴 인스턴스를 공유.
var magickAsSelf = sp.GetRequiredService<MagickProvider>(); var magickAsSelf = sp.GetRequiredService<MagickProvider>();
@ -81,7 +79,7 @@ public class DependencyInjectionTests
{ {
// 파사드 하위호환: DI 내부전환 후에도 CreateDefault가 동일하게 엔진/그래프를 구성. // 파사드 하위호환: DI 내부전환 후에도 CreateDefault가 동일하게 엔진/그래프를 구성.
var engine = Everything2EverythingBootstrap.CreateDefault(); var engine = Everything2EverythingBootstrap.CreateDefault();
Assert.Equal(15, engine.Providers.All.Count); Assert.Equal(14, engine.Providers.All.Count);
Assert.NotNull(engine.Providers.Graph.FindBestPath(".png", ".jpg")); Assert.NotNull(engine.Providers.Graph.FindBestPath(".png", ".jpg"));
Assert.NotNull(engine.Providers.Graph.FindBestPath(".svg", ".jpg", maxHops: 3)); Assert.NotNull(engine.Providers.Graph.FindBestPath(".svg", ".jpg", maxHops: 3));
} }

View file

@ -1,81 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,79 +0,0 @@
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

@ -1,56 +0,0 @@
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);
}
}