1
0
Fork 0

feat: 텍스트 문서 5종 양방향 변환 매트릭스 (DocumentProvider)

신규 DocumentProvider — html/htm/hwp/hwpx/docx/doc/md/markdown/txt 입력 →
html/docx/md/txt 출력 (HWP 출력은 H2Orestart 한계로 미지원).

라우팅 (멀티홉)
- {docx, html, txt, hwp} ↔ {docx, html, txt}: LibreOffice --convert-to
- md → html: Markdig (advanced extensions)
- html → md: ReverseMarkdown (GitHub flavored)
- md → docx/txt: md → html(Markdig) → LibreOffice 체인
- {docx, html, hwp} → md: → html(LibreOffice) → md(ReverseMarkdown) 체인
- md ↔ txt, html ↔ html 등: trivial copy
- txt → html: pre-wrapped HTML 인코딩

NuGet 추가
- Markdig 0.37.0
- ReverseMarkdown 4.6.0 (HtmlAgilityPack 의존성 자동 포함)

UI/메뉴 갱신
- AllFormats에 .html, .md 추가 (총 12종)
- PopularOutputs 카스케이드 12개로 확장:
  JPEG/PNG/WebP/PDF/Word/HTML/MD/TXT/AVIF/GIF/TIFF/BMP

요구 외부 도구: LibreOffice (DocxProvider/HwpxProvider와 공통)
MSIX 갱신: Markdig.dll + ReverseMarkdown.dll + HtmlAgilityPack.dll 포함
This commit is contained in:
Yun Chan 2026-05-07 17:22:15 +09:00
parent 50a195f9db
commit 25bebb46bf
5 changed files with 305 additions and 16 deletions

View file

@ -17,12 +17,14 @@ internal static class ContextMenuRegistrar
(".png", "PNG (.png)", "02"),
(".webp", "WebP (.webp)", "03"),
(".pdf", "PDF (.pdf)", "04"),
(".txt", "텍스트 (.txt) — OCR", "05"),
(".docx", "Word (.docx) — OCR", "06"),
(".avif", "AVIF (.avif)", "07"),
(".gif", "GIF (.gif)", "08"),
(".tif", "TIFF (.tif)", "09"),
(".bmp", "BMP (.bmp)", "10"),
(".docx", "Word (.docx)", "05"),
(".html", "HTML (.html)", "06"),
(".md", "Markdown (.md)", "07"),
(".txt", "텍스트 (.txt)", "08"),
(".avif", "AVIF (.avif)", "09"),
(".gif", "GIF (.gif)", "10"),
(".tif", "TIFF (.tif)", "11"),
(".bmp", "BMP (.bmp)", "12"),
};
public static void Register(ConversionEngine engine)

View file

@ -812,16 +812,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private static readonly OutputFormatInfo[] AllFormats =
{
new(".jpg", "JPEG", "JPG", "FsFmtJpg"),
new(".png", "PNG", "PNG", "FsFmtPng"),
new(".webp", "WebP", "WEBP", "FsFmtWebp"),
new(".avif", "AVIF", "AVIF", "FsFmtAvif"),
new(".bmp", "BMP", "BMP", "FsFmtBmp"),
new(".tif", "TIFF", "TIF", "FsFmtTiff"),
new(".gif", "GIF", "GIF", "FsFmtGif"),
new(".pdf", "PDF", "PDF", "FsFmtPdf"),
new(".txt", "텍스트 (OCR)", "TXT", "FsFmtOther"),
new(".docx", "Word (OCR)", "DOCX", "FsFmtDocx"),
new(".jpg", "JPEG", "JPG", "FsFmtJpg"),
new(".png", "PNG", "PNG", "FsFmtPng"),
new(".webp", "WebP", "WEBP", "FsFmtWebp"),
new(".avif", "AVIF", "AVIF", "FsFmtAvif"),
new(".bmp", "BMP", "BMP", "FsFmtBmp"),
new(".tif", "TIFF", "TIF", "FsFmtTiff"),
new(".gif", "GIF", "GIF", "FsFmtGif"),
new(".pdf", "PDF", "PDF", "FsFmtPdf"),
new(".docx", "Word", "DOCX", "FsFmtDocx"),
new(".html", "HTML", "HTML", "FsFmtHtml"),
new(".md", "Markdown", "MD", "FsFmtOther"),
new(".txt", "텍스트", "TXT", "FsFmtOther"),
};
private bool _suppressFormatChanged;

