diff --git a/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs b/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs index 34886bb2..bd4af885 100644 --- a/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs +++ b/Application/FileConverter/ConversionJobs/ConversionJobFactory.cs @@ -28,6 +28,11 @@ public static ConversionJob Create(ConversionPreset conversionPreset, string inp return new ConversionJob_PowerPoint(conversionPreset, inputFilePath); } + if (inputFileExtension == "md") + { + return new ConversionJob_Markdown(conversionPreset, inputFilePath); + } + if (conversionPreset.OutputType == OutputType.Ico) { return new ConversionJob_Ico(conversionPreset, inputFilePath); diff --git a/Application/FileConverter/ConversionJobs/ConversionJob_Markdown.cs b/Application/FileConverter/ConversionJobs/ConversionJob_Markdown.cs new file mode 100644 index 00000000..2cb27476 --- /dev/null +++ b/Application/FileConverter/ConversionJobs/ConversionJob_Markdown.cs @@ -0,0 +1,412 @@ +// License: http://www.gnu.org/licenses/gpl.html GPL version 3. + +namespace FileConverter.ConversionJobs +{ + using System; + using System.IO; + using System.Text; + + using Markdig; + using Markdig.Syntax; + using Markdig.Syntax.Inlines; + + using QuestPDF.Fluent; + using QuestPDF.Helpers; + using QuestPDF.Infrastructure; + + using FileConverter.Diagnostics; + + public class ConversionJob_Markdown : ConversionJob + { + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + + public ConversionJob_Markdown() : base() + { + } + + public ConversionJob_Markdown(ConversionPreset conversionPreset, string inputFilePath) : base(conversionPreset, inputFilePath) + { + } + + protected override void Initialize() + { + base.Initialize(); + + // Local, offline acknowledgement of the QuestPDF Community license. No network call. + QuestPDF.Settings.License = LicenseType.Community; + + if (this.ConversionPreset == null) + { + throw new Exception("The conversion preset must be valid."); + } + } + + protected override void Convert() + { + if (this.ConversionPreset == null) + { + throw new Exception("The conversion preset must be valid."); + } + + this.UserState = Properties.Resources.ConversionStateReadDocument; + + string markdownContent = File.ReadAllText(this.InputFilePath); + MarkdownDocument document = Markdig.Markdown.Parse(markdownContent, Pipeline); + + this.Progress = 0.3f; + this.UserState = Properties.Resources.ConversionStateConversion; + + Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(2, Unit.Centimetre); + page.DefaultTextStyle(style => style.FontSize(11)); + + page.Content().Column(column => + { + column.Spacing(8); + + foreach (Block block in document) + { + RenderBlock(column, block); + } + }); + }); + }).GeneratePdf(this.OutputFilePath); + + this.Progress = 0.95f; + + Debug.Log($"Write markdown to pdf succeed (output: {this.OutputFilePath})."); + } + + private static void RenderBlock(ColumnDescriptor column, Block block) + { + if (block is HeadingBlock heading) + { + RenderHeading(column, heading); + } + else if (block is Markdig.Extensions.Tables.Table table) + { + RenderTable(column, table); + } + else if (block is ListBlock list) + { + RenderList(column, list, 0); + } + else if (block is QuoteBlock quote) + { + RenderQuote(column, quote); + } + else if (block is FencedCodeBlock fencedCode) + { + RenderCodeBlock(column, fencedCode); + } + else if (block is CodeBlock code) + { + RenderCodeBlock(column, code); + } + else if (block is ParagraphBlock paragraph) + { + column.Item().Text(text => RenderInlines(text, paragraph.Inline, false, false)); + } + else if (block is ThematicBreakBlock) + { + column.Item().PaddingVertical(4).Height(1).Background(Colors.Grey.Lighten2); + } + else if (block is ContainerBlock container) + { + // Fallback for any container block type not handled explicitly above: render its children. + foreach (Block child in container) + { + RenderBlock(column, child); + } + } + } + + private static void RenderHeading(ColumnDescriptor column, HeadingBlock heading) + { + float fontSize; + switch (heading.Level) + { + case 1: + fontSize = 24f; + break; + + case 2: + fontSize = 20f; + break; + + case 3: + fontSize = 17f; + break; + + case 4: + fontSize = 14.5f; + break; + + case 5: + fontSize = 12.5f; + break; + + default: + fontSize = 11.5f; + break; + } + + column.Item().PaddingTop(heading.Level == 1 ? 0 : 6).Text(text => + { + text.DefaultTextStyle(style => style.FontSize(fontSize).Bold()); + RenderInlines(text, heading.Inline, false, false); + }); + } + + private static void RenderList(ColumnDescriptor column, ListBlock list, int nestingLevel) + { + int orderedIndex = 1; + + foreach (Block itemBlock in list) + { + ListItemBlock item = (ListItemBlock)itemBlock; + string bulletText = list.IsOrdered ? orderedIndex + "." : "•"; + orderedIndex++; + + column.Item().Row(row => + { + row.ConstantItem(14 * (nestingLevel + 1)); + row.ConstantItem(20).Text(bulletText); + row.RelativeItem().Column(inner => + { + inner.Spacing(4); + + foreach (Block child in item) + { + if (child is ListBlock nestedList) + { + RenderList(inner, nestedList, nestingLevel + 1); + } + else if (child is ParagraphBlock paragraph) + { + inner.Item().Text(text => RenderInlines(text, paragraph.Inline, false, false)); + } + else + { + RenderBlock(inner, child); + } + } + }); + }); + } + } + + private static void RenderQuote(ColumnDescriptor column, QuoteBlock quote) + { + column.Item().BorderLeft(2).BorderColor(Colors.Grey.Darken1).PaddingLeft(10).Column(inner => + { + inner.Spacing(4); + + foreach (Block child in quote) + { + RenderBlock(inner, child); + } + }); + } + + private static void RenderCodeBlock(ColumnDescriptor column, LeafBlock codeBlock) + { + string code = codeBlock.Lines.ToString(); + + column.Item().Background(Colors.Grey.Lighten3).Padding(8).Text(text => + { + text.DefaultTextStyle(style => style.FontFamily("Consolas").FontSize(9.5f)); + text.Span(code); + }); + } + + private static void RenderTable(ColumnDescriptor column, Markdig.Extensions.Tables.Table table) + { + int columnCount = table.ColumnDefinitions.Count; + if (columnCount == 0 && table.Count > 0) + { + columnCount = ((Markdig.Extensions.Tables.TableRow)table[0]).Count; + } + + if (columnCount == 0) + { + return; + } + + column.Item().PaddingVertical(4).Table(questTable => + { + questTable.ColumnsDefinition(columns => + { + for (int index = 0; index < columnCount; index++) + { + columns.RelativeColumn(); + } + }); + + foreach (Block rowBlock in table) + { + Markdig.Extensions.Tables.TableRow row = (Markdig.Extensions.Tables.TableRow)rowBlock; + + foreach (Block cellBlock in row) + { + Markdig.Extensions.Tables.TableCell cell = (Markdig.Extensions.Tables.TableCell)cellBlock; + + questTable.Cell() + .Border(1) + .BorderColor(Colors.Grey.Lighten1) + .Background(row.IsHeader ? Colors.Grey.Lighten3 : Colors.White) + .Padding(4) + .Text(text => + { + if (row.IsHeader) + { + text.DefaultTextStyle(style => style.Bold()); + } + + foreach (Block cellContent in cell) + { + if (cellContent is ParagraphBlock paragraph) + { + RenderInlines(text, paragraph.Inline, false, false); + } + } + }); + } + } + }); + } + + private static void RenderInlines(TextDescriptor text, ContainerInline container, bool bold, bool italic) + { + if (container == null) + { + return; + } + + for (Inline inline = container.FirstChild; inline != null; inline = inline.NextSibling) + { + if (inline is LiteralInline literal) + { + AddSpan(text, literal.Content.ToString(), bold, italic, false); + } + else if (inline is CodeInline code) + { + AddSpan(text, code.Content, bold, italic, true); + } + else if (inline is EmphasisInline emphasis) + { + bool nestedBold = bold || emphasis.DelimiterCount >= 2; + bool nestedItalic = italic || emphasis.DelimiterCount == 1; + RenderInlines(text, emphasis, nestedBold, nestedItalic); + } + else if (inline is LineBreakInline) + { + text.Line(string.Empty); + } + else if (inline is LinkInline link) + { + RenderLink(text, link, bold, italic); + } + else if (inline is ContainerInline nestedContainer) + { + RenderInlines(text, nestedContainer, bold, italic); + } + } + } + + private static void RenderLink(TextDescriptor text, LinkInline link, bool bold, bool italic) + { + string linkText = ExtractText(link); + + if (link.IsImage) + { + // Images are not embedded (no image download/decoding pipeline for this job); render a text reference instead. + AddSpan(text, "[" + (string.IsNullOrEmpty(linkText) ? link.Url : linkText) + "]", bold, italic, false); + return; + } + + if (string.IsNullOrEmpty(linkText)) + { + linkText = link.Url ?? string.Empty; + } + + if (string.IsNullOrEmpty(link.Url)) + { + AddSpan(text, linkText, bold, italic, false); + return; + } + + text.Hyperlink(linkText, link.Url).Underline().FontColor(Colors.Blue.Darken1); + } + + private static string ExtractText(ContainerInline container) + { + StringBuilder builder = new StringBuilder(); + + for (Inline inline = container.FirstChild; inline != null; inline = inline.NextSibling) + { + if (inline is LiteralInline literal) + { + builder.Append(literal.Content.ToString()); + } + else if (inline is ContainerInline nested) + { + builder.Append(ExtractText(nested)); + } + } + + return builder.ToString(); + } + + private static void AddSpan(TextDescriptor text, string content, bool bold, bool italic, bool code) + { + if (string.IsNullOrEmpty(content)) + { + return; + } + + if (code) + { + if (bold && italic) + { + text.Span(content).FontFamily("Consolas").BackgroundColor(Colors.Grey.Lighten3).Bold().Italic(); + } + else if (bold) + { + text.Span(content).FontFamily("Consolas").BackgroundColor(Colors.Grey.Lighten3).Bold(); + } + else if (italic) + { + text.Span(content).FontFamily("Consolas").BackgroundColor(Colors.Grey.Lighten3).Italic(); + } + else + { + text.Span(content).FontFamily("Consolas").BackgroundColor(Colors.Grey.Lighten3); + } + + return; + } + + if (bold && italic) + { + text.Span(content).Bold().Italic(); + } + else if (bold) + { + text.Span(content).Bold(); + } + else if (italic) + { + text.Span(content).Italic(); + } + else + { + text.Span(content); + } + } + } +} diff --git a/Application/FileConverter/FileConverter.csproj b/Application/FileConverter/FileConverter.csproj index fd50ac17..39b459a7 100644 --- a/Application/FileConverter/FileConverter.csproj +++ b/Application/FileConverter/FileConverter.csproj @@ -123,6 +123,7 @@ + @@ -395,6 +396,9 @@ 14.10.2 + + 1.3.2 + 10.0.3 @@ -410,6 +414,9 @@ 1.7.4.11 + + 2026.7.0 + 2.7.2 diff --git a/Application/FileConverter/Helpers.cs b/Application/FileConverter/Helpers.cs index 1112e10f..27d6fa62 100644 --- a/Application/FileConverter/Helpers.cs +++ b/Application/FileConverter/Helpers.cs @@ -21,7 +21,7 @@ public static class Helpers { public static readonly string[] CompatibleInputExtensions = { "3gp","3gpp","aac","aiff","ape","arw","avi","avif","bik","bmp","cda","cr2","dds","dng","doc","docx", - "exr","flac","flv","gif","heic","ico","jfif","jpg","jpeg","m4a","m4b","m4v","mkv","mov","mp3","mp4", + "exr","flac","flv","gif","heic","ico","jfif","jpg","jpeg","m4a","m4b","m4v","md","mkv","mov","mp3","mp4", "mpg","mpeg","nef","odp","ods","odt","oga","ogg","ogv","opus","pdf","png","ppt","pptx","psd", "raf", "rm","svg","tga","tif","tiff", "ts", "vob","wav","webm","webp","wma","wmv","xls","xlsx" }; @@ -94,6 +94,7 @@ public static string GetExtensionCategory(string extension) case "pdf": case "doc": case "docx": + case "md": case "ppt": case "pptx": case "odp": diff --git a/Application/FileConverter/Settings.default.xml b/Application/FileConverter/Settings.default.xml index 136b2518..6dd5aae9 100644 --- a/Application/FileConverter/Settings.default.xml +++ b/Application/FileConverter/Settings.default.xml @@ -670,6 +670,7 @@ xcf doc docx + md odp ods odt diff --git a/Installer/Product.wxs b/Installer/Product.wxs index c60f877f..21b71b15 100644 --- a/Installer/Product.wxs +++ b/Installer/Product.wxs @@ -127,7 +127,16 @@ - + + + + + + + + + +