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:
parent
50a195f9db
commit
25bebb46bf
5 changed files with 305 additions and 16 deletions
|
|
@ -17,12 +17,14 @@ internal static class ContextMenuRegistrar
|
||||||
(".png", "PNG (.png)", "02"),
|
(".png", "PNG (.png)", "02"),
|
||||||
(".webp", "WebP (.webp)", "03"),
|
(".webp", "WebP (.webp)", "03"),
|
||||||
(".pdf", "PDF (.pdf)", "04"),
|
(".pdf", "PDF (.pdf)", "04"),
|
||||||
(".txt", "텍스트 (.txt) — OCR", "05"),
|
(".docx", "Word (.docx)", "05"),
|
||||||
(".docx", "Word (.docx) — OCR", "06"),
|
(".html", "HTML (.html)", "06"),
|
||||||
(".avif", "AVIF (.avif)", "07"),
|
(".md", "Markdown (.md)", "07"),
|
||||||
(".gif", "GIF (.gif)", "08"),
|
(".txt", "텍스트 (.txt)", "08"),
|
||||||
(".tif", "TIFF (.tif)", "09"),
|
(".avif", "AVIF (.avif)", "09"),
|
||||||
(".bmp", "BMP (.bmp)", "10"),
|
(".gif", "GIF (.gif)", "10"),
|
||||||
|
(".tif", "TIFF (.tif)", "11"),
|
||||||
|
(".bmp", "BMP (.bmp)", "12"),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static void Register(ConversionEngine engine)
|
public static void Register(ConversionEngine engine)
|
||||||
|
|
|
||||||
|
|
@ -812,16 +812,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
||||||
|
|
||||||
private static readonly OutputFormatInfo[] AllFormats =
|
private static readonly OutputFormatInfo[] AllFormats =
|
||||||
{
|
{
|
||||||
new(".jpg", "JPEG", "JPG", "FsFmtJpg"),
|
new(".jpg", "JPEG", "JPG", "FsFmtJpg"),
|
||||||
new(".png", "PNG", "PNG", "FsFmtPng"),
|
new(".png", "PNG", "PNG", "FsFmtPng"),
|
||||||
new(".webp", "WebP", "WEBP", "FsFmtWebp"),
|
new(".webp", "WebP", "WEBP", "FsFmtWebp"),
|
||||||
new(".avif", "AVIF", "AVIF", "FsFmtAvif"),
|
new(".avif", "AVIF", "AVIF", "FsFmtAvif"),
|
||||||
new(".bmp", "BMP", "BMP", "FsFmtBmp"),
|
new(".bmp", "BMP", "BMP", "FsFmtBmp"),
|
||||||
new(".tif", "TIFF", "TIF", "FsFmtTiff"),
|
new(".tif", "TIFF", "TIF", "FsFmtTiff"),
|
||||||
new(".gif", "GIF", "GIF", "FsFmtGif"),
|
new(".gif", "GIF", "GIF", "FsFmtGif"),
|
||||||
new(".pdf", "PDF", "PDF", "FsFmtPdf"),
|
new(".pdf", "PDF", "PDF", "FsFmtPdf"),
|
||||||
new(".txt", "텍스트 (OCR)", "TXT", "FsFmtOther"),
|
new(".docx", "Word", "DOCX", "FsFmtDocx"),
|
||||||
new(".docx", "Word (OCR)", "DOCX", "FsFmtDocx"),
|
new(".html", "HTML", "HTML", "FsFmtHtml"),
|
||||||
|
new(".md", "Markdown", "MD", "FsFmtOther"),
|
||||||
|
new(".txt", "텍스트", "TXT", "FsFmtOther"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private bool _suppressFormatChanged;
|
private bool _suppressFormatChanged;
|
||||||
|
|
|
||||||
282
src/Everything2Everything.Core/Converters/DocumentProvider.cs
Normal file
282
src/Everything2Everything.Core/Converters/DocumentProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@
|
||||||
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
|
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
|
||||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
|
||||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
|
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
|
||||||
|
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||||
|
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ public static class Everything2EverythingBootstrap
|
||||||
new Converters.HtmlProvider(),
|
new Converters.HtmlProvider(),
|
||||||
new Converters.HwpxProvider(),
|
new Converters.HwpxProvider(),
|
||||||
new Converters.OcrProvider(pdf),
|
new Converters.OcrProvider(pdf),
|
||||||
|
new Converters.DocumentProvider(),
|
||||||
};
|
};
|
||||||
return new ConversionEngine(new ProviderRegistry(providers));
|
return new ConversionEngine(new ProviderRegistry(providers));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue