diff --git a/src/Filee.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs index b5afba72e..25c60d3ce 100644 --- a/src/Filee.Engines/Office/LibreOfficeConverter.cs +++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs @@ -453,6 +453,10 @@ void Add(IEnumerable sources, IEnumerable targets, int cost) // turn into DOCX (Excel → DOCX → LibreOffice would cost 17 + 19). const int Chosen = 10; Add(Writer, [.. Writer, "txt", "html"], Fallback); + // RTF → TXT (#1204): the built-in routes read RTF through Pandoc, which makes a code span of every run in a + // monospace font (through Markdown, about 29) or loses the line breaks (through the HWPX writer, about 32). + // Direct only, so it stays the fallback as a step towards other formats. + edges.Add(new ConversionEdge("rtf", "txt", Chosen, DirectOnly: true)); Add(Writer, ["pdf"], documents ? Chosen : Fallback); Add(Calc, [.. Calc, "html"], Fallback); Add(Calc, ["pdf"], sheets ? Chosen : Fallback); diff --git a/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs b/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs index 9bbb3b8bb..98436529f 100644 --- a/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs +++ b/tests/Filee.Engines.Tests/LibreOfficeFormatTests.cs @@ -74,7 +74,9 @@ public void Every_edge_has_a_filter_of_its_application() } // Built-in engines stay first: LibreOffice's edges cost more than a normal direct conversion, except // presentations → PDF, which it lays out like PowerPoint (Settings → Engines, on by default). - Assert.All(LibreOffice.Edges.Where(e => !(impress.Contains(e.From) && e.To == "pdf")), e => Assert.True(e.Cost >= 25)); + // RTF → TXT: RTF has no built-in reader, and every other route reads it through Pandoc (#1204). + Assert.All(LibreOffice.Edges.Where(e => !(impress.Contains(e.From) && e.To == "pdf") && !(e.From == "rtf" && e.To == "txt")), + e => Assert.True(e.Cost >= 25)); foreach (var from in new[] { "dot", "wps", "wpd", "lwp", "abw", "pages", "sdw", "cwk", "mcw", "602", "psw", "fodt", "ott", "et", "numbers", "sdc", "fods", "ots", "xlt", "key", "dps", "sda", "fodp", "otp", "pub", "cdr", "cgm", "vsd", "odg", "fodg" }) Assert.Contains(LibreOffice.Edges, e => e.From == from && e.To == "pdf"); // Read-only formats are never targets. diff --git a/tests/Filee.Engines.Tests/LibreOfficeRouteTests.cs b/tests/Filee.Engines.Tests/LibreOfficeRouteTests.cs index 99c4eccbc..34791c71b 100644 --- a/tests/Filee.Engines.Tests/LibreOfficeRouteTests.cs +++ b/tests/Filee.Engines.Tests/LibreOfficeRouteTests.cs @@ -58,6 +58,17 @@ public void Word_and_excel_go_the_built_in_way_unless_libreoffice_is_chosen(stri Assert.Equal("libreoffice", Assert.Single(chosen.Plan(from, "pdf")!.Steps).Converter.Id); } + [Fact] + public void Rtf_goes_to_txt_through_libreoffice() + { + // Issue #1204: through Markdown, code copied from an IDE came out full of backticks. + var planner = Planner(slides: false, hwpToPdf: false); + + Assert.Equal("libreoffice", Assert.Single(planner.Plan("rtf", "txt")!.Steps).Converter.Id); + Assert.Equal("pandoc", Assert.Single(planner.Plan("rtf", "md")!.Steps).Converter.Id); + Assert.DoesNotContain(planner.Plan("rtf", "epub")!.Steps, s => s.From == "rtf" && s.To == "txt"); // not a hop + } + [Theory] [InlineData("docx")] [InlineData("txt")] diff --git a/tests/Filee.Engines.Tests/RtfToTextTests.cs b/tests/Filee.Engines.Tests/RtfToTextTests.cs new file mode 100644 index 000000000..8578cb01c --- /dev/null +++ b/tests/Filee.Engines.Tests/RtfToTextTests.cs @@ -0,0 +1,57 @@ +// RTF → TXT through LibreOffice (#1204): code copied from an IDE (a colour run per token and an italic run here and +// there) keeps its text, its indentation and its blank lines. The route itself is in LibreOfficeRouteTests. + +using Filee.Core.Conversion; +using Filee.Core.Presets; + +namespace Filee.Engines.Tests; + +public class RtfToTextTests(EngineFixture fx) : IClassFixture +{ + private const string IdeCode = """ + {\rtf1\ansi\ansicpg1252\cocoartf2907 + \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fmodern\fcharset0 Courier;\f1\fmodern\fcharset0 Courier-Oblique;} + {\colortbl;\red255\green255\blue255;\red195\green122\blue88;\red175\green177\blue183;\red186\green102\blue173; + \red73\green153\blue245;\red39\green159\blue171;\red88\green157\blue94;} + \pard\tx560\tx1120\tx1680\tx2240\pardirnatural\partightenfactor0 + + \f0\fs26 \cf2 import \cf3 java.io.BufferedReader;\ + \ + \cf2 class \cf3 FileTask \cf2 implements \cf3 Runnable \{\ + \cf2 private \cf3 String \cf4 fileName\cf3 ;\ + \ + \cf2 public void \cf5 run\cf3 () \{\ + \cf2 int \cf3 count = \cf6 0\cf3 ;\ + content.append(line).append(\cf7 "\cf2 \\n\cf7 "\cf3 );\ + \cf2 for \cf3 (String word : line.split(\cf7 "\cf2 \\\\\cf7 W+"\cf3 )) \{\ + count++;\ + \}\ + System. + \f1\i \cf4 out + \f0\i0 \cf3 .println(\cf7 "Java word is found " \cf3 + count);\ + \}\ + \}\ + } + """; + + [Fact] + public async Task Code_copied_from_an_IDE_keeps_its_text() + { + Assert.SkipUnless(fx.Catalog.StatusOf(fx.Converters.Single(c => c.Id == "libreoffice")).IsAvailable, "needs LibreOffice"); + var rtf = Path.Combine(fx.NewFolder(), "FileTask.rtf"); + await File.WriteAllTextAsync(rtf, IdeCode, TestContext.Current.CancellationToken); + + var job = await fx.ConvertAsync([rtf], new Preset { TargetFormat = "txt" }); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var lines = (await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken)) + .TrimStart('\uFEFF').ReplaceLineEndings("\n").Split('\n'); + + Assert.DoesNotContain(lines, l => l.Contains('`')); + Assert.Equal("import java.io.BufferedReader;", lines[0]); + Assert.Equal("", lines[1]); // blank lines stay + Assert.Contains(" private String fileName;", lines); // and so does the indentation + Assert.Contains(" content.append(line).append(\"\\n\");", lines); + Assert.Contains(" for (String word : line.split(\"\\\\W+\")) {", lines); + Assert.Contains(" System.out.println(\"Java word is found \" + count);", lines); // the italic run + } +}