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[] OfficeInputs = { ".docx", ".doc", ".hwp", ".hwpx" }; private static readonly string[] OfficeOutputs = { ".html", ".docx", ".md", ".txt" }; private static readonly string[] TextInputsToDocx = { ".html", ".htm", ".md", ".markdown", ".txt" }; public ProviderCapability Capability { get; } = new( Id: "document", DisplayName: "문서 텍스트 변환 (DOCX/HWP/오피스)", SupportedConversions: ProviderCapability.PairsFromMatrix(OfficeInputs, OfficeOutputs) .Concat(ProviderCapability.PairsFromMatrix(TextInputsToDocx, new[] { ".docx" })) .Concat(new[] { new ConversionPair(".html", ".txt", LossClass.Recode), new ConversionPair(".htm", ".txt", LossClass.Recode), }).ToList(), Status: ProviderStatus.RequiresExternal, Summary: "DOCX·HWP 문서를 HTML/DOCX/MD/TXT로 변환하거나 텍스트를 DOCX로 저장합니다 (LibreOffice 엔진).", 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 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 ConvertAsync( string sourcePath, string outputDirectory, string outputExtension, ConvertOptions options, IProgress? 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 = $"{Path.GetFileNameWithoutExtension(mdPath)}{body}"; 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", "
\n"); var fullHtml = $"{Path.GetFileNameWithoutExtension(txtPath)}
{encoded}
"; 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))!; // soffice 호출 직렬화(기본 프로필 락 충돌 방지) + 타임아웃/트리 kill + 출력 검증은 LibreOfficeRunner가 담당. var produced = await LibreOfficeRunner.ConvertAsync( soffice, sourcePath, outDir, outputFormat, LibreOfficeRunner.DefaultTimeoutSeconds, ct) .ConfigureAwait(false); if (!string.Equals(produced, targetPath, StringComparison.OrdinalIgnoreCase)) { if (File.Exists(targetPath)) File.Delete(targetPath); File.Move(produced, targetPath); } } }