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