1
0
Fork 0

Phase 3: HTML(WebView2) + HWP/HWPX(LibreOffice) 실구현

HTML / HTM
- HtmlProvider: STA 스레드 + WPF Dispatcher에서 WebView2 헤드리스 컨트롤러(HWND_MESSAGE) 생성
- CDP Page.captureScreenshot(captureBeyondViewport=true)으로 풀페이지 PNG 획득
- Magick.NET으로 JPEG 인코드, 알파/리사이즈/품질 옵션 일관 적용
- ConvertOptions: HtmlViewportWidth/Height, HtmlWaitMilliseconds, HtmlFullPage

HWP / HWPX
- HwpxProvider: LibreOffice headless --convert-to pdf + H2Orestart 확장 자동 감지
- DocxProvider 패턴 재사용 (소스→PDF→PdfProvider 위임)
- ExternalToolDetector.IsH2OrestartInstalled — uno_packages/extensions 검색

기타
- Core csproj: UseWPF=true (WebView2가 WPF 의존), Microsoft.Web.WebView2 1.0.3912.50 추가
- AppX 매니페스트에 .html/.htm/.hwp/.hwpx ItemType 추가
- 파일 다이얼로그 필터 확장
- 두 ComingSoon 상태였던 Provider가 이제 Available/RequiresExternal로 노출됨

검증
- 솔루션 Release 빌드 통과
- MSIX 38개 페이로드 패키징 성공 (WebView2 SDK 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yun Chan 2026-05-06 13:23:47 +09:00
parent f2c8610ff6
commit 5750aed16f
9 changed files with 366 additions and 26 deletions

View file

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

View file

@ -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)
{

View file

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

View file

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

View file

@ -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<ProviderAvailability> 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<ConvertResult> ConvertAsync(
string sourcePath, string outputDirectory, ConvertOptions options,
IProgress<double>? progress, CancellationToken cancellationToken)
=> Task.FromResult(ConvertResult.Skip(sourcePath, "HTML 변환은 곧 지원 예정입니다."));
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? 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<byte[]> CapturePngAsync(
string sourcePath,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<byte[]>(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<double>? progress,
CancellationToken cancellationToken,
TaskCompletionSource<byte[]> 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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<CoreWebView2NavigationCompletedEventArgs>? 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);
}
}
}

View file

@ -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<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
{
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
return Task.FromResult(ProviderAvailability.NotReady(
"LibreOffice가 설치되어 있지 않습니다.",
Capability.ExternalDependencies));
public Task<ConvertResult> ConvertAsync(
string sourcePath, string outputDirectory, ConvertOptions options,
IProgress<double>? 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<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? 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<double>(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<bool> 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);
}
}

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<UseWindowsForms>false</UseWindowsForms>
<UseWPF>false</UseWPF>
<UseWPF>true</UseWPF>
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
</PropertyGroup>
@ -15,6 +15,11 @@
<PackageReference Include="PDFtoImage" Version="5.2.1" />
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
</ItemGroup>
<ItemGroup>
<Using Include="System.IO" />
</ItemGroup>
</Project>