View file

@ -0,0 +1,282 @@
using System.Diagnostics;
using System.Text;
using Everything2Everything.Core.Providers;
using Markdig;
namespace Everything2Everything.Core.Converters;
// 텍스트 문서 5종 매트릭스 (html · hwp · docx · md · txt)
//
// HWP/HWPX는 LibreOffice + H2Orestart로 읽기만 가능. 출력은 불가.
//
// 라우팅:
// {docx, doc, html, htm, txt, hwp, hwpx} ↔ {docx, html, txt} → LibreOffice
// md → html → Markdig
// html → md → ReverseMarkdown
// md → {docx, txt} = md → html → LibreOffice
// {docx, html, hwp} → md = → html (LibreOffice/Markdig) → ReverseMarkdown
// 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" };
public ProviderCapability Capability { get; } = new(
Id: "document",
DisplayName: "문서 텍스트 변환 (HTML/HWP/DOCX/MD/TXT)",
SupportedConversions: ProviderCapability.PairsFromMatrix(Inputs, Outputs),
Status: ProviderStatus.RequiresExternal,
Summary: "HTML·HWP·DOCX·Markdown·TXT 사이의 양방향 텍스트 변환 (LibreOffice + Markdig + ReverseMarkdown).",
ExternalDependencies: new[]
{
new ExternalDependency(
Name: "LibreOffice",
Description: "DOCX/HTML/TXT/HWP 변환 엔진. HWP 입력에는 H2Orestart 확장이 함께 필요합니다.",
DownloadUrl: "https://www.libreoffice.org/download/",
IsRequired: true),
},
RoadmapNote: "HWP/HWPX 출력은 H2Orestart 한계로 미지원.");
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
// LibreOffice는 docx/html/txt 변환에 필수. md ↔ md/txt만 하는 경우엔 없어도 되지만
// 일관성을 위해 LibreOffice를 요구. (md → md/txt는 trivial이지만 ConvertAsync 내부에서 처리)
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
{
return Task.FromResult(ProviderAvailability.NotReady(
"LibreOffice가 필요합니다 (DOCX/HTML/TXT/HWP 변환에 사용).",
Capability.ExternalDependencies));
}
return Task.FromResult(ProviderAvailability.Ready);
}
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var inExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
var outExt = ConversionPair.Normalize(outputExtension);
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
var workDir = Path.Combine(Path.GetTempPath(), $"e2e_doc_{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);
try
{
progress?.Report(0.05);
await RouteAsync(sourcePath, inExt, path, outExt, workDir, cancellationToken).ConfigureAwait(false);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath, ex.Message, ex);
}
finally
{
try { Directory.Delete(workDir, recursive: true); } catch { }
}
}
private static async Task RouteAsync(
string sourcePath, string inExt,
string outputPath, string outExt,
string workDir,
CancellationToken ct)
{
// Normalize aliases
var input = inExt switch
{
".markdown" => ".md",
".htm" => ".html",
".doc" => ".docx",
".hwpx" => ".hwp",
_ => inExt,
};
var output = outExt;
// 동일 형식 (이미 ConversionEngine에서 걸러지지만 안전장치)
if (input == output)
{
File.Copy(sourcePath, outputPath, overwrite: true);
return;
}
// ===== MD 입력 =====
if (input == ".md")
{
switch (output)
{
case ".txt":
// Markdown은 plain text + 마크업 — LibreOffice가 .md를 모르니 복사로 충분
File.Copy(sourcePath, outputPath, overwrite: true);
return;
case ".html":
await MdToHtmlAsync(sourcePath, outputPath, ct).ConfigureAwait(false);
return;
case ".docx":
var midHtml = Path.Combine(workDir, "mid.html");
await MdToHtmlAsync(sourcePath, midHtml, ct).ConfigureAwait(false);
await SofficeConvertAsync(midHtml, outputPath, "docx", ct).ConfigureAwait(false);
return;
}
}
// ===== HTML 입력 =====
if (input == ".html")
{
switch (output)
{
case ".md":
await HtmlToMdAsync(sourcePath, outputPath, ct).ConfigureAwait(false);
return;
case ".docx":
case ".txt":
await SofficeConvertAsync(sourcePath, outputPath, output.TrimStart('.'), ct).ConfigureAwait(false);
return;
}
}
// ===== TXT 입력 =====
if (input == ".txt")
{
switch (output)
{
case ".md":
File.Copy(sourcePath, outputPath, overwrite: true);
return;
case ".html":
await TxtToHtmlAsync(sourcePath, outputPath, ct).ConfigureAwait(false);
return;
case ".docx":
await SofficeConvertAsync(sourcePath, outputPath, "docx", ct).ConfigureAwait(false);
return;
}
}
// ===== DOCX 입력 (DOC도 alias로 ".docx") =====
// (실제 입력 ext가 ".doc"여도 LibreOffice는 동일하게 처리)
if (input == ".docx")
{
switch (output)
{
case ".html":
case ".txt":
await SofficeConvertAsync(sourcePath, outputPath, output.TrimStart('.'), ct).ConfigureAwait(false);
return;
case ".md":
var midHtml = Path.Combine(workDir, "mid.html");
await SofficeConvertAsync(sourcePath, midHtml, "html", ct).ConfigureAwait(false);
await HtmlToMdAsync(midHtml, outputPath, ct).ConfigureAwait(false);
return;
}
}
// ===== HWP 입력 (HWPX도 alias로 ".hwp") =====
if (input == ".hwp")
{
switch (output)
{
case ".html":
case ".txt":
case ".docx":
await SofficeConvertAsync(sourcePath, outputPath, output.TrimStart('.'), ct).ConfigureAwait(false);
return;
case ".md":
var midHtml = Path.Combine(workDir, "mid.html");
await SofficeConvertAsync(sourcePath, midHtml, "html", ct).ConfigureAwait(false);
await HtmlToMdAsync(midHtml, outputPath, ct).ConfigureAwait(false);
return;
}
}
throw new NotSupportedException($"지원하지 않는 변환 라우트: {inExt} → {outExt}");
}
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);
}
private static async Task SofficeConvertAsync(string sourcePath, string targetPath, string outputFormat, CancellationToken ct)
{
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
throw new InvalidOperationException("LibreOffice를 찾을 수 없습니다.");
var outDir = Path.GetDirectoryName(Path.GetFullPath(targetPath))!;
Directory.CreateDirectory(outDir);
var psi = new ProcessStartInfo
{
FileName = soffice,
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(outputFormat);
psi.ArgumentList.Add("--outdir");
psi.ArgumentList.Add(outDir);
psi.ArgumentList.Add(sourcePath);
using var proc = Process.Start(psi)
?? throw new InvalidOperationException("LibreOffice 프로세스 시작 실패");
try { await proc.WaitForExitAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) { try { proc.Kill(true); } catch { } throw; }
if (proc.ExitCode != 0)
throw new InvalidOperationException($"LibreOffice 변환 실패 (exit {proc.ExitCode})");
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
if (!File.Exists(produced))
throw new FileNotFoundException("LibreOffice가 결과물을 생성하지 않았습니다.", produced);
if (!string.Equals(produced, targetPath, StringComparison.OrdinalIgnoreCase))
{
if (File.Exists(targetPath)) File.Delete(targetPath);
File.Move(produced, targetPath);
}
}
}

View file

@ -18,6 +18,8 @@
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
</ItemGroup>
<ItemGroup>

View file

@ -17,6 +17,7 @@ public static class Everything2EverythingBootstrap
new Converters.HtmlProvider(),
new Converters.HwpxProvider(),
new Converters.OcrProvider(pdf),
new Converters.DocumentProvider(),
};
return new ConversionEngine(new ProviderRegistry(providers));
}