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

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