diff --git a/README.md b/README.md
index 09f9e37..f72f957 100644
--- a/README.md
+++ b/README.md
@@ -49,9 +49,9 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
| PNG · BMP · JPEG · WebP · AVIF · PSD · TIFF · GIF · RAW(NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | ✅ 준비됨 | Magick.NET 14.x |
| HEIC · HEIF | ✅ 준비됨 | PhotoSauce + libheif 디코드 |
| PDF | ✅ 준비됨 | PDFtoImage(PDFium) |
+| HTML · HTM | ✅ 준비됨 | WebView2 헤드리스 + CDP `Page.captureScreenshot` 풀페이지 |
| DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 |
-| HTML · HTM | 🕐 개발 중 | WebView2 헤드리스 캡처 예정 |
-| HWP · HWPX | 🕐 개발 중 | LibreOffice + H2Orestart 파이프라인 예정 |
+| HWP · HWPX | ⚙ 외부 도구 필요 | LibreOffice + [H2Orestart](https://github.com/ebandal/H2Orestart) 확장 |
## 기술 스택
@@ -64,8 +64,8 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj `
| 단계 | 상태 | 내용 |
|---|---|---|
| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환(이미지·HEIC·RAW·PDF·DOCX), Fluent UI |
-| Phase 2 | ✅ 빌드 가능 | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 |
-| Phase 3 | 🕐 | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 |
+| Phase 2 | ✅ | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 |
+| Phase 3 | ✅ | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 |
## 두 가지 사용 방식
diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest
index 91c4cdb..f79706a 100644
--- a/packaging/Package.appxmanifest
+++ b/packaging/Package.appxmanifest
@@ -170,6 +170,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
index 15dcce1..ad409fd 100644
--- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
+++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
@@ -73,7 +73,7 @@ public partial class ConvertWindow : FluentWindow
{
Multiselect = true,
Title = "추가할 파일 선택",
- Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc|모든 파일|*.*",
+ Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*",
};
if (dlg.ShowDialog(this) == true) AddFiles(dlg.FileNames);
}
diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
index aac1bf4..bc72514 100644
--- a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
+++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
@@ -193,7 +193,7 @@ public partial class MainWindow : FluentWindow, INotifyPropertyChanged
{
Title = "변환할 파일 선택",
Multiselect = true,
- Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc|모든 파일|*.*",
+ Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*",
};
if (dlg.ShowDialog(this) == true)
{
diff --git a/src/EverythingToJpeg.Core/ConvertOptions.cs b/src/EverythingToJpeg.Core/ConvertOptions.cs
index 46db7b6..6de34bb 100644
--- a/src/EverythingToJpeg.Core/ConvertOptions.cs
+++ b/src/EverythingToJpeg.Core/ConvertOptions.cs
@@ -36,5 +36,13 @@ public sealed class ConvertOptions
public string TransparencyBackground { get; set; } = "#FFFFFF";
+ public int HtmlViewportWidth { get; set; } = 1280;
+
+ public int? HtmlViewportHeight { get; set; }
+
+ public int HtmlWaitMilliseconds { get; set; } = 2000;
+
+ public bool HtmlFullPage { get; set; } = true;
+
public static ConvertOptions Quick() => new();
}
diff --git a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs b/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
index 31f08ad..6f37a01 100644
--- a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
+++ b/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
@@ -48,4 +48,38 @@ internal static class ExternalToolDetector
return false;
}
}
+
+ public static bool IsH2OrestartInstalled()
+ {
+ try
+ {
+ var roots = new[]
+ {
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ };
+ foreach (var root in roots)
+ {
+ if (string.IsNullOrEmpty(root)) continue;
+ var loDir = Path.Combine(root, "LibreOffice", "4", "user", "uno_packages", "cache", "uno_packages");
+ if (Directory.Exists(loDir))
+ {
+ foreach (var dir in Directory.EnumerateDirectories(loDir, "*H2Orestart*", SearchOption.AllDirectories))
+ {
+ if (Directory.Exists(dir)) return true;
+ }
+ }
+ var extDir = Path.Combine(root, "LibreOffice", "4", "user", "extensions", "bundled");
+ if (Directory.Exists(extDir))
+ {
+ foreach (var dir in Directory.EnumerateDirectories(extDir, "*H2O*", SearchOption.AllDirectories))
+ {
+ if (Directory.Exists(dir)) return true;
+ }
+ }
+ }
+ }
+ catch { }
+ return false;
+ }
}
diff --git a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs b/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
index 6deaeb0..66edad1 100644
--- a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
+++ b/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
@@ -1,4 +1,10 @@
+using System.Text.Json;
+using System.Threading;
+using System.Windows;
+using System.Windows.Threading;
using EverythingToJpeg.Core.Providers;
+using ImageMagick;
+using Microsoft.Web.WebView2.Core;
namespace EverythingToJpeg.Core.Converters;
@@ -8,8 +14,8 @@ public sealed class HtmlProvider : IConverterProvider
Id: "html",
DisplayName: "HTML / 웹 페이지",
Extensions: new[] { ".html", ".htm" },
- Status: ProviderStatus.ComingSoon,
- Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 JPEG로 캡처합니다.",
+ Status: ProviderStatus.Available,
+ Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 풀페이지 JPEG로 캡처합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@@ -18,13 +24,176 @@ public sealed class HtmlProvider : IConverterProvider
DownloadUrl: "https://developer.microsoft.com/microsoft-edge/webview2/",
IsRequired: true),
},
- RoadmapNote: "Phase 2 — WebView2 헤드리스 캡처 + 사용자 정의 viewport.");
+ RoadmapNote: null);
public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
- => Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
+ {
+ try
+ {
+ var version = CoreWebView2Environment.GetAvailableBrowserVersionString();
+ if (string.IsNullOrEmpty(version))
+ return Task.FromResult(ProviderAvailability.NotReady(
+ "WebView2 Runtime이 설치되어 있지 않습니다.",
+ Capability.ExternalDependencies));
+ return Task.FromResult(ProviderAvailability.Ready);
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(ProviderAvailability.NotReady(
+ "WebView2 감지 실패: " + ex.Message,
+ Capability.ExternalDependencies));
+ }
+ }
- public Task ConvertAsync(
- string sourcePath, string outputDirectory, ConvertOptions options,
- IProgress? progress, CancellationToken cancellationToken)
- => Task.FromResult(ConvertResult.Skip(sourcePath, "HTML 변환은 곧 지원 예정입니다."));
+ public async Task ConvertAsync(
+ string sourcePath,
+ string outputDirectory,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ var pngBytes = await CapturePngAsync(sourcePath, options, progress, cancellationToken)
+ .ConfigureAwait(false);
+
+ progress?.Report(0.85);
+
+ await Task.Run(() =>
+ {
+ using var image = new MagickImage(pngBytes);
+ if (options.FlattenTransparency && image.HasAlpha)
+ {
+ image.BackgroundColor = new MagickColor(options.TransparencyBackground);
+ image.Alpha(AlphaOption.Remove);
+ image.Alpha(AlphaOption.Off);
+ }
+ if (options.MaxLongEdgePixels is int maxLong && maxLong > 0
+ && (image.Width > (uint)maxLong || image.Height > (uint)maxLong))
+ {
+ image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
+ }
+ image.Quality = (uint)Math.Clamp(options.Quality, 1, 100);
+ image.Format = MagickFormat.Jpeg;
+ image.Write(path);
+ }, cancellationToken).ConfigureAwait(false);
+
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { path });
+ }
+
+ private static Task CapturePngAsync(
+ string sourcePath,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var dispatcher = Dispatcher.CurrentDispatcher;
+ _ = RunCaptureOnDispatcher(dispatcher, sourcePath, options, progress, cancellationToken, tcs);
+ Dispatcher.Run();
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.IsBackground = true;
+ thread.Name = "EverythingToJpeg.HtmlCapture";
+ thread.Start();
+
+ return tcs.Task;
+ }
+
+ private static async Task RunCaptureOnDispatcher(
+ Dispatcher dispatcher,
+ string sourcePath,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken,
+ TaskCompletionSource tcs)
+ {
+ CoreWebView2Controller? controller = null;
+ try
+ {
+ var userDataFolder = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "EverythingToJpeg", "WebView2");
+ Directory.CreateDirectory(userDataFolder);
+
+ var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
+ progress?.Report(0.15);
+
+ // HWND_MESSAGE = (IntPtr)(-3) → headless message-only parent
+ controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
+
+ int width = options.HtmlViewportWidth > 0 ? options.HtmlViewportWidth : 1280;
+ int height = options.HtmlViewportHeight ?? 720;
+ controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
+ controller.IsVisible = false;
+
+ var web = controller.CoreWebView2;
+ progress?.Report(0.3);
+
+ var navTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler? navHandler = null;
+ navHandler = (_, e) =>
+ {
+ web.NavigationCompleted -= navHandler!;
+ if (e.IsSuccess) navTcs.TrySetResult(true);
+ else navTcs.TrySetException(new InvalidOperationException(
+ $"내비게이션 실패: {e.WebErrorStatus}"));
+ };
+ web.NavigationCompleted += navHandler;
+
+ var fileUri = new Uri(sourcePath).AbsoluteUri;
+ web.Navigate(fileUri);
+
+ using (cancellationToken.Register(() => navTcs.TrySetCanceled()))
+ {
+ await navTcs.Task.ConfigureAwait(true);
+ }
+ progress?.Report(0.5);
+
+ if (options.HtmlWaitMilliseconds > 0)
+ await Task.Delay(options.HtmlWaitMilliseconds, cancellationToken).ConfigureAwait(true);
+
+ progress?.Report(0.65);
+
+ // Use CDP for full-page screenshot beyond viewport
+ var captureParams = options.HtmlFullPage
+ ? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
+ : "{\"format\":\"png\"}";
+
+ var resultJson = await web
+ .CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
+ .ConfigureAwait(true);
+ progress?.Report(0.8);
+
+ using var doc = JsonDocument.Parse(resultJson);
+ var b64 = doc.RootElement.GetProperty("data").GetString()
+ ?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
+ var pngBytes = Convert.FromBase64String(b64);
+
+ tcs.TrySetResult(pngBytes);
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+ finally
+ {
+ try { controller?.Close(); } catch { }
+ dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
+ }
+ }
}
diff --git a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs b/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
index acaa746..954266d 100644
--- a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
+++ b/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
@@ -1,30 +1,134 @@
+using System.Diagnostics;
using EverythingToJpeg.Core.Providers;
namespace EverythingToJpeg.Core.Converters;
public sealed class HwpxProvider : IConverterProvider
{
+ private readonly PdfProvider _pdfProvider;
+
+ public HwpxProvider() : this(new PdfProvider()) { }
+
+ public HwpxProvider(PdfProvider pdfProvider)
+ {
+ _pdfProvider = pdfProvider;
+ }
+
public ProviderCapability Capability { get; } = new(
Id: "hwpx",
DisplayName: "한글 문서 (HWP / HWPX)",
Extensions: new[] { ".hwp", ".hwpx" },
- Status: ProviderStatus.ComingSoon,
- Summary: "한글(HWP/HWPX) 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.",
+ Status: ProviderStatus.RequiresExternal,
+ Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 페이지별 JPEG로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
- Name: "LibreOffice + H2Orestart 확장",
- Description: "한글 파일을 LibreOffice가 읽도록 해 주는 오픈소스 확장입니다.",
- DownloadUrl: "https://github.com/ebandal/H2Orestart",
+ Name: "LibreOffice",
+ Description: "한글 변환에 필요한 헤드리스 오피스 엔진.",
+ DownloadUrl: "https://www.libreoffice.org/download/",
+ IsRequired: true),
+ new ExternalDependency(
+ Name: "H2Orestart 확장",
+ Description: "LibreOffice가 한글 파일을 읽도록 하는 오픈소스 확장. 다운로드한 oxt 파일을 LibreOffice에서 더블클릭해 설치.",
+ DownloadUrl: "https://github.com/ebandal/H2Orestart/releases",
IsRequired: true),
},
- RoadmapNote: "Phase 2 — H2Orestart + soffice headless 파이프라인. 한컴오피스 SDK 연동도 검토.");
+ RoadmapNote: null);
public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
- => Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
+ {
+ if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
+ return Task.FromResult(ProviderAvailability.NotReady(
+ "LibreOffice가 설치되어 있지 않습니다.",
+ Capability.ExternalDependencies));
- public Task ConvertAsync(
- string sourcePath, string outputDirectory, ConvertOptions options,
- IProgress? progress, CancellationToken cancellationToken)
- => Task.FromResult(ConvertResult.Skip(sourcePath, "HWP/HWPX 변환은 곧 지원 예정입니다."));
+ if (!ExternalToolDetector.IsH2OrestartInstalled())
+ return Task.FromResult(ProviderAvailability.NotReady(
+ "H2Orestart 확장이 설치되어 있지 않습니다. https://github.com/ebandal/H2Orestart/releases 에서 .oxt 다운로드 후 LibreOffice에서 설치하세요.",
+ Capability.ExternalDependencies));
+
+ return Task.FromResult(ProviderAvailability.Ready);
+ }
+
+ public async Task ConvertAsync(
+ string sourcePath,
+ string outputDirectory,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
+ return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
+
+ var tempPdf = Path.Combine(Path.GetTempPath(),
+ $"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
+
+ try
+ {
+ progress?.Report(0.05);
+
+ var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!converted)
+ return ConvertResult.Fail(sourcePath,
+ "LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요.");
+
+ progress?.Report(0.55);
+
+ var inner = new Progress(p => progress?.Report(0.55 + p * 0.45));
+ return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
+ with { SourcePath = sourcePath };
+ }
+ finally
+ {
+ try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
+ }
+ }
+
+ private static async Task ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
+ {
+ var outDir = Path.GetDirectoryName(targetPdf)!;
+ var psi = new ProcessStartInfo
+ {
+ FileName = sofficePath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ psi.ArgumentList.Add("--headless");
+ psi.ArgumentList.Add("--norestore");
+ psi.ArgumentList.Add("--nofirststartwizard");
+ psi.ArgumentList.Add("--convert-to");
+ psi.ArgumentList.Add("pdf");
+ psi.ArgumentList.Add("--outdir");
+ psi.ArgumentList.Add(outDir);
+ psi.ArgumentList.Add(sourcePath);
+
+ using var proc = Process.Start(psi);
+ if (proc is null) return false;
+
+ try
+ {
+ await proc.WaitForExitAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ try { proc.Kill(true); } catch { }
+ throw;
+ }
+
+ if (proc.ExitCode != 0) return false;
+
+ var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
+ if (!File.Exists(produced)) return false;
+
+ if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
+ {
+ if (File.Exists(targetPdf)) File.Delete(targetPdf);
+ File.Move(produced, targetPdf);
+ }
+ return File.Exists(targetPdf);
+ }
}
diff --git a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj b/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
index 62ab7bd..ce3376c 100644
--- a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
+++ b/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
@@ -6,7 +6,7 @@
enable
latest
false
- false
+ true
$(NoWarn);NU1901;NU1902;NU1903;NU1904
@@ -15,6 +15,11 @@
+
+
+
+
+