From ed857371396d4b5b250b0551cf590b7993fa1516 Mon Sep 17 00:00:00 2001 From: "copilot-developer-agent-robch[bot]" <175728472+Copilot@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:25:59 +0000 Subject: [PATCH 1/3] Initial plan for issue From 25a9e88608adb48336345e3d2039583abcecb85d Mon Sep 17 00:00:00 2001 From: "copilot-developer-agent-robch[bot]" <175728472+Copilot@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:31:02 +0000 Subject: [PATCH 2/3] Implement markdown export functionality with PDF/DOCX/PPTX support --- src/Commands/CommandLineOptions.cs | 35 +++++++++++ src/Commands/ExportCommand.cs | 72 +++++++++++++++++++++++ src/Exporters/DocxMarkdownExporter.cs | 41 +++++++++++++ src/Exporters/IMarkdownExporter.cs | 19 ++++++ src/Exporters/MarkdownExporters.cs | 51 ++++++++++++++++ src/Exporters/PdfMarkdownExporter.cs | 84 +++++++++++++++++++++++++++ src/Exporters/PptxMarkdownExporter.cs | 84 +++++++++++++++++++++++++++ src/assets/help/export.txt | 16 +++++ src/mdx.csproj | 2 + 9 files changed, 404 insertions(+) create mode 100644 src/Commands/ExportCommand.cs create mode 100644 src/Exporters/DocxMarkdownExporter.cs create mode 100644 src/Exporters/IMarkdownExporter.cs create mode 100644 src/Exporters/MarkdownExporters.cs create mode 100644 src/Exporters/PdfMarkdownExporter.cs create mode 100644 src/Exporters/PptxMarkdownExporter.cs create mode 100644 src/assets/help/export.txt diff --git a/src/Commands/CommandLineOptions.cs b/src/Commands/CommandLineOptions.cs index 87536bd..7fd210f 100644 --- a/src/Commands/CommandLineOptions.cs +++ b/src/Commands/CommandLineOptions.cs @@ -182,6 +182,7 @@ private static bool TryParseInputOptions(CommandLineOptions commandLineOptions, { "help" => "help", "run" => "run", + "export" => "export", _ => $"{name1} {name2}".Trim() }; @@ -191,6 +192,7 @@ private static bool TryParseInputOptions(CommandLineOptions commandLineOptions, "web get" => new WebGetCommand(), "help" => new HelpCommand(), "run" => new RunCommand(), + "export" => new ExportCommand(), _ => new FindFilesCommand() }; @@ -208,6 +210,7 @@ private static bool TryParseInputOptions(CommandLineOptions commandLineOptions, TryParseFindFilesCommandOptions(command as FindFilesCommand, args, ref i, arg) || TryParseWebCommandOptions(command as WebCommand, args, ref i, arg) || TryParseRunCommandOptions(command as RunCommand, args, ref i, arg) || + TryParseExportCommandOptions(command as ExportCommand, args, ref i, arg) || TryParseSharedCommandOptions(command, args, ref i, arg); if (parsedOption) return true; @@ -301,6 +304,38 @@ private static bool TryParseHelpCommandOptions(CommandLineOptions commandLineOpt return parsed; } + private static bool TryParseExportCommandOptions(ExportCommand command, string[] args, ref int i, string arg) + { + bool parsed = true; + + if (command == null) + { + parsed = false; + } + else if (arg == "--format") + { + var formatArgs = GetInputOptionArgs(i + 1, args, max: 1); + command.Format = formatArgs.FirstOrDefault() ?? throw new CommandLineException("Missing format value"); + i += formatArgs.Count(); + } + else if (arg == "--output") + { + var outputArgs = GetInputOptionArgs(i + 1, args, max: 1); + command.OutputPath = outputArgs.FirstOrDefault() ?? throw new CommandLineException("Missing output path"); + i += outputArgs.Count(); + } + else if (!arg.StartsWith("--")) + { + command.Files.Add(arg); + } + else + { + parsed = false; + } + + return parsed; + } + private static bool TryParseRunCommandOptions(RunCommand command, string[] args, ref int i, string arg) { bool parsed = true; diff --git a/src/Commands/ExportCommand.cs b/src/Commands/ExportCommand.cs new file mode 100644 index 0000000..aa280da --- /dev/null +++ b/src/Commands/ExportCommand.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; +using mdx.Exporters; + +namespace mdx.Commands; + +public class ExportCommand : Command +{ + private static readonly MarkdownExporters Exporters = new(); + + public string Format { get; set; } + public string OutputPath { get; set; } + public List Files { get; set; } = new(); + + public override string GetCommandName() => "export"; + + public override string GetUsage() => + "mdx export [options] \n" + + "Options:\n" + + " --format Output format (pdf, docx, or pptx)\n" + + " --output Output file path\n\n" + + "Example:\n" + + " mdx export --format pdf --output output.pdf input.md"; + + public override bool IsEmpty() => Files.Count == 0; + + public override Command Validate() + { + if (string.IsNullOrEmpty(Format)) + { + throw new CommandLineException("Export format must be specified with --format"); + } + + if (!Exporters.SupportedFormats.Contains(Format.ToLowerInvariant())) + { + throw new CommandLineException($"Unsupported export format: {Format}. Supported formats: {string.Join(", ", Exporters.SupportedFormats)}"); + } + + if (string.IsNullOrEmpty(OutputPath)) + { + throw new CommandLineException("Output path must be specified with --output"); + } + + if (Files.Count == 0) + { + throw new CommandLineException("At least one input file must be specified"); + } + + return this; + } + + public override void Execute() + { + var exporter = Exporters.GetExporter(Format); + var combinedContent = new List(); + + foreach (var file in Files) + { + if (!File.Exists(file)) + { + throw new FileNotFoundException($"Input file not found: {file}"); + } + + var content = File.ReadAllText(file); + combinedContent.Add(content); + } + + var finalContent = string.Join("\n\n", combinedContent); + exporter.Export(finalContent, OutputPath); + } +} \ No newline at end of file diff --git a/src/Exporters/DocxMarkdownExporter.cs b/src/Exporters/DocxMarkdownExporter.cs new file mode 100644 index 0000000..4a71478 --- /dev/null +++ b/src/Exporters/DocxMarkdownExporter.cs @@ -0,0 +1,41 @@ +using System.IO; +using System.Linq; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Markdig; + +namespace mdx.Exporters; + +public class DocxMarkdownExporter : IMarkdownExporter +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + public string OutputFormat => "docx"; + + public void Export(string markdownContent, string outputPath) + { + using var document = WordprocessingDocument.Create(outputPath, WordprocessingDocumentType.Document); + var mainPart = document.AddMainDocumentPart(); + mainPart.Document = new Document(); + var body = mainPart.Document.AppendChild(new Body()); + + // Parse markdown to HTML first (easier to process) + var html = Markdown.ToHtml(markdownContent, Pipeline); + + // Simple HTML parsing to generate basic DOCX paragraphs + var lines = html.Split('\n') + .Select(l => l.Trim()) + .Where(l => !string.IsNullOrWhiteSpace(l)); + + foreach (var line in lines) + { + var para = new Paragraph(new Run(new Text(line))); + body.AppendChild(para); + } + + mainPart.Document.Save(); + } +} \ No newline at end of file diff --git a/src/Exporters/IMarkdownExporter.cs b/src/Exporters/IMarkdownExporter.cs new file mode 100644 index 0000000..ff6d453 --- /dev/null +++ b/src/Exporters/IMarkdownExporter.cs @@ -0,0 +1,19 @@ +namespace mdx.Exporters; + +/// +/// Interface for exporting markdown content to other file formats +/// +public interface IMarkdownExporter +{ + /// + /// Get the supported output format extension (without dot) + /// + string OutputFormat { get; } + + /// + /// Export markdown content to the target format + /// + /// The markdown content to export + /// Path where to save the exported file + void Export(string markdownContent, string outputPath); +} \ No newline at end of file diff --git a/src/Exporters/MarkdownExporters.cs b/src/Exporters/MarkdownExporters.cs new file mode 100644 index 0000000..37ba15e --- /dev/null +++ b/src/Exporters/MarkdownExporters.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace mdx.Exporters; + +/// +/// Manages available markdown exporters +/// +public class MarkdownExporters +{ + private readonly Dictionary _exporters = new(); + + public MarkdownExporters() + { + RegisterExporters(); + } + + /// + /// Register all available exporters + /// + private void RegisterExporters() + { + RegisterExporter(new PdfMarkdownExporter()); + RegisterExporter(new DocxMarkdownExporter()); + RegisterExporter(new PptxMarkdownExporter()); + } + + /// + /// Register a single exporter + /// + private void RegisterExporter(IMarkdownExporter exporter) + { + _exporters[exporter.OutputFormat.ToLowerInvariant()] = exporter; + } + + /// + /// Get an exporter for the specified format + /// + public IMarkdownExporter GetExporter(string format) + { + return _exporters.TryGetValue(format.ToLowerInvariant(), out var exporter) + ? exporter + : throw new ArgumentException($"No exporter found for format: {format}"); + } + + /// + /// Get all supported export formats + /// + public IEnumerable SupportedFormats => _exporters.Keys; +} \ No newline at end of file diff --git a/src/Exporters/PdfMarkdownExporter.cs b/src/Exporters/PdfMarkdownExporter.cs new file mode 100644 index 0000000..8026c5a --- /dev/null +++ b/src/Exporters/PdfMarkdownExporter.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Markdig; +using PuppeteerSharp; + +namespace mdx.Exporters; + +public class PdfMarkdownExporter : IMarkdownExporter +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + public string OutputFormat => "pdf"; + + public void Export(string markdownContent, string outputPath) + { + // Convert markdown to HTML + var html = GenerateHtml(markdownContent); + + // Generate PDF using Puppeteer + GeneratePdfAsync(html, outputPath).GetAwaiter().GetResult(); + } + + private static string GenerateHtml(string markdown) + { + var htmlContent = Markdown.ToHtml(markdown, Pipeline); + return $@" + + + + + + + + {htmlContent} + +"; + } + + private static async Task GeneratePdfAsync(string html, string outputPath) + { + await new BrowserFetcher().DownloadAsync(); + + await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true }); + await using var page = await browser.NewPageAsync(); + + await page.SetContentAsync(html); + await page.PdfAsync(outputPath, new PdfOptions + { + Format = PaperFormat.A4, + PrintBackground = true, + MarginOptions = new MarginOptions + { + Top = "1cm", + Right = "1cm", + Bottom = "1cm", + Left = "1cm" + } + }); + } +} \ No newline at end of file diff --git a/src/Exporters/PptxMarkdownExporter.cs b/src/Exporters/PptxMarkdownExporter.cs new file mode 100644 index 0000000..b9f560d --- /dev/null +++ b/src/Exporters/PptxMarkdownExporter.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Linq; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Presentation; +using Drawing = DocumentFormat.OpenXml.Drawing; +using Markdig; + +namespace mdx.Exporters; + +public class PptxMarkdownExporter : IMarkdownExporter +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + public string OutputFormat => "pptx"; + + public void Export(string markdownContent, string outputPath) + { + using var presentation = PresentationDocument.Create(outputPath, PresentationDocumentType.Presentation); + var presentationPart = presentation.AddPresentationPart(); + presentationPart.Presentation = new Presentation(); + + var slideMasterPart = presentationPart.AddNewPart(); + var slideMaster = new SlideMaster( + new CommonSlideData(new ShapeTree()), + new ColorMap { Background1 = "lt1", Text1 = "dk1", Background2 = "lt2", Text2 = "dk2", Accent1 = "accent1", Accent2 = "accent2", Accent3 = "accent3", Accent4 = "accent4", Accent5 = "accent5", Accent6 = "accent6", Hyperlink = "hlink", FollowedHyperlink = "folHlink" } + ); + slideMasterPart.SlideMaster = slideMaster; + + var slideLayoutPart = slideMasterPart.AddNewPart(); + var slideLayout = new SlideLayout(new CommonSlideData(new ShapeTree())); + slideLayoutPart.SlideLayout = slideLayout; + + slideMaster.SlideLayoutIdList = new SlideLayoutIdList(new SlideLayoutId { Id = 2147483649U, RelationshipId = slideMasterPart.GetIdOfPart(slideLayoutPart) }); + + presentationPart.Presentation.SlideMasterIdList = new SlideMasterIdList(new SlideMasterId { Id = 2147483648U, RelationshipId = presentationPart.GetIdOfPart(slideMasterPart) }); + presentationPart.Presentation.SlideIdList = new SlideIdList(); + presentationPart.Presentation.SlideSize = new SlideSize { Cx = 9144000, Cy = 6858000, Type = SlideSizeValues.Screen16x9 }; + presentationPart.Presentation.NotesSize = new NotesSize { Cx = 6858000, Cy = 9144000 }; + + // Parse markdown to HTML first + var html = Markdown.ToHtml(markdownContent, Pipeline); + + // Split content by double newline to create slides + var slides = html.Split(new[] { "\n\n" }, System.StringSplitOptions.RemoveEmptyEntries) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()); + + foreach (var slideContent in slides) + { + CreateSlideWithText(presentationPart, slideLayoutPart, slideContent); + } + } + + private void CreateSlideWithText(PresentationPart presentationPart, SlideLayoutPart slideLayoutPart, string text) + { + var slidePart = presentationPart.AddNewPart(); + var slide = new Slide(new CommonSlideData(new ShapeTree())); + slidePart.Slide = slide; + + slide.CommonSlideData = new CommonSlideData( + new ShapeTree( + new NonVisualGroupShapeProperties( + new NonVisualDrawingProperties { Id = 1, Name = "" }, + new NonVisualGroupShapeDrawingProperties(), + new ApplicationNonVisualDrawingProperties()), + new GroupShapeProperties(), + new Shape( + new NonVisualShapeProperties( + new NonVisualDrawingProperties { Id = 2, Name = "Title 1" }, + new NonVisualShapeDrawingProperties(new Drawing.ShapeLocks { NoGrouping = true }), + new ApplicationNonVisualDrawingProperties(new PlaceholderShape())), + new ShapeProperties(), + new TextBody( + new Drawing.BodyProperties(), + new Drawing.ListStyle(), + new Drawing.Paragraph(new Drawing.Run(new Drawing.Text { Text = text })))))); + + slidePart.AddPart(slideLayoutPart, "rId1"); + presentationPart.Presentation.SlideIdList.Append(new SlideId { Id = 256U + (uint)presentationPart.Presentation.SlideIdList.Count(), RelationshipId = presentationPart.GetIdOfPart(slidePart) }); + } +} \ No newline at end of file diff --git a/src/assets/help/export.txt b/src/assets/help/export.txt new file mode 100644 index 0000000..726beae --- /dev/null +++ b/src/assets/help/export.txt @@ -0,0 +1,16 @@ +Convert markdown files to other formats like PDF, DOCX or PPTX. + +The export command takes one or more markdown files as input and converts them to the specified format. + +Usage: mdx export [options] + +Options: + --format Output format (pdf, docx, or pptx) + --output Output file path + +The input files will be combined in the order they are specified, with two newlines between each file's content. + +Examples: + mdx export --format pdf --output output.pdf input.md + mdx export --format docx --output presentation.docx slide1.md slide2.md slide3.md + mdx export --format pptx --output deck.pptx content.md \ No newline at end of file diff --git a/src/mdx.csproj b/src/mdx.csproj index 22a7f98..759708e 100644 --- a/src/mdx.csproj +++ b/src/mdx.csproj @@ -21,6 +21,8 @@ + + From b2c11ea91f84af7633e6bb9c37b703f1a80f1316 Mon Sep 17 00:00:00 2001 From: "copilot-developer-agent-robch[bot]" <175728472+Copilot@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:31:51 +0000 Subject: [PATCH 3/3] Add tests for markdown export functionality --- tests/ExportCommandTests.cs | 78 ++++++++++++++++++++++++++++++++++ tests/MarkdownExporterTests.cs | 62 +++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/ExportCommandTests.cs create mode 100644 tests/MarkdownExporterTests.cs diff --git a/tests/ExportCommandTests.cs b/tests/ExportCommandTests.cs new file mode 100644 index 0000000..6693779 --- /dev/null +++ b/tests/ExportCommandTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using mdx.Commands; +using mdx.Exporters; +using NUnit.Framework; + +[TestFixture] +public class ExportCommandTests +{ + private string _testDir; + private string _inputFile; + private string _outputFile; + + [SetUp] + public void Setup() + { + _testDir = Path.Combine(Path.GetTempPath(), "mdx-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_testDir); + _inputFile = Path.Combine(_testDir, "input.md"); + _outputFile = Path.Combine(_testDir, "output.pdf"); + + // Create a test markdown file + File.WriteAllText(_inputFile, "# Test Heading\nTest content"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + + [Test] + public void ExportCommand_ValidateThrowsWhenFormatMissing() + { + var cmd = new ExportCommand { OutputPath = "test.pdf", Files = { "input.md" } }; + Assert.Throws(() => cmd.Validate()); + } + + [Test] + public void ExportCommand_ValidateThrowsWhenOutputMissing() + { + var cmd = new ExportCommand { Format = "pdf", Files = { "input.md" } }; + Assert.Throws(() => cmd.Validate()); + } + + [Test] + public void ExportCommand_ValidateThrowsWhenNoFiles() + { + var cmd = new ExportCommand { Format = "pdf", OutputPath = "test.pdf" }; + Assert.Throws(() => cmd.Validate()); + } + + [Test] + public void ExportCommand_ValidateThrowsWhenFormatInvalid() + { + var cmd = new ExportCommand { Format = "invalid", OutputPath = "test.pdf", Files = { "input.md" } }; + Assert.Throws(() => cmd.Validate()); + } + + [Test] + public void ExportCommand_Execute_CreatesOutputFile() + { + var cmd = new ExportCommand + { + Format = "pdf", + OutputPath = _outputFile, + Files = { _inputFile } + }; + + cmd.Execute(); + + Assert.That(File.Exists(_outputFile)); + Assert.That(new FileInfo(_outputFile).Length, Is.GreaterThan(0)); + } +} \ No newline at end of file diff --git a/tests/MarkdownExporterTests.cs b/tests/MarkdownExporterTests.cs new file mode 100644 index 0000000..4eb553c --- /dev/null +++ b/tests/MarkdownExporterTests.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using mdx.Exporters; +using NUnit.Framework; + +[TestFixture] +public class MarkdownExporterTests +{ + private string _testDir; + + [SetUp] + public void Setup() + { + _testDir = Path.Combine(Path.GetTempPath(), "mdx-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + + [Test] + public void PdfExporter_CreatesValidPdf() + { + var exporter = new PdfMarkdownExporter(); + var outputPath = Path.Combine(_testDir, "test.pdf"); + + exporter.Export("# Test\nContent", outputPath); + + Assert.That(File.Exists(outputPath)); + Assert.That(new FileInfo(outputPath).Length, Is.GreaterThan(0)); + } + + [Test] + public void DocxExporter_CreatesValidDocx() + { + var exporter = new DocxMarkdownExporter(); + var outputPath = Path.Combine(_testDir, "test.docx"); + + exporter.Export("# Test\nContent", outputPath); + + Assert.That(File.Exists(outputPath)); + Assert.That(new FileInfo(outputPath).Length, Is.GreaterThan(0)); + } + + [Test] + public void PptxExporter_CreatesValidPptx() + { + var exporter = new PptxMarkdownExporter(); + var outputPath = Path.Combine(_testDir, "test.pptx"); + + exporter.Export("# Test\nContent", outputPath); + + Assert.That(File.Exists(outputPath)); + Assert.That(new FileInfo(outputPath).Length, Is.GreaterThan(0)); + } +} \ No newline at end of file