Compare commits

...

37 Commits

Author SHA1 Message Date
Martijn Laarman
d47fbc757f Optimize PipeTable parsing: O(n²) → O(n) for 3.7x–85x speedup, enables 10K+ row tables (#922)
* Optimize PipeTable parsing: O(n²) → O(n) for large tables

Pipe tables were creating deeply nested tree structures where each pipe
delimiter contained all subsequent content as children, causing O(n²)
traversal complexity for n cells. This change restructures the parser to
use a flat sibling-based structure, treating tables as matrices rather
than nested trees.

Key changes:
- Set IsClosed=true on PipeTableDelimiterInline to prevent nesting
- Add PromoteNestedPipesToRootLevel() to flatten pipes nested in emphasis
- Update cell boundary detection to use sibling traversal
- Move EmphasisInlineParser before PipeTableParser in processing order
- Fix EmphasisInlineParser to continue past IsClosed delimiters
- Add ContainsParentOrSiblingOfType<T>() helper for flat structure detection

Performance improvements (measured on typical markdown content):

| Rows | Before    | After   | Speedup |
|------|-----------|---------|---------|
| 100  | 542 μs    | 150 μs  | 3.6x    |
| 500  | 23,018 μs | 763 μs  | 30x     |
| 1000 | 89,418 μs | 1,596 μs| 56x     |
| 1500 | 201,593 μs| 2,740 μs| 74x     |
| 5000 | CRASH     | 10,588 μs| ∞      |
| 10000| CRASH     | 18,551 μs| ∞      |

Tables with 5000+ rows previously crashed due to stack overflow from
recursive depth. They now parse successfully with linear time complexity.

* remove baseline results file

* Do not use System.Index and fix nullabillity checks for older platforms
2026-01-30 22:05:18 +01:00
prozolic
3602433b84 Replace null checks with IsEmpty property for ReadOnlySpan<char> (#916)
This change suppresses CA2265 warnings.
2026-01-30 22:01:50 +01:00
prozolic
1bac4afc9b Use Dictionary.TryAdd instead of ContainsKey and indexer by reducing lookups. (#917)
* Use Dictionary.TryAdd instead of ContainsKey and indexer by reducing lookups.

* Update src/Markdig/Parsers/ParserList.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 22:01:27 +01:00
Tatsunori Uchino
a89056d961 Recognize supplementary characters (#913)
* Recognize supplementary characters

* Internatize Rune

* Fix failing tests

* Fix extra comment error

* Remove extra local variable c

* Reorganize classes around Rune

* Prepare both Rune and char variants / make Rune variant public for .NET

* Make APIs in StringSlice.cs public only in modern .NET

* Throw exception if cannot obtain first Rune

* Add comments

* Add comment on PeekRuneExtra

* Use `Rune.TryCreate`

* Remove backtrack

* Fix parameter name in XML comment

* Don't throw when error in `Rune.DecodeFromUtf16`

* Fix RuneAt

* Add tests of Rune-related methods of `StringSlice`

* Make comment more tolerant of changes

* Tweak comment

* Fix comment

* Add `readonly`

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* Move namespace of polyfilled Rune out of System.Text

* Apply suggestions from code review

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* Fix regression by review suggestion

* Prepare constant for .NET Standard test

* Don't call `IsPunctuationException` if unnecessary

* PR feedback

---------

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>
2026-01-12 11:08:03 +01:00
Miha Zupan
cd7b9ca0ef Test netstandard (#915)
* Add GH Action to test netstandard 2.0 and 2.1

* Account for TFM changes in tests project
2025-11-17 18:46:26 +01:00
Alexandre Mutel
fb698598e4 Use central package management 2025-11-17 08:19:42 +01:00
mos379
12590e5fbe feat(link-helper): improve ASCII normalization handling (#911)
* feat(link-helper): improve ASCII normalization handling

Enhanced the `Urilize` method to better handle ASCII normalization and special characters. Added support for decomposing characters when `allowOnlyAscii` is true and skipping diacritical marks. Introduced handling for special German, Scandinavian, and Icelandic characters via new helper methods: `IsSpecialScandinavianOrGermanChar` and `NormalizeScandinavianOrGermanChar`.

Reorganized `using` directives for better clarity. Updated the processing loop in `Urilize` to handle normalized spans and ASCII equivalents more effectively. These changes improve link generation compatibility across various languages.

* Add tests for Scandinavian and German character normalization

Added tests for NormalizeScandinavianOrGermanChar method to validate character normalization for various special characters in both ASCII and non-ASCII contexts.

* test(link-helper): update ASCII transliteration tests

Updated test cases in `TestUrilizeOnlyAscii_Simple` to reflect
changes in `LinkHelper.Urilize` behavior. Non-ASCII characters
like `æ` and `ø` are now transliterated to their ASCII
equivalents (`ae` and `oe`) instead of being removed.
2025-11-10 22:01:35 +01:00
Miha Zupan
8c01cf0549 Add another test for pipe tables (#907) 2025-10-21 08:37:43 +02:00
Miha Zupan
bcbd8e47ac Lazily allocate ProcessInlinesBegin/End delegates on Blocks (#906) 2025-10-21 08:37:02 +02:00
Miha Zupan
d6e88f16f7 Fix pipe table parsing with a leading paragraph (#905)
* Fix pipe table parsing with a leading paragraph

* Use the alternative approach
2025-10-20 21:43:25 +02:00
Miha Zupan
03bdf60086 Add a basic fuzzing project (#903)
* Add basic fuzzing project

* Mark the project as non-packable
2025-10-17 08:09:28 +02:00
Miha Zupan
5c78932f55 Fix edge cases in EmphasisInlineParser (#902) 2025-10-17 08:07:15 +02:00
Miha Zupan
191e33ab32 Fix build warnings (#899) 2025-10-16 17:25:47 +02:00
Miha Zupan
800235ba7a Fix IndexOutOfRangeException in CodeInlineParser (#900) 2025-10-16 17:25:30 +02:00
Miha Zupan
d5f8a809a0 Move sln to slnx (#901) 2025-10-16 17:24:33 +02:00
Asttear
781d9b5365 Remove leading newline in block attributes (#896)
* Remove leading newline in block attributes

fix #895

* Add handling logic for `\r\n`
2025-10-05 11:21:12 +02:00
Phillip Haydon
543570224e Fix issue where an inline code block that spans multiple lines doesn't parse correctly (#893)
* fixes issue where an inline code block that spans multiple lines doesn't get treated as code

* Update src/Markdig.Tests/TestPipeTable.cs

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* Apply suggestion from @MihaZupan

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* Update src/Markdig.Tests/TestPipeTable.cs

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* fix broken test

* removed unreachable code and added more tests

* Update src/Markdig.Tests/TestPipeTable.cs

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* Update src/Markdig.Tests/TestPipeTable.cs

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

* removed uncessary inline code check

* Update src/Markdig/Parsers/Inlines/CodeInlineParser.cs

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>

---------

Co-authored-by: Miha Zupan <mihazupan.zupan1@gmail.com>
Co-authored-by: Alexandre Mutel <alexandre_mutel@live.com>
2025-10-03 09:34:24 +02:00
Daniel Klecha
4dc0be88b4 add options for link inline (#894)
* add options for link inline

* create LinkOptions and associate it with all four parsers

* set EnableHtmlParsing to true by default
2025-10-03 09:22:51 +02:00
Phillip Haydon
0e9e80e1cd Fix for table depth error when cell contains backticks (#891)
* failing test

* fixed bug with table containing back tick which causes depth error
2025-09-21 16:26:02 +02:00
Alexandre Mutel
1b04599c44 Merge pull request #888 from prozolic/pullreq
Fixes issue #845
2025-09-11 07:55:51 +02:00
prozolic
5e6fb2d1c5 Add test for issue #845 list item blank line 2025-09-08 22:36:09 +09:00
prozolic
14406bc60d Fixes issue #845 2025-09-06 21:10:51 +09:00
Alexandre Mutel
2aa6780a30 Merge pull request #883 from messani/master
Add source position tracking for grid tables
2025-08-28 09:04:44 +02:00
Alexandre Mutel
c43646586c Merge pull request #885 from dannyp32/supportTableWithoutExtraLine
Add support for a table without an extra new line before it
2025-08-28 09:02:29 +02:00
Daniel Pino
d548b82bcd Add support for a table without an extra new line before it 2025-08-09 08:50:49 +00:00
Tibor Peluch
aab5543cb5 Code cleanup 2025-07-14 20:17:50 +02:00
Tibor Peluch
2e1d741aaf Cleaned up code, added tests for source position 2025-07-14 10:23:15 +02:00
Tibor Peluch
80c50e31e2 Attempt to fix tracking of tree node positions (line, column) inside GridTable 2025-07-11 13:25:03 +02:00
Alexandre Mutel
7ff8db9016 Merge pull request #877 from Mertsch/Mertsch-patch-1
Update readme.md
2025-06-19 08:41:54 +02:00
Alexandre Mutel
c69fb9ae73 Merge pull request #879 from stylefish/issue878
Fixes #878: RoundtripRenderer: render indent and 0 blocks for ordered lists
2025-06-19 08:41:10 +02:00
stylefish
5a3c206076 Fixes #878: render indent and 0 blocks 2025-06-16 11:26:23 +02:00
Mertsch
b92890094c Update readme.md 2025-06-12 14:26:00 +02:00
Alexandre Mutel
682c727288 Merge pull request #876 from Akarinnnnn/fix-872
Fix #872 by reserve null title string.
2025-06-05 07:57:29 +02:00
Fa鸽
ec2eef25b2 Remove HtmlHelper.UnescapeNullable 2025-06-04 19:23:18 +08:00
Fa鸽
6261660d37 Explain why not to normalize link title into empty strings 2025-05-31 22:26:33 +08:00
Fa鸽
6d1fa96389 Changed link parsing tests for #872 2025-05-31 16:33:29 +08:00
Fa鸽
47c4e9b1e2 Fix #872 by reserve null title string. 2025-05-31 16:01:42 +08:00
76 changed files with 4163 additions and 400 deletions

View File

@@ -12,8 +12,9 @@ insert_final_newline = false
trim_trailing_whitespace = true
# Solution Files
[*.sln]
indent_style = tab
[*.slnx]
indent_size = 2
insert_final_newline = true
# XML Project Files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
@@ -35,3 +36,8 @@ insert_final_newline = true
# Bash Files
[*.sh]
end_of_line = lf
# C# files
[*.cs]
# License header
file_header_template = Copyright (c) Alexandre Mutel. All rights reserved.\nThis file is licensed under the BSD-Clause 2 license.\nSee the license.txt file in the project root for more information.

2
.gitattributes vendored
View File

@@ -1,3 +1,3 @@
* text=auto
*.cs text=auto diff=csharp
*.sln text=auto eol=crlf
*.slnx text=auto eol=crlf

44
.github/workflows/test-netstandard.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Test netstandard
on: pull_request
jobs:
test-netstandard:
runs-on: ubuntu-latest
strategy:
matrix:
netstandard-version: ['netstandard2.0', 'netstandard2.1']
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
- name: Patch build to test ${{ matrix.netstandard-version }}
run: |
cd src
sed -i 's/<TargetFrameworks>.*<\/TargetFrameworks>/<TargetFrameworks>${{ matrix.netstandard-version }}<\/TargetFrameworks>/' Markdig/Markdig.targets
sed -i 's/<TargetFrameworks>.*<\/TargetFrameworks>/<TargetFrameworks>net8.0;net9.0<\/TargetFrameworks>/' Markdig.Tests/Markdig.Tests.csproj
echo "Markdig.targets TFMs:"
grep "TargetFrameworks" Markdig/Markdig.targets
echo "Markdig.Tests.csproj TFMs:"
grep "TargetFrameworks" Markdig.Tests/Markdig.Tests.csproj
- name: Restore dependencies
run: dotnet restore src/Markdig.Tests/Markdig.Tests.csproj
- name: Test Debug
run: |
dotnet build src/Markdig.Tests/Markdig.Tests.csproj -c Debug --no-restore
dotnet test src/Markdig.Tests/Markdig.Tests.csproj -c Debug --no-build
- name: Test Release
run: |
dotnet build src/Markdig.Tests/Markdig.Tests.csproj -c Release --no-restore
dotnet test src/Markdig.Tests/Markdig.Tests.csproj -c Release --no-build

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@
*.sln.docstates
*.nuget.props
*.nuget.targets
src/.idea
BenchmarkDotNet.Artifacts
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs

View File

@@ -2,7 +2,7 @@
<img align="right" width="160px" height="160px" src="img/markdig.png">
Markdig is a fast, powerful, [CommonMark](http://commonmark.org/) compliant, extensible Markdown processor for .NET.
Markdig is a fast, powerful, [CommonMark](https://commonmark.org/) compliant, extensible Markdown processor for .NET.
> NOTE: The repository is under construction. There will be a dedicated website and proper documentation at some point!
@@ -14,7 +14,7 @@ You can **try Markdig online** and compare it to other implementations on [babel
- **Abstract Syntax Tree** with precise source code location for syntax tree, useful when building a Markdown editor.
- Checkout [Markdown Editor v2 for Visual Studio 2022](https://marketplace.visualstudio.com/items?itemName=MadsKristensen.MarkdownEditor2) powered by Markdig!
- Converter to **HTML**
- Passing more than **600+ tests** from the latest [CommonMark specs (0.31.2)](http://spec.commonmark.org/)
- Passing more than **600+ tests** from the latest [CommonMark specs (0.31.2)](https://spec.commonmark.org/)
- Includes all the core elements of CommonMark:
- including **GFM fenced code blocks**.
- **Extensible** architecture
@@ -22,9 +22,9 @@ You can **try Markdig online** and compare it to other implementations on [babel
- [**Roundtrip support**](./src/Markdig/Roundtrip.md): Parses trivia (whitespace, newlines and other characters) to support lossless parse ⭢ render roundtrip. This enables changing markdown documents without introducing undesired trivia changes.
- Built-in with **20+ extensions**, including:
- 2 kind of tables:
- [**Pipe tables**](src/Markdig.Tests/Specs/PipeTableSpecs.md) (inspired from GitHub tables and [PanDoc - Pipe Tables](http://pandoc.org/README.html#extension-pipe_tables))
- [**Grid tables**](src/Markdig.Tests/Specs/GridTableSpecs.md) (inspired from [Pandoc - Grid Tables](http://pandoc.org/README.html#extension-grid_tables))
- [**Extra emphasis**](src/Markdig.Tests/Specs/EmphasisExtraSpecs.md) (inspired from [Pandoc - Emphasis](http://pandoc.org/README.html#strikeout) and [Markdown-it](https://markdown-it.github.io/))
- [**Pipe tables**](src/Markdig.Tests/Specs/PipeTableSpecs.md) (inspired from GitHub tables and [PanDoc - Pipe Tables](https://pandoc.org/MANUAL.html#extension-pipe_tables))
- [**Grid tables**](src/Markdig.Tests/Specs/GridTableSpecs.md) (inspired from [Pandoc - Grid Tables](https://pandoc.org/MANUAL.html#extension-grid_tables))
- [**Extra emphasis**](src/Markdig.Tests/Specs/EmphasisExtraSpecs.md) (inspired from [Pandoc - Emphasis](https://pandoc.org/MANUAL.html#strikeout) and [Markdown-it](https://markdown-it.github.io/))
- strike through `~~`,
- Subscript `~`
- Superscript `^`
@@ -33,7 +33,7 @@ You can **try Markdig online** and compare it to other implementations on [babel
- [**Special attributes**](src/Markdig.Tests/Specs/GenericAttributesSpecs.md) or attached HTML attributes (inspired from [PHP Markdown Extra - Special Attributes](https://michelf.ca/projects/php-markdown/extra/#spe-attr))
- [**Definition lists**](src/Markdig.Tests/Specs/DefinitionListSpecs.md) (inspired from [PHP Markdown Extra - Definitions Lists](https://michelf.ca/projects/php-markdown/extra/#def-list))
- [**Footnotes**](src/Markdig.Tests/Specs/FootnotesSpecs.md) (inspired from [PHP Markdown Extra - Footnotes](https://michelf.ca/projects/php-markdown/extra/#footnotes))
- [**Auto-identifiers**](src/Markdig.Tests/Specs/AutoIdentifierSpecs.md) for headings (similar to [Pandoc - Auto Identifiers](http://pandoc.org/README.html#extension-auto_identifiers))
- [**Auto-identifiers**](src/Markdig.Tests/Specs/AutoIdentifierSpecs.md) for headings (similar to [Pandoc - Auto Identifiers](https://pandoc.org/MANUAL.html#extension-auto_identifiers))
- [**Auto-links**](src/Markdig.Tests/Specs/AutoLinks.md) generates links if a text starts with `http://` or `https://` or `ftp://` or `mailto:` or `www.xxx.yyy`
- [**Task Lists**](src/Markdig.Tests/Specs/TaskListSpecs.md) inspired from [Github Task lists](https://github.com/blog/1375-task-lists-in-gfm-issues-pulls-comments).
- [**Extra bullet lists**](src/Markdig.Tests/Specs/ListExtraSpecs.md), supporting alpha bullet `a.` `b.` and roman bullet (`i`, `ii`...etc.)
@@ -70,7 +70,7 @@ If you are looking for support for an old .NET Framework 3.5 or 4.0, you can dow
While there is not yet a dedicated documentation, you can find from the [specs documentation](src/Markdig.Tests/Specs/readme.md) how to use these extensions.
In the meantime, you can have a "behind the scene" article about Markdig in my blog post ["Implementing a Markdown Engine for .NET"](http://xoofx.github.io/blog/2016/06/13/implementing-a-markdown-processor-for-dotnet/)
In the meantime, you can have a "behind the scene" article about Markdig in my blog post ["Implementing a Markdown Engine for .NET"](https://xoofx.github.io/blog/2016/06/13/implementing-a-markdown-processor-for-dotnet/)
## Download
@@ -153,7 +153,7 @@ image editing, optimization, and delivery server](https://github.com/imazen/imag
## Credits
Thanks to the fantastic work done by [John Mac Farlane](http://johnmacfarlane.net/) for the CommonMark specs and all the people involved in making Markdown a better standard!
Thanks to the fantastic work done by [John Mac Farlane](https://johnmacfarlane.net/) for the CommonMark specs and all the people involved in making Markdown a better standard!
This project would not have been possible without this huge foundation.
@@ -161,7 +161,7 @@ Thanks also to the project [BenchmarkDotNet](https://github.com/PerfDotNet/Bench
Some decoding part (e.g HTML [EntityHelper.cs](https://github.com/lunet-io/markdig/blob/master/src/Markdig/Helpers/EntityHelper.cs)) have been re-used from [CommonMark.NET](https://github.com/Knagis/CommonMark.NET)
Thanks to the work done by @clarkd on the JIRA Link extension (https://github.com/clarkd/MarkdigJiraLinker), now included with this project!
Thanks to the work done by @clarkd on the [JIRA Link extension](https://github.com/clarkd/MarkdigJiraLinker), now included with this project!
## Author
Alexandre MUTEL aka [xoofx](http://xoofx.github.io)
Alexandre MUTEL aka [xoofx](https://xoofx.github.io/)

View File

@@ -0,0 +1,23 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="BenchmarkDotNet.Diagnostics.Windows" Version="0.14.0" />
<PackageVersion Include="CommonMark.NET" Version="0.15.1" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MarkdownSharp" Version="2.0.5" />
<PackageVersion Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.23.0" />
<PackageVersion Include="Microsoft.Diagnostics.Runtime" Version="3.1.512801" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MinVer" Version="6.0.0" />
<PackageVersion Include="NUnit" Version="4.4.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net462' OR '$(TargetFramework)' == 'netstandard2.0'">
<PackageVersion Include="System.Memory" Version="4.6.3" />
</ItemGroup>
</Project>

View File

@@ -19,12 +19,12 @@
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Diagnostics.Windows" Version="0.14.0" />
<PackageReference Include="CommonMark.NET" Version="0.15.1" />
<PackageReference Include="Markdown" Version="2.2.1" />
<PackageReference Include="MarkdownSharp" Version="2.0.5" />
<PackageReference Include="Microsoft.Diagnostics.Runtime" Version="3.1.512801" />
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="BenchmarkDotNet.Diagnostics.Windows" />
<PackageReference Include="CommonMark.NET" />
<PackageReference Include="Markdown" />
<PackageReference Include="MarkdownSharp" />
<PackageReference Include="Microsoft.Diagnostics.Runtime" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Markdig\Markdig.csproj" />

View File

@@ -0,0 +1,81 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using Markdig;
namespace Testamina.Markdig.Benchmarks.PipeTable;
/// <summary>
/// Benchmark for pipe table parsing performance, especially for large tables.
/// Tests the performance of PipeTableParser with varying table sizes.
/// </summary>
[MemoryDiagnoser]
[GcServer(true)] // Use server GC to get more comprehensive GC stats
public class PipeTableBenchmark
{
private string _100Rows = null!;
private string _500Rows = null!;
private string _1000Rows = null!;
private string _1500Rows = null!;
private string _5000Rows = null!;
private string _10000Rows = null!;
private MarkdownPipeline _pipeline = null!;
[GlobalSetup]
public void Setup()
{
// Pipeline with pipe tables enabled (part of advanced extensions)
_pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
// Generate tables of various sizes
// Note: Before optimization, 5000+ rows hit depth limit due to nested tree structure.
// After optimization, these should work.
_100Rows = PipeTableGenerator.Generate(rows: 100, columns: 5);
_500Rows = PipeTableGenerator.Generate(rows: 500, columns: 5);
_1000Rows = PipeTableGenerator.Generate(rows: 1000, columns: 5);
_1500Rows = PipeTableGenerator.Generate(rows: 1500, columns: 5);
_5000Rows = PipeTableGenerator.Generate(rows: 5000, columns: 5);
_10000Rows = PipeTableGenerator.Generate(rows: 10000, columns: 5);
}
[Benchmark(Description = "PipeTable 100 rows x 5 cols")]
public string Parse100Rows()
{
return Markdown.ToHtml(_100Rows, _pipeline);
}
[Benchmark(Description = "PipeTable 500 rows x 5 cols")]
public string Parse500Rows()
{
return Markdown.ToHtml(_500Rows, _pipeline);
}
[Benchmark(Description = "PipeTable 1000 rows x 5 cols")]
public string Parse1000Rows()
{
return Markdown.ToHtml(_1000Rows, _pipeline);
}
[Benchmark(Description = "PipeTable 1500 rows x 5 cols")]
public string Parse1500Rows()
{
return Markdown.ToHtml(_1500Rows, _pipeline);
}
[Benchmark(Description = "PipeTable 5000 rows x 5 cols")]
public string Parse5000Rows()
{
return Markdown.ToHtml(_5000Rows, _pipeline);
}
[Benchmark(Description = "PipeTable 10000 rows x 5 cols")]
public string Parse10000Rows()
{
return Markdown.ToHtml(_10000Rows, _pipeline);
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using System.Text;
namespace Testamina.Markdig.Benchmarks.PipeTable;
/// <summary>
/// Generates pipe table markdown content for benchmarking purposes.
/// </summary>
public static class PipeTableGenerator
{
private const int DefaultCellWidth = 10;
/// <summary>
/// Generates a pipe table in markdown format.
/// </summary>
/// <param name="rows">Number of data rows (excluding header)</param>
/// <param name="columns">Number of columns</param>
/// <param name="cellWidth">Width of each cell content (default: 10)</param>
/// <returns>Pipe table markdown string</returns>
public static string Generate(int rows, int columns, int cellWidth = DefaultCellWidth)
{
var sb = new StringBuilder();
// Header row
sb.Append('|');
for (int col = 0; col < columns; col++)
{
sb.Append(' ');
sb.Append($"Header {col + 1}".PadRight(cellWidth));
sb.Append(" |");
}
sb.AppendLine();
// Separator row (with dashes)
sb.Append('|');
for (int col = 0; col < columns; col++)
{
sb.Append(new string('-', cellWidth + 2));
sb.Append('|');
}
sb.AppendLine();
// Data rows
for (int row = 0; row < rows; row++)
{
sb.Append('|');
for (int col = 0; col < columns; col++)
{
sb.Append(' ');
sb.Append($"R{row + 1}C{col + 1}".PadRight(cellWidth));
sb.Append(" |");
}
sb.AppendLine();
}
return sb.ToString();
}
}

View File

@@ -7,6 +7,7 @@ using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
using Markdig;
using Testamina.Markdig.Benchmarks.PipeTable;
namespace Testamina.Markdig.Benchmarks;
@@ -68,7 +69,16 @@ public class Program
//config.Add(gcDiagnoser);
//var config = DefaultConfig.Instance;
BenchmarkRunner.Run<Program>(config);
// Run specific benchmarks based on command line arguments
if (args.Length > 0 && args[0] == "--pipetable")
{
BenchmarkRunner.Run<PipeTableBenchmark>(config);
}
else
{
BenchmarkRunner.Run<Program>(config);
}
//BenchmarkRunner.Run<TestDictionary>(config);
//BenchmarkRunner.Run<TestMatchPerf>();
//BenchmarkRunner.Run<TestStringPerf>();

4
src/Markdig.Fuzzing/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
corpus
libfuzzer-dotnet-windows.exe
crash-*
timeout-*

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SharpFuzz" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Markdig\Markdig.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,71 @@
using Markdig;
using Markdig.Renderers.Roundtrip;
using Markdig.Syntax;
using SharpFuzz;
using System.Diagnostics;
using System.Text;
ReadOnlySpanAction fuzzTarget = ParseRenderFuzzer.FuzzTarget;
if (args.Length > 0)
{
// Run the target on existing inputs
string[] files = Directory.Exists(args[0])
? Directory.GetFiles(args[0])
: [args[0]];
Debugger.Launch();
foreach (string inputFile in files)
{
fuzzTarget(File.ReadAllBytes(inputFile));
}
}
else
{
Fuzzer.LibFuzzer.Run(fuzzTarget);
}
sealed class ParseRenderFuzzer
{
private static readonly MarkdownPipeline s_advancedPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
private static readonly ResettableRoundtripRenderer _roundtripRenderer = new();
public static void FuzzTarget(ReadOnlySpan<byte> bytes)
{
string text = Encoding.UTF8.GetString(bytes);
try
{
MarkdownDocument document = Markdown.Parse(text);
_ = document.ToHtml();
document = Markdown.Parse(text, s_advancedPipeline);
_ = document.ToHtml(s_advancedPipeline);
document = Markdown.Parse(text, trackTrivia: true);
_ = document.ToHtml();
_roundtripRenderer.Reset();
_roundtripRenderer.Render(document);
_ = Markdown.Normalize(text);
_ = Markdown.ToPlainText(text);
}
catch (Exception ex) when (IsIgnorableException(ex)) { }
}
private static bool IsIgnorableException(Exception exception)
{
return exception.Message.Contains("Markdown elements in the input are too deeply nested", StringComparison.Ordinal);
}
private sealed class ResettableRoundtripRenderer : RoundtripRenderer
{
public ResettableRoundtripRenderer() : base(new StringWriter(new StringBuilder(1024 * 1024))) { }
public new void Reset() => base.Reset();
}
}

View File

@@ -0,0 +1,86 @@
param (
[string]$configuration = $null
)
Set-StrictMode -Version Latest
$libFuzzer = "libfuzzer-dotnet-windows.exe"
$outputDir = "bin"
function Get-LibFuzzer {
param (
[string]$Path
)
$libFuzzerUrl = "https://github.com/Metalnem/libfuzzer-dotnet/releases/download/v2025.05.02.0904/libfuzzer-dotnet-windows.exe"
$expectedHash = "17af5b3f6ff4d2c57b44b9a35c13051b570eb66f0557d00015df3832709050bf"
Write-Output "Downloading libFuzzer from $libFuzzerUrl..."
try {
$tempFile = "$Path.tmp"
Invoke-WebRequest -Uri $libFuzzerUrl -OutFile $tempFile -UseBasicParsing
$downloadedHash = (Get-FileHash -Path $tempFile -Algorithm SHA256).Hash
if ($downloadedHash -eq $ExpectedHash) {
Move-Item -Path $tempFile -Destination $Path -Force
Write-Output "libFuzzer downloaded successfully to $Path"
}
else {
Write-Error "Hash validation failed."
Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
}
catch {
Write-Error "Failed to download libFuzzer: $($_.Exception.Message)"
Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
}
# Check if libFuzzer exists, download if not
if (-not (Test-Path $libFuzzer)) {
Get-LibFuzzer -Path $libFuzzer
}
$toolListOutput = dotnet tool list --global sharpFuzz.CommandLine 2>$null
if (-not ($toolListOutput -match "sharpfuzz")) {
Write-Output "Installing sharpfuzz CLI"
dotnet tool install --global sharpFuzz.CommandLine
}
if (Test-Path $outputDir) {
Remove-Item -Recurse -Force $outputDir
}
if ($configuration -eq $null) {
$configuration = "Debug"
}
dotnet publish -c $configuration -o $outputDir
$project = Join-Path $outputDir "Markdig.Fuzzing.dll"
$fuzzingTarget = Join-Path $outputDir "Markdig.dll"
Write-Output "Instrumenting $fuzzingTarget"
& sharpfuzz $fuzzingTarget
if ($LastExitCode -ne 0) {
Write-Error "An error occurred while instrumenting $fuzzingTarget"
exit 1
}
New-Item -ItemType Directory -Force -Path corpus | Out-Null
$libFuzzerArgs = @("--target_path=dotnet", "--target_arg=$project", "-timeout=10", "corpus")
# Add any additional arguments passed to the script
if ($args) {
$libFuzzerArgs += $args
}
Write-Output "Starting libFuzzer with arguments: $libFuzzerArgs"
& ./$libFuzzer @libFuzzerArgs

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -9,12 +9,13 @@
<StartupObject>Markdig.Tests.Program</StartupObject>
<SpecExecutable>$(MSBuildProjectDirectory)\..\SpecFileGen\bin\$(Configuration)\$(TargetFramework)\SpecFileGen.dll</SpecExecutable>
<SpecTimestamp>$(MSBuildProjectDirectory)\..\SpecFileGen\bin\$(Configuration)\$(TargetFramework)\SpecFileGen.timestamp</SpecTimestamp>
<NoWarn>$(NoWarn);NETSDK1138</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
</ItemGroup>
<ItemGroup>
@@ -35,10 +36,10 @@
<InputSpecFiles Remove="Specs\readme.md" />
<!-- Allow Visual Studio up-to-date check to verify that nothing has changed - https://github.com/dotnet/project-system/blob/main/docs/up-to-date-check.md -->
<UpToDateCheckInput Include="@(InputSpecFiles)" />
<OutputSpecFiles Include="@(InputSpecFiles->'%(RelativeDir)%(Filename).generated.cs')" />
<OutputSpecFiles Include="@(InputSpecFiles-&gt;'%(RelativeDir)%(Filename).generated.cs')" />
</ItemGroup>
<Target Name="GeneratedSpecsFile" BeforeTargets="BeforeCompile;CoreCompile" Inputs="@(ItemSpecExecutable);@(InputSpecFiles)" Outputs="@(ItemSpecExecutable->'%(RelativeDir)%(Filename).timestamp');@(InputSpecFiles->'%(RelativeDir)%(Filename).generated.cs')">
<Target Name="GeneratedSpecsFile" BeforeTargets="BeforeCompile;CoreCompile" Inputs="@(ItemSpecExecutable);@(InputSpecFiles)" Outputs="@(ItemSpecExecutable-&gt;'%(RelativeDir)%(Filename).timestamp');@(InputSpecFiles-&gt;'%(RelativeDir)%(Filename).generated.cs')">
<Message Importance="high" Text="Regenerating Specs Files" />
<Exec Command="dotnet $(SpecExecutable)" />
<WriteLinesToFile File="$(SpecTimestamp)" Lines="$([System.DateTime]::Now)" />

View File

@@ -386,4 +386,27 @@ Also not a note.</p>
";
TestParser.TestSpec(input, expected, new MarkdownPipelineBuilder().UseAlertBlocks().Build());
}
[Test]
public void TestIssue845ListItemBlankLine()
{
TestParser.TestSpec("-\n\n foo",@"
<ul>
<li></li>
</ul>
<p>foo</p>");
TestParser.TestSpec("-\n-\n\n foo",@"
<ul>
<li></li>
<li></li>
</ul>
<p>foo</p>");
TestParser.TestSpec("-\n\n-\n\n foo",@"
<ul>
<li></li>
<li></li>
</ul>
<p>foo</p>");
}
}

View File

@@ -25,6 +25,7 @@ public class TestUnorderedList
[TestCase("-\ti1")]
[TestCase("-\ti1\n-\ti2")]
[TestCase("-\ti1\n- i2\n-\ti3")]
[TestCase("- 1.\n- 2.")]
public void Test(string value)
{
RoundTrip(value);

View File

@@ -98,5 +98,22 @@ namespace Markdig.Tests.Specs.GenericAttributes
TestParser.TestSpec("[Foo](url){data-x=1}\n\n[Foo](url){data-x='1'}\n\n[Foo](url){data-x=11}", "<p><a href=\"url\" data-x=\"1\">Foo</a></p>\n<p><a href=\"url\" data-x=\"1\">Foo</a></p>\n<p><a href=\"url\" data-x=\"11\">Foo</a></p>", "attributes|advanced", context: "Example 3\nSection Extensions / Generic Attributes\n");
}
// Attributes that occur immediately before a block element, on a line by themselves, affect that element
[Test]
public void ExtensionsGenericAttributes_Example004()
{
// Example 4
// Section: Extensions / Generic Attributes
//
// The following Markdown:
// {.center}
// A paragraph
//
// Should be rendered as:
// <p class="center">A paragraph</p>
TestParser.TestSpec("{.center}\nA paragraph", "<p class=\"center\">A paragraph</p>", "attributes|advanced", context: "Example 4\nSection Extensions / Generic Attributes\n");
}
}
}

View File

@@ -61,3 +61,12 @@ Attribute values can be one character long
<p><a href="url" data-x="1">Foo</a></p>
<p><a href="url" data-x="11">Foo</a></p>
````````````````````````````````
Attributes that occur immediately before a block element, on a line by themselves, affect that element
```````````````````````````````` example
{.center}
A paragraph
.
<p class="center">A paragraph</p>
````````````````````````````````

View File

@@ -386,5 +386,34 @@ namespace Markdig.Tests.Specs.GridTables
TestParser.TestSpec("+", "<ul>\n<li></li>\n</ul>", "gridtables|advanced", context: "Example 11\nSection Extensions / Grid Table\n");
}
// A table may begin right after a paragraph without an empty line in between:
[Test]
public void ExtensionsGridTable_Example012()
{
// Example 12
// Section: Extensions / Grid Table
//
// The following Markdown:
// Some
// **text**.
// +---+
// | A |
// +---+
//
// Should be rendered as:
// <p>Some
// <strong>text</strong>.</p>
// <table>
// <col style="width:100%" />
// <tbody>
// <tr>
// <td>A</td>
// </tr>
// </tbody>
// </table>
TestParser.TestSpec("Some\n**text**.\n+---+\n| A |\n+---+", "<p>Some\n<strong>text</strong>.</p>\n<table>\n<col style=\"width:100%\" />\n<tbody>\n<tr>\n<td>A</td>\n</tr>\n</tbody>\n</table>", "gridtables|advanced", context: "Example 12\nSection Extensions / Grid Table\n");
}
}
}

View File

@@ -285,3 +285,24 @@ An empty `+` on a line should result in a simple empty list output:
<li></li>
</ul>
````````````````````````````````
A table may begin right after a paragraph without an empty line in between:
```````````````````````````````` example
Some
**text**.
+---+
| A |
+---+
.
<p>Some
<strong>text</strong>.</p>
<table>
<col style="width:100%" />
<tbody>
<tr>
<td>A</td>
</tr>
</tbody>
</table>
````````````````````````````````

View File

@@ -825,5 +825,190 @@ namespace Markdig.Tests.Specs.PipeTables
TestParser.TestSpec("a | b\n-- | - \n0 | 1 | 2", "<table>\n<thead>\n<tr>\n<th>a</th>\n<th>b</th>\n<th></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>0</td>\n<td>1</td>\n<td>2</td>\n</tr>\n</tbody>\n</table>", "pipetables|advanced", context: "Example 25\nSection Extensions / Pipe Table\n");
}
// A table may begin right after a paragraph without an empty line in between:
[Test]
public void ExtensionsPipeTable_Example026()
{
// Example 26
// Section: Extensions / Pipe Table
//
// The following Markdown:
// Some
// **text**.
// | A |
// |---|
// | B |
//
// Should be rendered as:
// <p>Some
// <strong>text</strong>.</p>
// <table>
// <thead>
// <tr>
// <th>A</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>B</td>
// </tr>
// </tbody>
// </table>
TestParser.TestSpec("Some\n**text**.\n| A |\n|---|\n| B |", "<p>Some\n<strong>text</strong>.</p>\n<table>\n<thead>\n<tr>\n<th>A</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>B</td>\n</tr>\n</tbody>\n</table>", "pipetables|advanced", context: "Example 26\nSection Extensions / Pipe Table\n");
}
// Tables can be nested inside other blocks, like lists:
[Test]
public void ExtensionsPipeTable_Example027()
{
// Example 27
// Section: Extensions / Pipe Table
//
// The following Markdown:
// Bullet list
// * Table 1
//
// | Header 1 | Header 2 |
// |----------------|----------------|
// | Row 1 Column 1 | Row 1 Column 2 |
//
// * Table 2
// | Header 1 | Header 2 |
// |----------------|----------------|
// | Row 1 Column 1 | Row 1 Column 2 |
//
// * Table 3
// Lorem ipsum ...
// Lorem ipsum ...
// | Header 1 | Header 2 |
// |----------------|----------------|
// | Row 1 Column 1 | Row 1 Column 2 |
//
//
// Ordered list
// 1. Table 1
//
// | Header 1 | Header 2 |
// |----------------|----------------|
// | Row 1 Column 1 | Row 1 Column 2 |
//
// 2. Table 2
// | Header 1 | Header 2 |
// |----------------|----------------|
// | Row 1 Column 1 | Row 1 Column 2 |
//
// 3. Table 3
// Lorem ipsum ...
// Lorem ipsum ...
// | Header 1 | Header 2 |
// |----------------|----------------|
// | Row 1 Column 1 | Row 1 Column 2 |
//
// Should be rendered as:
// <p>Bullet list</p>
// <ul>
// <li><p>Table 1</p>
// <table>
// <thead>
// <tr>
// <th>Header 1</th>
// <th>Header 2</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>Row 1 Column 1</td>
// <td>Row 1 Column 2</td>
// </tr>
// </tbody>
// </table></li>
// <li><p>Table 2</p>
// <table>
// <thead>
// <tr>
// <th>Header 1</th>
// <th>Header 2</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>Row 1 Column 1</td>
// <td>Row 1 Column 2</td>
// </tr>
// </tbody>
// </table></li>
// <li><p>Table 3
// Lorem ipsum ...
// Lorem ipsum ...</p>
// <table>
// <thead>
// <tr>
// <th>Header 1</th>
// <th>Header 2</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>Row 1 Column 1</td>
// <td>Row 1 Column 2</td>
// </tr>
// </tbody>
// </table></li>
// </ul>
// <p>Ordered list</p>
// <ol>
// <li><p>Table 1</p>
// <table>
// <thead>
// <tr>
// <th>Header 1</th>
// <th>Header 2</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>Row 1 Column 1</td>
// <td>Row 1 Column 2</td>
// </tr>
// </tbody>
// </table></li>
// <li><p>Table 2</p>
// <table>
// <thead>
// <tr>
// <th>Header 1</th>
// <th>Header 2</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>Row 1 Column 1</td>
// <td>Row 1 Column 2</td>
// </tr>
// </tbody>
// </table></li>
// <li><p>Table 3
// Lorem ipsum ...
// Lorem ipsum ...</p>
// <table>
// <thead>
// <tr>
// <th>Header 1</th>
// <th>Header 2</th>
// </tr>
// </thead>
// <tbody>
// <tr>
// <td>Row 1 Column 1</td>
// <td>Row 1 Column 2</td>
// </tr>
// </tbody>
// </table></li>
// </ol>
TestParser.TestSpec("Bullet list\n* Table 1\n\n | Header 1 | Header 2 |\n |----------------|----------------|\n | Row 1 Column 1 | Row 1 Column 2 |\n\n* Table 2\n | Header 1 | Header 2 |\n |----------------|----------------|\n | Row 1 Column 1 | Row 1 Column 2 |\n\n* Table 3\n Lorem ipsum ...\n Lorem ipsum ...\n | Header 1 | Header 2 |\n |----------------|----------------|\n | Row 1 Column 1 | Row 1 Column 2 |\n\n\nOrdered list\n1. Table 1\n\n | Header 1 | Header 2 |\n |----------------|----------------|\n | Row 1 Column 1 | Row 1 Column 2 |\n\n2. Table 2\n | Header 1 | Header 2 |\n |----------------|----------------|\n | Row 1 Column 1 | Row 1 Column 2 |\n\n3. Table 3\n Lorem ipsum ...\n Lorem ipsum ...\n | Header 1 | Header 2 |\n |----------------|----------------|\n | Row 1 Column 1 | Row 1 Column 2 |", "<p>Bullet list</p>\n<ul>\n<li><p>Table 1</p>\n<table>\n<thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Row 1 Column 1</td>\n<td>Row 1 Column 2</td>\n</tr>\n</tbody>\n</table></li>\n<li><p>Table 2</p>\n<table>\n<thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Row 1 Column 1</td>\n<td>Row 1 Column 2</td>\n</tr>\n</tbody>\n</table></li>\n<li><p>Table 3\nLorem ipsum ...\nLorem ipsum ...</p>\n<table>\n<thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Row 1 Column 1</td>\n<td>Row 1 Column 2</td>\n</tr>\n</tbody>\n</table></li>\n</ul>\n<p>Ordered list</p>\n<ol>\n<li><p>Table 1</p>\n<table>\n<thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Row 1 Column 1</td>\n<td>Row 1 Column 2</td>\n</tr>\n</tbody>\n</table></li>\n<li><p>Table 2</p>\n<table>\n<thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Row 1 Column 1</td>\n<td>Row 1 Column 2</td>\n</tr>\n</tbody>\n</table></li>\n<li><p>Table 3\nLorem ipsum ...\nLorem ipsum ...</p>\n<table>\n<thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Row 1 Column 1</td>\n<td>Row 1 Column 2</td>\n</tr>\n</tbody>\n</table></li>\n</ol>", "pipetables|advanced", context: "Example 27\nSection Extensions / Pipe Table\n");
}
}
}

View File

@@ -612,4 +612,173 @@ a | b
</tr>
</tbody>
</table>
````````````````````````````````
A table may begin right after a paragraph without an empty line in between:
```````````````````````````````` example
Some
**text**.
| A |
|---|
| B |
.
<p>Some
<strong>text</strong>.</p>
<table>
<thead>
<tr>
<th>A</th>
</tr>
</thead>
<tbody>
<tr>
<td>B</td>
</tr>
</tbody>
</table>
````````````````````````````````
Tables can be nested inside other blocks, like lists:
```````````````````````````````` example
Bullet list
* Table 1
| Header 1 | Header 2 |
|----------------|----------------|
| Row 1 Column 1 | Row 1 Column 2 |
* Table 2
| Header 1 | Header 2 |
|----------------|----------------|
| Row 1 Column 1 | Row 1 Column 2 |
* Table 3
Lorem ipsum ...
Lorem ipsum ...
| Header 1 | Header 2 |
|----------------|----------------|
| Row 1 Column 1 | Row 1 Column 2 |
Ordered list
1. Table 1
| Header 1 | Header 2 |
|----------------|----------------|
| Row 1 Column 1 | Row 1 Column 2 |
2. Table 2
| Header 1 | Header 2 |
|----------------|----------------|
| Row 1 Column 1 | Row 1 Column 2 |
3. Table 3
Lorem ipsum ...
Lorem ipsum ...
| Header 1 | Header 2 |
|----------------|----------------|
| Row 1 Column 1 | Row 1 Column 2 |
.
<p>Bullet list</p>
<ul>
<li><p>Table 1</p>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1 Column 1</td>
<td>Row 1 Column 2</td>
</tr>
</tbody>
</table></li>
<li><p>Table 2</p>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1 Column 1</td>
<td>Row 1 Column 2</td>
</tr>
</tbody>
</table></li>
<li><p>Table 3
Lorem ipsum ...
Lorem ipsum ...</p>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1 Column 1</td>
<td>Row 1 Column 2</td>
</tr>
</tbody>
</table></li>
</ul>
<p>Ordered list</p>
<ol>
<li><p>Table 1</p>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1 Column 1</td>
<td>Row 1 Column 2</td>
</tr>
</tbody>
</table></li>
<li><p>Table 2</p>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1 Column 1</td>
<td>Row 1 Column 2</td>
</tr>
</tbody>
</table></li>
<li><p>Table 3
Lorem ipsum ...
Lorem ipsum ...</p>
<table>
<thead>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Row 1 Column 1</td>
<td>Row 1 Column 2</td>
</tr>
</tbody>
</table></li>
</ol>
````````````````````````````````

View File

@@ -0,0 +1,10 @@
namespace Markdig.Tests;
public class TestCodeInline
{
[Test]
public void UnpairedCodeInlineWithTrailingChars()
{
TestParser.TestSpec("*`\n\f", "<p>*`</p>");
}
}

View File

@@ -148,6 +148,9 @@ public class TestEmphasisExtended
[TestCase("1Foo1", "<one-only>Foo</one-only>")]
[TestCase("1121", "1<one-only>2</one-only>")]
[TestCase("22322", "<two-only>3</two-only>")]
[TestCase("2223222", "2<two-only>32</two-only>")]
[TestCase("22223222", "22<two-only>32</two-only>")]
[TestCase("22223223222", "22223<two-only>3</two-only>2")]
[TestCase("2232", "2232")]
[TestCase("333", "333")]
[TestCase("3334333", "<three-only>4</three-only>")]

View File

@@ -22,6 +22,18 @@ public partial class TestEmphasisPlus
TestParser.TestSpec("normal ***Strong emphasis*** normal", "<p>normal <em><strong>Strong emphasis</strong></em> normal</p>", "");
}
[Test]
public void SupplementaryPunctuation()
{
TestParser.TestSpec("a*a∇*a\n\na*∇a*a\n\na*a𝜵*a\n\na*𝜵a*a\n\na*𐬼a*a\n\na*a𐬼*a", "<p>a*a∇*a</p>\n<p>a*∇a*a</p>\n<p>a*a𝜵*a</p>\n<p>a*𝜵a*a</p>\n<p>a*𐬼a*a</p>\n<p>a*a𐬼*a</p>", "");
}
[Test]
public void RecognizeSupplementaryChars()
{
TestParser.TestSpec("🌶️**𰻞**🍜**𰻞**🌶️**麺**🍜", "<p>🌶️<strong>𰻞</strong>🍜<strong>𰻞</strong>🌶️<strong>麺</strong>🍜</p>", "");
}
[Test]
public void OpenEmphasisHasConvenientContentStringSlice()
{

View File

@@ -112,26 +112,26 @@ public class TestLinkHelper
}
[Test]
public void TestUrlAndTitleEmpty()
public void TestUrlEmptyAndTitleNull()
{
// 01234
var text = new StringSlice(@"(<>)A");
Assert.True(LinkHelper.TryParseInlineLink(ref text, out string link, out string title, out SourceSpan linkSpan, out SourceSpan titleSpan));
Assert.AreEqual(string.Empty, link);
Assert.AreEqual(string.Empty, title);
Assert.AreEqual(null, title);
Assert.AreEqual(new SourceSpan(1, 2), linkSpan);
Assert.AreEqual(SourceSpan.Empty, titleSpan);
Assert.AreEqual('A', text.CurrentChar);
}
[Test]
public void TestUrlAndTitleEmpty2()
public void TestUrlEmptyAndTitleNull2()
{
// 012345
var text = new StringSlice(@"( <> )A");
Assert.True(LinkHelper.TryParseInlineLink(ref text, out string link, out string title, out SourceSpan linkSpan, out SourceSpan titleSpan));
Assert.AreEqual(string.Empty, link);
Assert.AreEqual(string.Empty, title);
Assert.AreEqual(null, title);
Assert.AreEqual(new SourceSpan(2, 3), linkSpan);
Assert.AreEqual(SourceSpan.Empty, titleSpan);
Assert.AreEqual('A', text.CurrentChar);
@@ -158,7 +158,7 @@ public class TestLinkHelper
var text = new StringSlice(@"()A");
Assert.True(LinkHelper.TryParseInlineLink(ref text, out string link, out string title, out SourceSpan linkSpan, out SourceSpan titleSpan));
Assert.AreEqual(string.Empty, link);
Assert.AreEqual(string.Empty, title);
Assert.AreEqual(null, title);
Assert.AreEqual(SourceSpan.Empty, linkSpan);
Assert.AreEqual(SourceSpan.Empty, titleSpan);
Assert.AreEqual('A', text.CurrentChar);
@@ -327,8 +327,8 @@ public class TestLinkHelper
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, true));
}
[TestCase("bær", "br")]
[TestCase("bør", "br")]
[TestCase("bær", "baer")]
[TestCase("bør", "boer")]
[TestCase("bΘr", "br")]
[TestCase("四五", "")]
public void TestUrilizeOnlyAscii_NonAscii(string input, string expectedResult)
@@ -343,6 +343,75 @@ public class TestLinkHelper
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, true));
}
// Tests for NormalizeScandinavianOrGermanChar method mappings
// These special characters are always normalized (both allowOnlyAscii=true and false)
//
// Note: When allowOnlyAscii=true, NFD (Canonical Decomposition) is applied first:
// - German umlauts ä,ö,ü decompose to base letter + combining mark (ü -> u + ¨)
// The combining mark is then stripped, leaving just the base letter (ü -> u)
// - å decomposes similarly (å -> a + ˚ -> a)
// - But ø, æ, ß, þ, ð do NOT decompose, so they use NormalizeScandinavianOrGermanChar
//
// When allowOnlyAscii=false, NormalizeScandinavianOrGermanChar is used for ALL special chars
// German ß (Eszett/sharp s) - does NOT decompose with NFD
[TestCase("Straße", "strasse")] // ß -> ss (both allowOnlyAscii=true and false)
// Scandinavian æ, ø - do NOT decompose with NFD
[TestCase("æble", "aeble")] // æ -> ae (both modes)
[TestCase("Ærø", "aeroe")] // Æ -> Ae, ø -> oe (both modes, then lowercase)
[TestCase("København", "koebenhavn")] // ø -> oe (both modes)
[TestCase("Øresund", "oeresund")] // Ø -> Oe (both modes, then lowercase)
// Icelandic þ, ð - do NOT decompose with NFD
[TestCase("þing", "thing")] // þ (thorn) -> th (both modes)
[TestCase("bað", "bad")] // ð (eth) -> d (both modes)
// Mixed special characters (only chars that behave same in both modes)
[TestCase("øst-æble", "oest-aeble")] // ø->oe, æ->ae (both modes)
public void TestUrilizeScandinavianGermanChars(string input, string expectedResult)
{
// These transformations apply regardless of allowOnlyAscii flag
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, true));
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, false));
}
// Tests specific to allowOnlyAscii=true behavior
// German umlauts (ä, ö, ü) and å decompose with NFD, so they become base letter only
[TestCase("schön", "schon")] // ö decomposes to o (NFD strips combining mark)
[TestCase("Mädchen", "madchen")] // ä decomposes to a
[TestCase("Übung", "ubung")] // Ü decomposes to U (then lowercase to u)
[TestCase("Düsseldorf", "dusseldorf")] // ü decomposes to u
[TestCase("Käse", "kase")] // ä decomposes to a
[TestCase("gå", "ga")] // å decomposes to a
[TestCase("Ålesund", "alesund")] // Å decomposes to A (then lowercase)
[TestCase("grüßen", "grussen")] // ü decomposes to u, ß -> ss
[TestCase("Þór", "thor")] // Þ -> Th, ó decomposes to o (then lowercase)
[TestCase("Íslandsbanki", "islandsbanki")] // Í decomposes to I (then lowercase)
public void TestUrilizeOnlyAscii_GermanUmlautsDecompose(string input, string expectedResult)
{
// With allowOnlyAscii=true, these characters decompose via NFD and lose their diacritics
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, true));
}
// Tests specific to allowOnlyAscii=false behavior
// All special chars use NormalizeScandinavianOrGermanChar (including ä, ö, ü, å)
[TestCase("schön", "schoen")] // ö -> oe (NormalizeScandinavianOrGermanChar)
[TestCase("Mädchen", "maedchen")] // ä -> ae
[TestCase("Übung", "uebung")] // Ü -> Ue (then lowercase)
[TestCase("Düsseldorf", "duesseldorf")] // ü -> ue
[TestCase("Käse", "kaese")] // ä -> ae
[TestCase("gå", "gaa")] // å -> aa
[TestCase("Ålesund", "aalesund")] // Å -> Aa (then lowercase)
[TestCase("grüßen", "gruessen")] // ü -> ue, ß -> ss
[TestCase("Þór", "thór")] // Þ -> Th (then lowercase 'th'), ó is kept as-is
[TestCase("Íslandsbanki", "íslandsbanki")] // í is kept as-is when allowOnlyAscii=false
public void TestUrilizeNonAscii_GermanUmlautsExpanded(string input, string expectedResult)
{
// With allowOnlyAscii=false, these characters use NormalizeScandinavianOrGermanChar
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, false));
}
[TestCase("123", "")]
[TestCase("1,-b", "b")]
[TestCase("b1,-", "b1")] // Not Pandoc equivalent: b1-
@@ -360,11 +429,11 @@ public class TestLinkHelper
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, false));
}
[TestCase("bær", "bær")]
[TestCase("æ5el", "æ5el")]
[TestCase("-æ5el", "æ5el")]
[TestCase("-frø-", "frø")]
[TestCase("-fr-ø", "fr-ø")]
[TestCase("bær", "baer")]
[TestCase("æ5el", "ae5el")]
[TestCase("-æ5el", "ae5el")]
[TestCase("-frø-", "froe")]
[TestCase("-fr-ø", "fr-oe")]
public void TestUrilizeNonAscii_Simple(string input, string expectedResult)
{
Assert.AreEqual(expectedResult, LinkHelper.Urilize(input, false));
@@ -393,4 +462,4 @@ public class TestLinkHelper
{
TestParser.TestSpec("[Foo]\n\n[Foo]: http://ünicode.com", "<p><a href=\"http://xn--nicode-2ya.com\">Foo</a></p>");
}
}
}

View File

@@ -1,5 +1,7 @@
using Markdig;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Markdig.Tests;
@@ -10,6 +12,9 @@ public sealed class TestPipeTable
[TestCase("| S | T |\r\n|---|---|\t\r\n| G | H |")]
[TestCase("| S | T |\r\n|---|---|\f\r\n| G | H |")]
[TestCase("| S | \r\n|---|\r\n| G |\r\n\r\n| D | D |\r\n| ---| ---| \r\n| V | V |", 2)]
[TestCase("a\r| S | T |\r|---|---|")]
[TestCase("a\n| S | T |\r|---|---|")]
[TestCase("a\r\n| S | T |\r|---|---|")]
public void TestTableBug(string markdown, int tableCount = 1)
{
MarkdownDocument document =
@@ -55,4 +60,147 @@ public sealed class TestPipeTable
Assert.AreEqual(0, column.Width);
}
}
[Test]
public void TableWithUnbalancedCodeSpanParsesWithoutDepthLimitError()
{
const string markdown = """
| Count | A | B | C | D | E |
|-------|---|---|---|---|---|
| 0 | B | C | D | E | F |
| 1 | B | `C | D | E | F |
| 2 | B | `C | D | E | F |
| 3 | B | C | D | E | F |
| 4 | B | C | D | E | F |
| 5 | B | C | D | E | F |
| 6 | B | C | D | E | F |
| 7 | B | C | D | E | F |
| 8 | B | C | D | E | F |
| 9 | B | C | D | E | F |
| 10 | B | C | D | E | F |
| 11 | B | C | D | E | F |
| 12 | B | C | D | E | F |
| 13 | B | C | D | E | F |
| 14 | B | C | D | E | F |
| 15 | B | C | D | E | F |
| 16 | B | C | D | E | F |
| 17 | B | C | D | E | F |
| 18 | B | C | D | E | F |
| 19 | B | C | D | E | F |
""";
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
MarkdownDocument document = null!;
Assert.DoesNotThrow(() => document = Markdown.Parse(markdown, pipeline));
var tables = document.Descendants().OfType<Table>().ToArray();
Assert.That(tables, Has.Length.EqualTo(1));
string html = string.Empty;
Assert.DoesNotThrow(() => html = Markdown.ToHtml(markdown, pipeline));
Assert.That(html, Does.Contain("<table"));
Assert.That(html, Does.Contain("<td>`C</td>"));
}
[Test]
public void CodeInlineWithPipeDelimitersRemainsCodeInline()
{
const string markdown = "`|| hidden text ||`";
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var document = Markdown.Parse(markdown, pipeline);
var codeInline = document.Descendants().OfType<CodeInline>().SingleOrDefault();
Assert.IsNotNull(codeInline);
Assert.That(codeInline!.Content, Is.EqualTo("|| hidden text ||"));
Assert.That(document.ToHtml(), Is.EqualTo("<p><code>|| hidden text ||</code></p>\n"));
}
[Test]
public void MultiLineCodeInlineWithPipeDelimitersRendersAsCode()
{
string markdown =
"""
`
|| hidden text ||
`
""".ReplaceLineEndings("\n");
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var html = Markdown.ToHtml(markdown, pipeline);
Assert.That(html, Is.EqualTo("<p><code>|| hidden text ||</code></p>\n"));
}
[Test]
public void TableCellWithCodeInlineRendersCorrectly()
{
const string markdown =
"""
| Count | A | B | C | D | E |
|-------|---|---|---|---|---|
| 0 | B | C | D | E | F |
| 1 | B | `Code block` | D | E | F |
| 2 | B | C | D | E | F |
""";
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var html = Markdown.ToHtml(markdown, pipeline);
Assert.That(html, Does.Contain("<td><code>Code block</code></td>"));
}
[Test]
public void CodeInlineWithIndentedContentPreservesWhitespace()
{
const string markdown = "`\n foo\n`";
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var document = Markdown.Parse(markdown, pipeline);
var codeInline = document.Descendants().OfType<CodeInline>().Single();
Assert.That(codeInline.Content, Is.EqualTo("foo"));
Assert.That(Markdown.ToHtml(markdown, pipeline), Is.EqualTo("<p><code>foo</code></p>\n"));
}
[Test]
public void TableWithIndentedPipeAfterCodeInlineParsesCorrectly()
{
var markdown =
"""
`
|| hidden text ||
`
| Count | Value |
|-------|-------|
| 0 | B |
""".ReplaceLineEndings("\n");
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var html = Markdown.ToHtml(markdown, pipeline);
Assert.That(html, Does.Contain("<p><code>|| hidden text ||</code></p>"));
Assert.That(html, Does.Contain("<table"));
Assert.That(html, Does.Contain("<td>B</td>"));
}
}

View File

@@ -31,4 +31,14 @@ public class TestSmartyPants
TestParser.TestSpec("<<test>>", "<p>&laquo;test&raquo;</p>", pipeline);
}
[Test]
public void RecognizesSupplementaryCharacters()
{
var pipeline = new MarkdownPipelineBuilder()
.UseSmartyPants()
.Build();
TestParser.TestSpec("\"𝜵\"𠮷\"𝜵\"𩸽\"", "<p>&quot;𝜵&ldquo;𠮷&rdquo;𝜵&ldquo;𩸽&rdquo;</p>", pipeline);
}
}

View File

@@ -834,6 +834,29 @@ literal ( 2, 2) 11-11
", "pipetables");
}
[Test]
public void TestGridTable()
{
Check("0\n\n+-+-+\n|A|B|\n+=+=+\n|C|D|\n+-+-+", @"
paragraph ( 0, 0) 0-0
literal ( 0, 0) 0-0
table ( 2, 0) 3-31
tablerow ( 3, 0) 9-13
tablecell ( 3, 0) 9-11
paragraph ( 3, 1) 10-10
literal ( 3, 1) 10-10
tablecell ( 3, 2) 11-13
paragraph ( 3, 3) 12-12
literal ( 3, 3) 12-12
tablerow ( 5, 0) 21-25
tablecell ( 5, 0) 21-23
paragraph ( 5, 1) 22-22
literal ( 5, 1) 22-22
tablecell ( 5, 2) 23-25
paragraph ( 5, 3) 24-24
literal ( 5, 3) 24-24", "gridtables");
}
[Test]
public void TestIndentedCode()
{

View File

@@ -0,0 +1,165 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
namespace Markdig.Tests;
[TestFixture]
public class TestStringSlice
{
#if NET
[Test]
public void TestRuneBmp()
{
var slice = new StringSlice("01234");
Assert.AreEqual('0', slice.CurrentRune.Value);
Assert.AreEqual(0, slice.Start);
Assert.AreEqual('1', slice.NextRune().Value);
Assert.AreEqual(1, slice.Start);
Assert.AreEqual('2', slice.NextRune().Value);
Assert.AreEqual(2, slice.Start);
Assert.AreEqual('2', slice.CurrentRune.Value);
Assert.AreEqual("234", slice.ToString());
Assert.AreEqual('3', slice.PeekRuneExtra(1).Value);
Assert.AreEqual('4', slice.PeekRuneExtra(2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(3).Value);
Assert.AreEqual('1', slice.PeekRuneExtra(-1).Value);
Assert.AreEqual('0', slice.PeekRuneExtra(-2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-3).Value);
Assert.AreEqual('0', slice.RuneAt(0).Value);
Assert.AreEqual('1', slice.RuneAt(1).Value);
Assert.AreEqual('2', slice.RuneAt(2).Value);
Assert.AreEqual('3', slice.RuneAt(3).Value);
Assert.AreEqual('4', slice.RuneAt(4).Value);
Assert.AreEqual(2, slice.Start);
}
[Test]
public void TestRuneSupplementaryOnly()
{
var slice = new StringSlice("𝟎𝟏𝟐𝟑𝟒");
Assert.AreEqual(10, slice.Length);
// 𝟎 = U+1D7CE, 𝟐 = U+1D7D0
Assert.AreEqual(0x1D7CE, slice.CurrentRune.Value); // 𝟎
Assert.AreEqual(0, slice.Start);
Assert.AreEqual(0x1D7CF, slice.NextRune().Value); // 𝟏
Assert.AreEqual(2, slice.Start);
Assert.AreEqual(0x1D7D0, slice.NextRune().Value); // 𝟐
Assert.AreEqual(4, slice.Start);
Assert.AreEqual(0x1D7D0, slice.CurrentRune.Value); // 𝟐
Assert.AreEqual("𝟐𝟑𝟒", slice.ToString());
// CurrentRune occupies 2 `char`s, so next Rune starts at index 2
Assert.AreEqual(0x1D7D1, slice.PeekRuneExtra(2).Value); // 𝟑
Assert.AreEqual(0x1D7D2, slice.PeekRuneExtra(4).Value); // 𝟒
Assert.AreEqual(0, slice.PeekRuneExtra(6).Value);
Assert.AreEqual(0x1D7CF, slice.PeekRuneExtra(-1).Value); // 𝟏
Assert.AreEqual(0x1D7CE, slice.PeekRuneExtra(-3).Value); // 𝟎
Assert.AreEqual(0, slice.PeekRuneExtra(-5).Value);
Assert.AreEqual(0x1D7CE, slice.RuneAt(0).Value); // 𝟎
Assert.AreEqual(0x1D7CF, slice.RuneAt(2).Value); // 𝟏
Assert.AreEqual(0x1D7D0, slice.RuneAt(4).Value); // 𝟐
Assert.AreEqual(0x1D7D1, slice.RuneAt(6).Value); // 𝟑
Assert.AreEqual(0x1D7D2, slice.RuneAt(8).Value); // 𝟒
// The following usages are not expected. You should take into consideration the `char`s that the Rune you just acquired occupies.
Assert.AreEqual(0, slice.PeekRuneExtra(-4).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(1).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(3).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(5).Value);
Assert.AreEqual(0, slice.RuneAt(1).Value);
Assert.AreEqual(0, slice.RuneAt(3).Value);
Assert.AreEqual(0, slice.RuneAt(5).Value);
Assert.AreEqual(0, slice.RuneAt(7).Value);
Assert.AreEqual(0, slice.RuneAt(9).Value);
Assert.AreEqual(4, slice.Start);
}
[Test]
public void TestRuneIsolatedHighSurrogate()
{
var slice = new StringSlice("\ud800\ud801\ud802\ud803\ud804");
Assert.AreEqual(0, slice.CurrentRune.Value);
Assert.AreEqual(0, slice.Start);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual(0, slice.CurrentRune.Value);
Assert.AreEqual('\ud801', slice.CurrentChar);
Assert.AreEqual(1, slice.Start);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual(2, slice.Start);
Assert.AreEqual('\ud802', slice.CurrentChar);
Assert.AreEqual(0, slice.CurrentRune.Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-3).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-1).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(1).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(3).Value);
Assert.AreEqual(0, slice.RuneAt(0).Value);
Assert.AreEqual(0, slice.RuneAt(1).Value);
Assert.AreEqual(0, slice.RuneAt(2).Value);
Assert.AreEqual(0, slice.RuneAt(3).Value);
Assert.AreEqual(0, slice.RuneAt(4).Value);
Assert.AreEqual(2, slice.Start);
}
[Test]
public void TestRuneIsolatedLowSurrogate()
{
var slice = new StringSlice("\udc00\udc01\udc02\udc03\udc04");
Assert.AreEqual(0, slice.CurrentRune.Value);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual('\udc01', slice.CurrentChar);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual('\udc02', slice.CurrentChar);
Assert.AreEqual(0, slice.CurrentRune.Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-3).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(-1).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(1).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(2).Value);
Assert.AreEqual(0, slice.PeekRuneExtra(3).Value);
Assert.AreEqual(0, slice.RuneAt(0).Value);
Assert.AreEqual(0, slice.RuneAt(1).Value);
Assert.AreEqual(0, slice.RuneAt(2).Value);
Assert.AreEqual(0, slice.RuneAt(3).Value);
Assert.AreEqual(0, slice.RuneAt(4).Value);
}
[Test]
public void TestMixedInput()
{
var slice = new StringSlice("a\udc00bc𝟑d𝟒\udc00");
Assert.AreEqual(10, slice.Length);
Assert.AreEqual('a', slice.CurrentRune.Value);
Assert.AreEqual(0, slice.Start);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual(1, slice.Start);
Assert.AreEqual('b', slice.NextRune().Value);
Assert.AreEqual(2, slice.Start);
Assert.AreEqual('c', slice.NextRune().Value);
Assert.AreEqual(3, slice.Start);
Assert.AreEqual(0x1D7D1, slice.NextRune().Value);
Assert.AreEqual(4, slice.Start);
Assert.AreEqual('d', slice.NextRune().Value);
Assert.AreEqual(6, slice.Start);
Assert.AreEqual(0x1D7D2, slice.NextRune().Value);
Assert.AreEqual(7, slice.Start);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual(9, slice.Start);
Assert.False(slice.IsEmpty);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual(10, slice.Start);
Assert.True(slice.IsEmpty);
slice = new StringSlice(slice.Text + 'a', 7, 10);
Assert.AreEqual(0x1D7D2, slice.CurrentRune.Value);
Assert.AreEqual(0, slice.NextRune().Value);
Assert.AreEqual(9, slice.Start);
Assert.AreEqual('a', slice.NextRune().Value);
}
#endif
}

View File

@@ -8,7 +8,6 @@ namespace Markdig.Tests;
[TestFixture]
public class TestStringSliceList
{
// TODO: Add tests for StringSlice
// TODO: Add more tests for StringLineGroup
[Test]

View File

@@ -14,7 +14,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" />
</ItemGroup>
<ItemGroup>

View File

@@ -73,8 +73,6 @@ public class AbbreviationParser : BlockParser
private void DocumentOnProcessInlinesBegin(InlineProcessor inlineProcessor, Inline? inline)
{
inlineProcessor.Document.ProcessInlinesBegin -= DocumentOnProcessInlinesBegin;
var abbreviations = inlineProcessor.Document.GetAbbreviations();
// Should not happen, but another extension could decide to remove them, so...
if (abbreviations is null)

View File

@@ -101,7 +101,6 @@ public class AutoIdentifierExtension : IMarkdownExtension
private void DocumentOnProcessInlinesBegin(InlineProcessor processor, Inline? inline)
{
var doc = processor.Document;
doc.ProcessInlinesBegin -= _processInlinesBegin;
var dictionary = (Dictionary<string, HeadingLinkReferenceDefinition>)doc.GetData(this)!;
foreach (var keyPair in dictionary)
{

View File

@@ -2,9 +2,11 @@
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using Markdig.Parsers;
namespace Markdig.Extensions.AutoLinks;
public class AutoLinkOptions
public class AutoLinkOptions : LinkOptions
{
public AutoLinkOptions()
{
@@ -13,11 +15,6 @@ public class AutoLinkOptions
public string ValidPreviousCharacters { get; set; }
/// <summary>
/// Should the link open in a new window when clicked (false by default)
/// </summary>
public bool OpenInNewWindow { get; set; }
/// <summary>
/// Should a www link be prefixed with https:// instead of http:// (false by default)
/// </summary>

View File

@@ -134,9 +134,6 @@ public class FootnoteParser : BlockParser
/// <param name="inline">The inline.</param>
private void Document_ProcessInlinesEnd(InlineProcessor state, Inline? inline)
{
// Unregister
state.Document.ProcessInlinesEnd -= Document_ProcessInlinesEnd;
var footnotes = (FootnoteGroup)state.Document.GetData(DocumentKey)!;
// Remove the footnotes from the document and readd them at the end
state.Document.Remove(footnotes);

View File

@@ -109,6 +109,15 @@ public class GenericAttributesParser : InlineParser
{
isValid = true;
line.SkipChar(); // skip }
// skip line breaks
if (line.CurrentChar == '\n')
{
line.SkipChar();
}
else if (line.CurrentChar == '\r' && line.PeekChar() == '\n')
{
line.Start += 2;
}
break;
}

View File

@@ -3,13 +3,14 @@
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
using Markdig.Parsers;
namespace Markdig.Extensions.JiraLinks;
/// <summary>
/// Available options for replacing JIRA links
/// </summary>
public class JiraLinkOptions
public class JiraLinkOptions : LinkOptions
{
/// <summary>
/// The base Url (e.g. `https://mycompany.atlassian.net`)
@@ -21,11 +22,6 @@ public class JiraLinkOptions
/// </summary>
public string BasePath { get; set; }
/// <summary>
/// Should the link open in a new window when clicked
/// </summary>
public bool OpenInNewWindow { get; set; }
public JiraLinkOptions(string baseUrl)
{
OpenInNewWindow = true; //default

View File

@@ -36,16 +36,15 @@ public class SmartyPantsInlineParser : InlineParser, IPostInlineProcessor
// -- &ndash; 'ndash'
// --- — &mdash; 'mdash'
var pc = slice.PeekCharExtra(-1);
var c = slice.CurrentChar;
var openingChar = c;
var pc = slice.PeekRuneExtra(-1);
var openingChar = slice.CurrentChar;
var startingPosition = slice.Start;
// undefined first
var type = (SmartyPantType) 0;
switch (c)
switch (openingChar)
{
case '\'':
type = SmartyPantType.Quote; // We will resolve them at the end of parsing all inlines
@@ -93,9 +92,9 @@ public class SmartyPantsInlineParser : InlineParser, IPostInlineProcessor
}
// Skip char
c = slice.NextChar();
var next = slice.NextRune();
CharHelper.CheckOpenCloseDelimiter(pc, c, false, out bool canOpen, out bool canClose);
CharHelper.CheckOpenCloseDelimiter(pc, next, false, out bool canOpen, out bool canClose);
bool postProcess = false;
@@ -204,8 +203,6 @@ public class SmartyPantsInlineParser : InlineParser, IPostInlineProcessor
private void BlockOnProcessInlinesEnd(InlineProcessor processor, Inline? inline)
{
processor.Block!.ProcessInlinesEnd -= BlockOnProcessInlinesEnd;
var pants = (ListSmartyPants) processor.ParserStates[Index];
var openers = new Stack<Opener>(4);

View File

@@ -1,10 +1,11 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Syntax;
using System.Linq;
namespace Markdig.Extensions.Tables;
@@ -60,7 +61,12 @@ public class GridTableParser : BlockParser
}
// Store the line (if we need later to build a ParagraphBlock because the GridTable was in fact invalid)
tableState.AddLine(ref processor.Line);
var table = new Table(this);
var table = new Table(this)
{
Line = processor.LineIndex,
Column = processor.Column,
Span = { Start = lineStart }
};
table.SetData(typeof(GridTableState), tableState);
// Calculate the total width of all columns
@@ -94,10 +100,12 @@ public class GridTableParser : BlockParser
tableState.AddLine(ref processor.Line);
if (processor.CurrentChar == '+')
{
gridTable.UpdateSpanEnd(processor.Line.End);
return HandleNewRow(processor, tableState, gridTable);
}
if (processor.CurrentChar == '|')
{
gridTable.UpdateSpanEnd(processor.Line.End);
return HandleContents(processor, tableState, gridTable);
}
TerminateCurrentRow(processor, tableState, gridTable, true);
@@ -182,8 +190,18 @@ public class GridTableParser : BlockParser
var columnSlice = columns[i];
if (columnSlice.CurrentCell != null)
{
currentRow ??= new TableRow();
if (currentRow == null)
{
TableCell firstCell = columns.First(c => c.CurrentCell != null).CurrentCell!;
TableCell lastCell = columns.Last(c => c.CurrentCell != null).CurrentCell!;
currentRow ??= new TableRow()
{
Span = new SourceSpan(firstCell.Span.Start, lastCell.Span.End),
Line = firstCell.Line
};
}
// If this cell does not already belong to a row
if (columnSlice.CurrentCell.Parent is null)
{
@@ -271,7 +289,10 @@ public class GridTableParser : BlockParser
columnSlice.CurrentCell = new TableCell(this)
{
ColumnSpan = columnSlice.CurrentColumnSpan,
ColumnIndex = i
ColumnIndex = i,
Column = columnSlice.Start,
Line = processor.LineIndex,
Span = new SourceSpan(line.Start + columnSlice.Start, line.Start + columnSlice.End)
};
columnSlice.BlockProcessor ??= processor.CreateChild();
@@ -281,7 +302,8 @@ public class GridTableParser : BlockParser
}
// Process the content of the cell
columnSlice.BlockProcessor!.LineIndex = processor.LineIndex;
columnSlice.BlockProcessor.ProcessLine(sliceForCell);
columnSlice.BlockProcessor.ProcessLinePart(sliceForCell, sliceForCell.Start - line.Start);
}
// Go to next column

View File

@@ -38,7 +38,7 @@ public class PipeTableExtension : IMarkdownExtension
var lineBreakParser = pipeline.InlineParsers.FindExact<LineBreakInlineParser>();
if (!pipeline.InlineParsers.Contains<PipeTableParser>())
{
pipeline.InlineParsers.InsertBefore<EmphasisInlineParser>(new PipeTableParser(lineBreakParser!, Options));
pipeline.InlineParsers.InsertAfter<EmphasisInlineParser>(new PipeTableParser(lineBreakParser!, Options));
}
}

View File

@@ -3,7 +3,6 @@
// See the license.txt file in the project root for more information.
using System.Diagnostics;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Parsers.Inlines;
@@ -20,7 +19,7 @@ namespace Markdig.Extensions.Tables;
/// <seealso cref="IPostInlineProcessor" />
public class PipeTableParser : InlineParser, IPostInlineProcessor
{
private readonly LineBreakInlineParser lineBreakParser;
private readonly LineBreakInlineParser _lineBreakParser;
/// <summary>
/// Initializes a new instance of the <see cref="PipeTableParser" /> class.
@@ -29,7 +28,7 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
/// <param name="options">The options.</param>
public PipeTableParser(LineBreakInlineParser lineBreakParser, PipeTableOptions? options = null)
{
this.lineBreakParser = lineBreakParser ?? throw new ArgumentNullException(nameof(lineBreakParser));
_lineBreakParser = lineBreakParser ?? throw new ArgumentNullException(nameof(lineBreakParser));
OpeningCharacters = ['|', '\n', '\r'];
Options = options ?? new PipeTableOptions();
}
@@ -60,13 +59,12 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
if (tableState is null)
{
// A table could be preceded by an empty line or a line containing an inline
// that has not been added to the stack, so we consider this as a valid
// start for a table. Typically, with this, we can have an attributes {...}
// starting on the first line of a pipe table, even if the first line
// doesn't have a pipe
if (processor.Inline != null && (localLineIndex > 0 || c == '\n' || c == '\r'))
if (processor.Inline != null && (c == '\n' || c == '\r'))
{
return false;
}
@@ -75,6 +73,7 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
{
isFirstLineEmpty = true;
}
// Else setup a table processor
tableState = new TableState();
processor.ParserStates[Index] = tableState;
@@ -87,8 +86,7 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
tableState.IsInvalidTable = true;
}
tableState.LineHasPipe = false;
lineBreakParser.Match(processor, ref slice);
tableState.LineIndex++;
_lineBreakParser.Match(processor, ref slice);
if (!isFirstLineEmpty)
{
tableState.ColumnAndLineDelimiters.Add(processor.Inline!);
@@ -102,15 +100,11 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
Span = new SourceSpan(position, position),
Line = globalLineIndex,
Column = column,
LocalLineIndex = localLineIndex
LocalLineIndex = localLineIndex,
IsClosed = true // Creates flat sibling structure for O(n) traversal
};
var deltaLine = localLineIndex - tableState.LineIndex;
if (deltaLine > 0)
{
tableState.IsInvalidTable = true;
}
tableState.LineHasPipe = true;
tableState.LineIndex = localLineIndex;
slice.SkipChar(); // Skip the `|` character
tableState.ColumnAndLineDelimiters.Add(processor.Inline);
@@ -132,6 +126,8 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
return true;
}
// With flat structure, pipes are siblings at root level
// Walk backwards from the last child to find pipe delimiters
var child = container.LastChild;
List<PipeTableDelimiterInline>? delimitersToRemove = null;
@@ -149,8 +145,8 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
break;
}
var subContainer = child as ContainerInline;
child = subContainer?.LastChild;
// Walk siblings instead of descending into containers
child = child.PreviousSibling;
}
// If we have found any delimiters, transform them to literals
@@ -193,19 +189,36 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
// Remove previous state
state.ParserStates[Index] = null!;
// Continue
if (tableState is null || container is null || tableState.IsInvalidTable || !tableState.LineHasPipe ) //|| tableState.LineIndex != state.LocalLineIndex)
// Abort if not a valid table
if (tableState is null || container is null || tableState.IsInvalidTable || !tableState.LineHasPipe)
{
if (tableState is not null)
{
foreach (var inline in tableState.ColumnAndLineDelimiters)
{
if (inline is PipeTableDelimiterInline pipeDelimiter)
{
pipeDelimiter.ReplaceByLiteral();
}
}
}
return true;
}
// Detect the header row
var delimiters = tableState.ColumnAndLineDelimiters;
// TODO: we could optimize this by merging FindHeaderRow and the cell loop
var aligns = FindHeaderRow(delimiters);
if (Options.RequireHeaderSeparator && aligns is null)
{
// No valid header separator found - convert all pipe delimiters to literals
foreach (var inline in delimiters)
{
if (inline is PipeTableDelimiterInline pipeDelimiter)
{
pipeDelimiter.ReplaceByLiteral();
}
}
return true;
}
@@ -218,72 +231,43 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
attributes.CopyTo(table.GetAttributes());
}
state.BlockNew = table;
var cells = tableState.Cells;
cells.Clear();
//delimiters[0].DumpTo(state.DebugLog);
// Pipes may end up nested inside unmatched emphasis delimiters, e.g.:
// *a | b*|
// Promote them to root level so we have a flat sibling structure.
PromoteNestedPipesToRootLevel(delimiters, container);
// delimiters contain a list of `|` and `\n` delimiters
// The `|` delimiters are created as child containers.
// So the following:
// | a | b \n
// | d | e \n
// The inline tree is now flat: all pipes and line breaks are siblings at root level.
// For example, `| a | b \n| c | d \n` produces:
// [|] [a] [|] [b] [\n] [|] [c] [|] [d] [\n]
//
// Will generate a tree of the following node:
// |
// a
// |
// b
// \n
// |
// d
// |
// e
// \n
// When parsing delimiters, we need to recover whether a row is of the following form:
// 0) | a | b | \n
// 1) | a | b \n
// 2) a | b \n
// 3) a | b | \n
// Tables support four row formats:
// | a | b | (leading and trailing pipes)
// | a | b (leading pipe only)
// a | b (no leading or trailing pipes)
// a | b | (trailing pipe only)
// If the last element is not a line break, add a line break to homogenize parsing in the next loop
// Ensure the table ends with a line break to simplify row detection
var lastElement = delimiters[delimiters.Count - 1];
if (!(lastElement is LineBreakInline))
{
while (true)
// Find the actual last sibling (there may be content after the last delimiter)
while (lastElement.NextSibling != null)
{
if (lastElement is ContainerInline lastElementContainer)
{
var nextElement = lastElementContainer.LastChild;
if (nextElement != null)
{
lastElement = nextElement;
continue;
}
}
break;
lastElement = lastElement.NextSibling;
}
var endOfTable = new LineBreakInline();
// If the last element is a container, we have to add the EOL to its child
// otherwise only next sibling
if (lastElement is ContainerInline)
{
((ContainerInline)lastElement).AppendChild(endOfTable);
}
else
{
lastElement.InsertAfter(endOfTable);
}
lastElement.InsertAfter(endOfTable);
delimiters.Add(endOfTable);
tableState.EndOfLines.Add(endOfTable);
}
int lastPipePos = 0;
// Cell loop
// Reconstruct the table from the delimiters
// Build table rows and cells by iterating through delimiters
TableRow? row = null;
TableRow? firstRow = null;
for (int i = 0; i < delimiters.Count; i++)
@@ -298,9 +282,7 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
firstRow ??= row;
// If the first delimiter is a pipe and doesn't have any parent or previous sibling, for cases like:
// 0) | a | b | \n
// 1) | a | b \n
// Skip leading pipe at start of row (e.g., `| a | b` or `| a | b |`)
if (pipeSeparator != null && (delimiter.PreviousSibling is null || delimiter.PreviousSibling is LineBreakInline))
{
delimiter.Remove();
@@ -314,57 +296,37 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
}
}
// We need to find the beginning/ending of a cell from a right delimiter. From the delimiter 'x', we need to find a (without the delimiter start `|`)
// So we iterate back to the first pipe or line break
// x
// 1) | a | b \n
// 2) a | b \n
// Find cell content by walking backwards from this delimiter to the previous pipe or line break.
// For `| a | b \n` at delimiter 'x':
// [|] [a] [x] [b] [\n]
// ^--- current delimiter
// Walk back: [a] is the cell content (stop at [|])
Inline? endOfCell = null;
Inline? beginOfCell = null;
var cellContentIt = delimiter;
while (true)
var cellContentIt = delimiter.PreviousSibling;
while (cellContentIt != null)
{
cellContentIt = cellContentIt.PreviousSibling ?? cellContentIt.Parent;
if (cellContentIt is null || cellContentIt is LineBreakInline)
{
if (cellContentIt is LineBreakInline || cellContentIt is PipeTableDelimiterInline)
break;
}
// The cell begins at the first effective child after a | or the top ContainerInline (which is not necessary to bring into the tree + it contains an invalid span calculation)
if (cellContentIt is PipeTableDelimiterInline || (cellContentIt.GetType() == typeof(ContainerInline) && cellContentIt.Parent is null ))
{
beginOfCell = ((ContainerInline)cellContentIt).FirstChild;
if (endOfCell is null)
{
endOfCell = beginOfCell;
}
// Stop at the root ContainerInline (which is not necessary to bring into the tree + it contains an invalid span calculation)
if (cellContentIt.GetType() == typeof(ContainerInline) && cellContentIt.Parent is null)
break;
}
beginOfCell = cellContentIt;
if (endOfCell is null)
{
endOfCell = beginOfCell;
}
endOfCell ??= beginOfCell;
cellContentIt = cellContentIt.PreviousSibling;
}
// If the current deilimiter is a pipe `|` OR
// If the current delimiter is a pipe `|` OR
// the beginOfCell/endOfCell are not null and
// either they are :
// either they are:
// - different
// - they contain a single element, but it is not a line break (\n) or an empty/whitespace Literal.
// Then we can add a cell to the current row
if (!isLine || (beginOfCell != null && endOfCell != null && ( beginOfCell != endOfCell || !(beginOfCell is LineBreakInline || (beginOfCell is LiteralInline beingOfCellLiteral && beingOfCellLiteral.Content.IsEmptyOrWhitespace())))))
{
if (!isLine)
{
// If the delimiter is a pipe, we need to remove it from the tree
// so that previous loop looking for a parent will not go further on subsequent cells
delimiter.Remove();
lastPipePos = delimiter.Span.End;
}
// We trim whitespace at the beginning and ending of the cell
TrimStart(beginOfCell);
TrimEnd(endOfCell);
@@ -372,10 +334,20 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
var cellContainer = new ContainerInline();
// Copy elements from beginOfCell on the first level
// The pipe delimiter serves as a boundary - stop when we hit it
var cellIt = beginOfCell;
while (cellIt != null && !IsLine(cellIt) && !(cellIt is PipeTableDelimiterInline))
{
var nextSibling = cellIt.NextSibling;
// Skip empty literals (can result from trimming)
if (cellIt is LiteralInline { Content.IsEmpty: true })
{
cellIt.Remove();
cellIt = nextSibling;
continue;
}
cellIt.Remove();
if (cellContainer.Span.IsEmpty)
{
@@ -388,8 +360,16 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
cellIt = nextSibling;
}
if (!isLine)
{
// Remove the pipe delimiter AFTER copying cell content
// This preserves the sibling chain during the copy loop
delimiter.Remove();
lastPipePos = delimiter.Span.End;
}
// Create the cell and add it to the pending row
var tableParagraph = new ParagraphBlock()
var tableParagraph = new ParagraphBlock
{
Span = cellContainer.Span,
Line = cellContainer.Line,
@@ -441,8 +421,7 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
endOfLine.Remove();
}
// If we have a header row, we can remove it
// TODO: we could optimize this by merging FindHeaderRow and the previous loop
// Mark first row as header and remove the separator row if present
var tableRow = (TableRow)table[0];
tableRow.IsHeader = Options.RequireHeaderSeparator;
if (aligns != null)
@@ -452,11 +431,13 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
table.ColumnDefinitions.AddRange(aligns);
}
// Perform delimiter processor that are coming after this processor
// Perform all post-processors on cell content
// With InsertAfter, emphasis runs before pipe table, so we need to re-run from index 0
// to ensure emphasis delimiters in cells are properly matched
foreach (var cell in cells)
{
var paragraph = (ParagraphBlock) cell[0];
state.PostProcessInlines(postInlineProcessorIndex + 1, paragraph.Inline, null, true);
state.PostProcessInlines(0, paragraph.Inline, null, true);
if (paragraph.Inline?.LastChild is not null)
{
paragraph.Inline.Span.End = paragraph.Inline.LastChild.Span.End;
@@ -477,6 +458,35 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
table.NormalizeUsingMaxWidth();
}
if (state.Block is ParagraphBlock { Inline.FirstChild: not null } leadingParagraph)
{
// The table was preceded by a non-empty paragraph, e.g.
// ```md
// Some text
// | Header |
// ```
//
// Keep the paragraph as-is and insert the table after it.
// Since we've already processed all the inlines in this table block,
// we can't insert it while the parent is still being processed.
// Hook up a callback that inserts the table after we're done with ProcessInlines for the parent block.
// We've processed inlines in the table, but not the leading paragraph itself yet.
state.PostProcessInlines(0, leadingParagraph.Inline, null, isFinalProcessing: true);
ContainerBlock parent = leadingParagraph.Parent!;
parent.ProcessInlinesEnd += (_, _) =>
{
parent.Insert(parent.IndexOf(leadingParagraph) + 1, table);
};
}
else
{
// Nothing interesting in the existing block, just replace it.
state.BlockNew = table;
}
// We don't want to continue procesing delimiters, as we are already processing them here
return false;
}
@@ -517,7 +527,7 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
continue;
}
// The last delimiter is always null,
// Parse the separator row (second row) to extract column alignments
for (int j = i + 1; j < delimiters.Count; j++)
{
var delimiter = delimiters[j];
@@ -529,11 +539,13 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
continue;
}
// Check the left side of a `|` delimiter
// Parse the content before this delimiter as a column definition (e.g., `:---`, `---:`, `:---:`)
// Skip if previous sibling is a pipe (empty cell) or whitespace
TableColumnAlign? align = null;
int delimiterCount = 0;
if (delimiter.PreviousSibling != null &&
!(delimiter.PreviousSibling is LiteralInline li && li.Content.IsEmptyOrWhitespace()) && // ignore parsed whitespace
!(delimiter.PreviousSibling is PipeTableDelimiterInline) &&
!(delimiter.PreviousSibling is LiteralInline li && li.Content.IsEmptyOrWhitespace()) &&
!ParseHeaderString(delimiter.PreviousSibling, out align, out delimiterCount))
{
break;
@@ -545,14 +557,13 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
totalDelimiterCount += delimiterCount;
columnDefinitions.Add(new TableColumnDefinition() { Alignment = align, Width = delimiterCount});
// If this is the last delimiter, we need to check the right side of the `|` delimiter
// If this is the last pipe, check for a trailing column definition (row without trailing pipe)
// e.g., `| :--- | ---:` has content after the last pipe
if (nextDelimiter is null)
{
var nextSibling = columnDelimiter != null
? columnDelimiter.FirstChild
: delimiter.NextSibling;
var nextSibling = delimiter.NextSibling;
// If there is no content after
// No trailing content means row ends with pipe: `| :--- |`
if (IsNullOrSpace(nextSibling))
{
isValidRow = true;
@@ -633,9 +644,9 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
private static void TrimStart(Inline? inline)
{
while (inline is ContainerInline && !(inline is DelimiterInline))
while (inline is ContainerInline containerInline && !(containerInline is DelimiterInline))
{
inline = ((ContainerInline)inline).FirstChild;
inline = containerInline.FirstChild;
}
if (inline is LiteralInline literal)
@@ -646,6 +657,13 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
private static void TrimEnd(Inline? inline)
{
// Walk into containers to find the last leaf to trim
// Skip PipeTableDelimiterInline but walk into other containers (including emphasis)
while (inline is ContainerInline container && !(inline is PipeTableDelimiterInline))
{
inline = container.LastChild;
}
if (inline is LiteralInline literal)
{
literal.Content.TrimEnd();
@@ -666,14 +684,112 @@ public class PipeTableParser : InlineParser, IPostInlineProcessor
return false;
}
/// <summary>
/// Promotes nested pipe delimiters and line breaks to root level.
/// </summary>
/// <remarks>
/// Handles cases like `*a | b*|` where the pipe ends up inside an unmatched emphasis container.
/// After promotion, all delimiters become siblings at root level for consistent cell boundary detection.
/// </remarks>
private static void PromoteNestedPipesToRootLevel(List<Inline> delimiters, ContainerInline root)
{
for (int i = 0; i < delimiters.Count; i++)
{
var delimiter = delimiters[i];
// Handle both pipe delimiters and line breaks
bool isPipe = delimiter is PipeTableDelimiterInline;
bool isLineBreak = delimiter is LineBreakInline;
if (!isPipe && !isLineBreak)
continue;
// Skip if already at root level
if (delimiter.Parent == root)
continue;
// Find the top-level ancestor (direct child of root)
var ancestor = delimiter.Parent;
while (ancestor?.Parent != null && ancestor.Parent != root)
{
ancestor = ancestor.Parent;
}
if (ancestor is null || ancestor.Parent != root)
continue;
// Split: promote delimiter to be sibling of ancestor
SplitContainerAtDelimiter(delimiter, ancestor);
}
}
/// <summary>
/// Splits a container at the delimiter, promoting the delimiter to root level.
/// </summary>
/// <remarks>
/// For input `*a | b*`, the pipe is inside the emphasis container:
/// EmphasisDelimiter { "a", Pipe, "b" }
/// After splitting:
/// EmphasisDelimiter { "a" }, Pipe, Container { "b" }
/// </remarks>
private static void SplitContainerAtDelimiter(Inline delimiter, Inline ancestor)
{
if (delimiter.Parent is not { } parent) return;
// Collect content after the delimiter
var contentAfter = new List<Inline>();
var current = delimiter.NextSibling;
while (current != null)
{
contentAfter.Add(current);
current = current.NextSibling;
}
// Remove content after delimiter from parent
foreach (var inline in contentAfter)
{
inline.Remove();
}
// Remove delimiter from parent
delimiter.Remove();
// Insert delimiter after the ancestor (at root level)
ancestor.InsertAfter(delimiter);
// If there's content after, wrap in new container and insert after delimiter
if (contentAfter.Count > 0)
{
// Create new container matching the original parent type
var newContainer = CreateMatchingContainer(parent);
foreach (var inline in contentAfter)
{
newContainer.AppendChild(inline);
}
delimiter.InsertAfter(newContainer);
}
}
/// <summary>
/// Creates a container to wrap content split from the source container.
/// </summary>
private static ContainerInline CreateMatchingContainer(ContainerInline source)
{
// Emphasis processing runs before pipe table processing, so emphasis delimiters
// are already resolved. A plain ContainerInline suffices.
return new ContainerInline
{
Span = source.Span,
Line = source.Line,
Column = source.Column
};
}
private sealed class TableState
{
public bool IsInvalidTable { get; set; }
public bool LineHasPipe { get; set; }
public int LineIndex { get; set; }
public List<Inline> ColumnAndLineDelimiters { get; } = [];
public List<TableCell> Cells { get; } = [];

View File

@@ -47,6 +47,7 @@ public static class TableHelper
/// <param name="slice">The text slice.</param>
/// <param name="delimiterChar">The delimiter character (either `-` or `=`). If `\0`, it will detect the character (either `-` or `=`)</param>
/// <param name="align">The alignment of the column.</param>
/// <param name="delimiterCount">The number of times <paramref name="delimiterChar"/> appeared in the column header.</param>
/// <returns>
/// <c>true</c> if parsing was successful
/// </returns>

View File

@@ -1,11 +1,12 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using System.Buffers;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
namespace Markdig.Helpers;
@@ -72,14 +73,51 @@ public static class CharHelper
private static bool IsPunctuationException(char c) =>
c is '' or '-' or '†' or '‡';
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsPunctuationException(Rune c) =>
c.IsBmp && IsPunctuationException((char)c.Value);
public static void CheckOpenCloseDelimiter(char pc, char c, bool enableWithinWord, out bool canOpen, out bool canClose)
{
pc.CheckUnicodeCategory(out bool prevIsWhiteSpace, out bool prevIsPunctuation);
c.CheckUnicodeCategory(out bool nextIsWhiteSpace, out bool nextIsPunctuation);
CheckOpenCloseDelimiter(
prevIsWhiteSpace,
prevIsPunctuation,
prevIsPunctuation && IsPunctuationException(pc),
nextIsWhiteSpace,
nextIsPunctuation,
nextIsPunctuation && IsPunctuationException(c),
enableWithinWord,
out canOpen,
out canClose);
}
var prevIsExcepted = prevIsPunctuation && IsPunctuationException(pc);
var nextIsExcepted = nextIsPunctuation && IsPunctuationException(c);
#if NET
public
#else
internal
#endif
static void CheckOpenCloseDelimiter(Rune pc, Rune c, bool enableWithinWord, out bool canOpen, out bool canClose)
{
pc.CheckUnicodeCategory(out bool prevIsWhiteSpace, out bool prevIsPunctuation);
c.CheckUnicodeCategory(out bool nextIsWhiteSpace, out bool nextIsPunctuation);
CheckOpenCloseDelimiter(
prevIsWhiteSpace,
prevIsPunctuation,
prevIsPunctuation && IsPunctuationException(pc),
nextIsWhiteSpace,
nextIsPunctuation,
nextIsPunctuation && IsPunctuationException(c),
enableWithinWord,
out canOpen,
out canClose);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CheckOpenCloseDelimiter(bool prevIsWhiteSpace, bool prevIsPunctuation, bool prevIsExcepted, bool nextIsWhiteSpace, bool nextIsPunctuation, bool nextIsExcepted, bool enableWithinWord, out bool canOpen, out bool canClose)
{
// A left-flanking delimiter run is a delimiter run that is
// (1) not followed by Unicode whitespace, and either
// (2a) not followed by a punctuation character or
@@ -100,13 +138,13 @@ public static class CharHelper
if (!enableWithinWord)
{
var temp = canOpen;
// A single _ character can open emphasis iff it is part of a left-flanking delimiter run and either
// (a) not part of a right-flanking delimiter run or
// A single _ character can open emphasis iff it is part of a left-flanking delimiter run and either
// (a) not part of a right-flanking delimiter run or
// (b) part of a right-flanking delimiter run preceded by punctuation.
canOpen = canOpen && (!canClose || prevIsPunctuation);
// A single _ character can close emphasis iff it is part of a right-flanking delimiter run and either
// (a) not part of a left-flanking delimiter run or
// (a) not part of a left-flanking delimiter run or
// (b) part of a left-flanking delimiter run followed by punctuation.
canClose = canClose && (!temp || nextIsPunctuation);
}
@@ -180,6 +218,11 @@ public static class CharHelper
return (column & (TabSize - 1)) != 0;
}
/// <summary>
/// <see langword="true"/> if the character is a <see href="https://spec.commonmark.org/0.31.2/#unicode-whitespace-character">Unicode whitespace character</see>.
/// </summary>
/// <param name="c">The character to evaluate.</param>
/// <returns><see langword="true"/> if the character is a Unicode whitespace character</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsWhitespace(this char c)
{
@@ -199,6 +242,21 @@ public static class CharHelper
return IsWhitespaceRare(c);
}
/// <summary>
/// <see langword="true"/> if the character is a <see href="https://spec.commonmark.org/0.31.2/#unicode-whitespace-character">Unicode whitespace character</see>.
/// </summary>
/// <param name="r">The character to evaluate. A supplementary character is also accepted.</param>
/// <returns><see langword="true"/> if the character is a Unicode whitespace character</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
#if NET
public
#else
internal
#endif
static bool IsWhitespace(this Rune r) => r.IsBmp && IsWhitespace((char)r.Value);
// Note: there is no supplementary character whose Unicode category is Zs (at least as of Unicode 17).
// https://www.compart.com/en/unicode/category/Zs
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsWhiteSpaceOrZero(this char c)
{
@@ -243,7 +301,12 @@ public static class CharHelper
return s_escapableSymbolChars.Contains(c);
}
// Check if a char is a space or a punctuation
/// <summary>
/// Checks the Unicode category of the given character and determines whether it is a whitespace or punctuation character.
/// </summary>
/// <param name="c">The character to check.</param>
/// <param name="space">Output parameter indicating whether the character is a whitespace character.</param>
/// <param name="punctuation">Output parameter indicating whether the character is a punctuation character.</param>
public static void CheckUnicodeCategory(this char c, out bool space, out bool punctuation)
{
if (IsWhitespace(c))
@@ -263,6 +326,36 @@ public static class CharHelper
}
}
/// <summary>
/// Check if a character is a <see href="https://spec.commonmark.org/0.31.2/#unicode-whitespace-character">Unicode whitespace</see> or <see href="https://spec.commonmark.org/0.31.2/#unicode-punctuation-character">punctuation character</see>.
/// </summary>
/// <param name="r">The character to evaluate. A supplementary character is also accepted.</param>
/// <param name="space"><see langword="true"/> if the character is an <see href="https://spec.commonmark.org/0.31.2/#unicode-whitespace-character">Unicode whitespace character</see></param>
/// <param name="punctuation"><see langword="true"/> if the character is a <see href="https://spec.commonmark.org/0.31.2/#unicode-punctuation-character">Unicode punctuation character</see></param>
#if NET
public
#else
internal
#endif
static void CheckUnicodeCategory(this Rune r, out bool space, out bool punctuation)
{
if (IsWhitespace(r))
{
space = true;
punctuation = false;
}
else if (r.Value <= 127)
{
space = r.Value == 0;
punctuation = r.IsBmp && IsAsciiPunctuationOrZero((char)r.Value);
}
else
{
space = false;
punctuation = (CommonMarkPunctuationCategoryMask & (1 << (int)Rune.GetUnicodeCategory(r))) != 0;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool IsSpaceOrPunctuationForGFMAutoLink(char c)
{
@@ -306,22 +399,37 @@ public static class CharHelper
return c == '\0';
}
/// <summary>
/// Returns <see langword="true"/> if the character is a <see href="https://spec.commonmark.org/0.31.2/#space">space</see> (U+0020).
/// </summary>
/// <param name="c">The character to evaluate</param>
/// <returns><see langword="true"/> if the character is a space</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsSpace(this char c)
{
// 2.1 Characters and lines
// 2.1 Characters and lines
// A space is U+0020.
return c == ' ';
}
/// <summary>
/// Returns <see langword="true"/> if the character is a <see href="https://spec.commonmark.org/0.31.2/#tab">tab</see> (U+0009).
/// </summary>
/// <param name="c">The character to evaluate</param>
/// <returns><see langword="true"/> if the character is a tab</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsTab(this char c)
{
// 2.1 Characters and lines
// 2.1 Characters and lines
// A space is U+0009.
return c == '\t';
}
/// <summary>
/// Returns <see langword="true"/> if the character is a <see href="https://spec.commonmark.org/0.31.2/#space">space</see> (U+0020) or <see href="https://spec.commonmark.org/0.31.2/#tab">tab</see> (U+0009).
/// </summary>
/// <param name="c">The character to evaluate.</param>
/// <returns><see langword="true"/> if the character is a space or tab</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsSpaceOrTab(this char c)
{

View File

@@ -51,10 +51,7 @@ public sealed class CharacterMap<T> where T : class
{
nonAsciiMap ??= [];
if (!nonAsciiMap.ContainsKey(openingChar))
{
nonAsciiMap[openingChar] = state.Value;
}
nonAsciiMap.TryAdd(openingChar, state.Value);
}
}

View File

@@ -1,5 +1,5 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using System.Diagnostics;
@@ -430,7 +430,7 @@ public static class HtmlHelper
const string EndOfComment = "-->";
int endOfComment = slice.IndexOf(EndOfComment, StringComparison.Ordinal);
int endOfComment = slice.IndexOf(EndOfComment.AsSpan(), StringComparison.Ordinal);
if (endOfComment < 0)
{
return false;
@@ -474,7 +474,7 @@ public static class HtmlHelper
public static string Unescape(string? text, bool removeBackSlash = true)
{
// Credits: code from CommonMark.NET
// Copyright (c) 2014, Kārlis Gaņģis All rights reserved.
// Copyright (c) 2014, Kārlis Gaņģis All rights reserved.
// See license for details: https://github.com/Knagis/CommonMark.NET/blob/master/LICENSE.md
if (string.IsNullOrEmpty(text))
{
@@ -553,7 +553,7 @@ public static class HtmlHelper
public static int ScanEntity<T>(T slice, out int numericEntity, out int namedEntityStart, out int namedEntityLength) where T : ICharIterator
{
// Credits: code from CommonMark.NET
// Copyright (c) 2014, Kārlis Gaņģis All rights reserved.
// Copyright (c) 2014, Kārlis Gaņģis All rights reserved.
// See license for details: https://github.com/Knagis/CommonMark.NET/blob/master/LICENSE.md
numericEntity = 0;
@@ -568,7 +568,7 @@ public static class HtmlHelper
var start = slice.Start;
char c = slice.NextChar();
int counter = 0;
if (c == '#')
{
c = slice.PeekChar();

View File

@@ -1,12 +1,14 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using Markdig.Syntax;
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using Markdig.Syntax;
using System.Text;
namespace Markdig.Helpers;
@@ -30,13 +32,40 @@ public static class LinkHelper
var headingBuffer = new ValueStringBuilder(stackalloc char[ValueStringBuilder.StackallocThreshold]);
bool hasLetter = keepOpeningDigits && headingText.Length > 0 && char.IsLetterOrDigit(headingText[0]);
bool previousIsSpace = false;
for (int i = 0; i < headingText.Length; i++)
// First normalize the string to decompose characters if allowOnlyAscii is true
string normalizedString = string.Empty;
if (allowOnlyAscii)
{
var c = headingText[i];
var normalized = allowOnlyAscii ? CharNormalizer.ConvertToAscii(c) : null;
for (int j = 0; j < (normalized?.Length ?? 1); j++)
normalizedString = headingText.ToString().Normalize(NormalizationForm.FormD);
}
var textToProcess = string.IsNullOrEmpty(normalizedString) ? headingText : normalizedString.AsSpan();
for (int i = 0; i < textToProcess.Length; i++)
{
var c = textToProcess[i];
// Skip combining diacritical marks when normalized
if (allowOnlyAscii && CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
{
if (normalized != null)
continue;
}
// Handle German umlauts and Norwegian/Danish characters explicitly (they don't decompose properly)
ReadOnlySpan<char> normalized;
if (IsSpecialScandinavianOrGermanChar(c))
{
normalized = NormalizeScandinavianOrGermanChar(c);
}
else
{
normalized = allowOnlyAscii ? CharNormalizer.ConvertToAscii(c) : ReadOnlySpan<char>.Empty;
}
for (int j = 0; j < (normalized.Length < 1 ? 1 : normalized.Length); j++)
{
if (!normalized.IsEmpty)
{
c = normalized[j];
}
@@ -101,6 +130,50 @@ public static class LinkHelper
return headingBuffer.ToString();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsSpecialScandinavianOrGermanChar(char c)
{
// German umlauts and ß
// Norwegian/Danish/Swedish æ, ø, å
// Icelandic þ (thorn), ð (eth)
return c == 'ä' || c == 'ö' || c == 'ü' ||
c == 'Ä' || c == 'Ö' || c == 'Ü' ||
c == 'ß' ||
c == 'æ' || c == 'ø' || c == 'å' ||
c == 'Æ' || c == 'Ø' || c == 'Å' ||
c == 'þ' || c == 'ð' ||
c == 'Þ' || c == 'Ð';
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ReadOnlySpan<char> NormalizeScandinavianOrGermanChar(char c)
{
return c switch
{
// German
'ä' => "ae",
'ö' => "oe",
'ü' => "ue",
'Ä' => "Ae",
'Ö' => "Oe",
'Ü' => "Ue",
'ß' => "ss",
// Norwegian/Danish/Swedish
'æ' => "ae",
'ø' => "oe",
'å' => "aa",
'Æ' => "Ae",
'Ø' => "Oe",
'Å' => "Aa",
// Icelandic
'þ' => "th",
'Þ' => "Th",
'ð' => "d",
'Ð' => "D",
_ => ReadOnlySpan<char>.Empty
};
}
public static string UrilizeAsGfm(string headingText)
{
return UrilizeAsGfm(headingText.AsSpan());
@@ -142,13 +215,13 @@ public static class LinkHelper
return false;
}
// An absolute URI, for these purposes, consists of a scheme followed by a colon (:)
// followed by zero or more characters other than ASCII whitespace and control characters, <, and >.
// An absolute URI, for these purposes, consists of a scheme followed by a colon (:)
// followed by zero or more characters other than ASCII whitespace and control characters, <, and >.
// If the URI includes these characters, they must be percent-encoded (e.g. %20 for a space).
// A URI that would end with a full stop (.) is treated instead as ending immediately before the full stop.
// a scheme is any sequence of 232 characters
// beginning with an ASCII letter
// a scheme is any sequence of 232 characters
// beginning with an ASCII letter
// and followed by any combination of ASCII letters, digits, or the symbols plus (”+”), period (”.”), or hyphen (”-”).
// An email address, for these purposes, is anything that matches the non-normative regex from the HTML5 spec:
@@ -203,7 +276,7 @@ public static class LinkHelper
if (isValidChar)
{
// a scheme is any sequence of 232 characters
// a scheme is any sequence of 232 characters
if (state > 0 && builder.Length >= 32)
{
goto ReturnFalse;
@@ -218,7 +291,8 @@ public static class LinkHelper
}
state = 1;
break;
} else if (c == '@')
}
else if (c == '@')
{
if (state > 0)
{
@@ -233,8 +307,8 @@ public static class LinkHelper
}
}
// append ':' or '@'
builder.Append(c);
// append ':' or '@'
builder.Append(c);
if (state < 0)
{
@@ -341,10 +415,10 @@ public static class LinkHelper
public static bool TryParseInlineLink(ref StringSlice text, out string? link, out string? title, out SourceSpan linkSpan, out SourceSpan titleSpan)
{
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
// 2. optional whitespace, TODO: specs: is it whitespace or multiple whitespaces?
// 3. an optional link destination,
// 4. an optional link title separated from the link destination by whitespace,
// 3. an optional link destination,
// 4. an optional link title separated from the link destination by whitespace,
// 5. optional whitespace, TODO: specs: is it whitespace or multiple whitespaces?
// 6. and a right parenthesis )
bool isValid = false;
@@ -355,7 +429,7 @@ public static class LinkHelper
linkSpan = SourceSpan.Empty;
titleSpan = SourceSpan.Empty;
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
if (c == '(')
{
text.SkipChar();
@@ -411,7 +485,7 @@ public static class LinkHelper
{
// Skip ')'
text.SkipChar();
title ??= string.Empty;
// not to normalize nulls into empty strings, since LinkInline.Title property is nullable.
}
return isValid;
@@ -431,10 +505,10 @@ public static class LinkHelper
out SourceSpan triviaAfterTitle,
out bool urlHasPointyBrackets)
{
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
// 2. optional whitespace, TODO: specs: is it whitespace or multiple whitespaces?
// 3. an optional link destination,
// 4. an optional link title separated from the link destination by whitespace,
// 3. an optional link destination,
// 4. an optional link title separated from the link destination by whitespace,
// 5. optional whitespace, TODO: specs: is it whitespace or multiple whitespaces?
// 6. and a right parenthesis )
bool isValid = false;
@@ -452,7 +526,7 @@ public static class LinkHelper
urlHasPointyBrackets = false;
titleEnclosingCharacter = '\0';
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
// 1. An inline link consists of a link text followed immediately by a left parenthesis (,
if (c == '(')
{
text.SkipChar();
@@ -699,7 +773,7 @@ public static class LinkHelper
var c = text.CurrentChar;
// a sequence of zero or more characters between an opening < and a closing >
// a sequence of zero or more characters between an opening < and a closing >
// that contains no line breaks, or unescaped < or > characters, or
if (c == '<')
{
@@ -746,9 +820,9 @@ public static class LinkHelper
else
{
// a nonempty sequence of characters that does not start with <, does not include ASCII space or control characters,
// and includes parentheses only if (a) they are backslash-escaped or (b) they are part of a
// balanced pair of unescaped parentheses that is not itself inside a balanced pair of unescaped
// parentheses.
// and includes parentheses only if (a) they are backslash-escaped or (b) they are part of a
// balanced pair of unescaped parentheses that is not itself inside a balanced pair of unescaped
// parentheses.
bool hasEscape = false;
int openedParent = 0;
while (true)
@@ -848,7 +922,7 @@ public static class LinkHelper
var c = text.CurrentChar;
// a sequence of zero or more characters between an opening < and a closing >
// a sequence of zero or more characters between an opening < and a closing >
// that contains no line breaks, or unescaped < or > characters, or
if (c == '<')
{
@@ -895,9 +969,9 @@ public static class LinkHelper
else
{
// a nonempty sequence of characters that does not start with <, does not include ASCII space or control characters,
// and includes parentheses only if (a) they are backslash-escaped or (b) they are part of a
// balanced pair of unescaped parentheses that is not itself inside a balanced pair of unescaped
// parentheses.
// and includes parentheses only if (a) they are backslash-escaped or (b) they are part of a
// balanced pair of unescaped parentheses that is not itself inside a balanced pair of unescaped
// parentheses.
bool hasEscape = false;
int openedParent = 0;
while (true)
@@ -1127,7 +1201,7 @@ public static class LinkHelper
if (c != '\0' && c != '\n' && c != '\r')
{
// If we were able to parse the url but the title doesn't end with space,
// If we were able to parse the url but the title doesn't end with space,
// we are still returning a valid definition
if (newLineCount > 0 && title != null)
{
@@ -1267,7 +1341,7 @@ public static class LinkHelper
if (c != '\0' && c != '\n' && c != '\r')
{
// If we were able to parse the url but the title doesn't end with space,
// If we were able to parse the url but the title doesn't end with space,
// we are still returning a valid definition
if (newLineCount > 0 && title != null)
{
@@ -1565,4 +1639,4 @@ public static class LinkHelper
label = buffer.ToString();
return true;
}
}
}

View File

@@ -1,11 +1,12 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
#nullable disable
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
namespace Markdig.Helpers;
@@ -125,6 +126,34 @@ public struct StringSlice : ICharIterator
}
}
/// <summary>
/// Gets the current <see cref="Rune"/>. Recognizes supplementary code points that cannot be covered by a single <see cref="char"/>.
/// </summary>
/// <returns>The current rune or <see langword="default"/> if the current position contains an incomplete surrogate pair or <see cref="IsEmpty"/>.</returns>
#if NET
public
#else
internal
#endif
readonly Rune CurrentRune
{
get
{
int start = Start;
if (start > End) return default;
char first = Text[start];
// '\0' is stored in `rune` if `TryCreate` returns false
if (!Rune.TryCreate(first, out Rune rune) && start + 1 <= End)
{
// The first character is a surrogate, check if we have a valid pair
Rune.TryCreate(first, Text[start + 1], out rune);
}
return rune;
}
}
/// <summary>
/// Gets a value indicating whether this instance is empty.
/// </summary>
@@ -145,6 +174,32 @@ public struct StringSlice : ICharIterator
get => Text[index];
}
/// <summary>
/// Gets the <see cref="Rune"/> at the specified index.
/// Recognizes supplementary code points that cannot be covered by a single <see cref="char"/>.
/// </summary>
/// <param name="index">The index into <see cref="Text"/>.</param>
/// <returns>The rune at the specified index or <see langword="default"/> if the location contains an incomplete surrogate pair.</returns>
/// <exception cref="IndexOutOfRangeException">Thrown when the given <paramref name="index"/> is out of range</exception>
#if NET
public
#else
internal
#endif
readonly Rune RuneAt(int index)
{
string text = Text;
char first = text[index];
if (!Rune.TryCreate(first, out Rune rune) && (uint)(index + 1) < (uint)text.Length)
{
// The first character is a surrogate, check if we have a valid pair
Rune.TryCreate(first, text[index + 1], out rune);
}
return rune;
}
/// <summary>
/// Goes to the next character, incrementing the <see cref="Start" /> position.
@@ -166,6 +221,50 @@ public struct StringSlice : ICharIterator
return Text[start];
}
/// <summary>
/// Goes to the next <see cref="Rune"/>, incrementing the <see cref="Start"/> position.
/// If <see cref="CurrentRune"/> is a supplementary character, <see cref="Start"/> will be advanced by 2.
/// </summary>
/// <returns>The current rune or <see langword="default"/> if the next position contains an incomplete surrogate pair or <see cref="IsEmpty"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
#if NET
public
#else
internal
#endif
Rune NextRune()
{
int start = Start;
if (start >= End)
{
Start = End + 1;
return default;
}
// Start may be pointing at the start of a previous surrogate pair. Check if we have to advance by 2 chars.
if (
// Advance to the next character, checking for a valid surrogate pair
char.IsHighSurrogate(Text[start++])
// Don't unconditionally increment `start` here. Check the surrogate code unit at `start` is a part of a valid surrogate pair first.
&& start <= End
&& char.IsLowSurrogate(Text[start]))
{
// Valid surrogate pair representing a supplementary character
start++;
}
Start = start;
var first = Text[start];
// '\0' is stored in `rune` if `TryCreate` returns false
if (!Rune.TryCreate(first, out Rune rune) && start + 1 <= End)
{
// Supplementary character
Rune.TryCreate(first, Text[start + 1], out rune);
}
return rune;
}
/// <summary>
/// Goes to the next character, incrementing the <see cref="Start" /> position.
/// </summary>
@@ -244,6 +343,60 @@ public struct StringSlice : ICharIterator
return (uint)index < (uint)text.Length ? text[index] : '\0';
}
/// <summary>
/// Peeks a <see cref="Rune"/> at the specified offset from the current beginning of the slice
/// without using the range <see cref="Start"/> or <see cref="End"/>, returns <see langword="default"/> if outside the <see cref="Text"/>.
/// Recognizes supplementary code points that cannot be covered by a single <see cref="char"/>.
/// A positive <paramref name="offset"/> value expects the <em>high</em> surrogate and a negative <paramref name="offset"/> expects the <em>low</em> surrogate of the surrogate pair of a supplementary character at that position.
/// </summary>
/// <param name="offset">The offset.</param>
/// <returns>The rune at the specified offset, returns default if none.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
#if NET
public
#else
internal
#endif
readonly Rune PeekRuneExtra(int offset)
{
int index = Start + offset;
string text = Text;
if ((uint)index >= (uint)text.Length)
{
return default;
}
var bmpOrNearerSurrogate = text[index];
if (Rune.TryCreate(bmpOrNearerSurrogate, out var rune))
{
// BMP
return rune;
}
// Check if we have a valid surrogate pair
if (offset < 0)
{
// The code unit at `index` should be a low surrogate
// The scalar value (rune) of a supplementary character should start at `index - 1`, which should be a high surrogate
// By casting to uint and comparing with < text.Length ("abusing" overflow), we can check both > 0 and < text.Length in one check
if ((uint)(index - 1) < (uint)text.Length)
{
// Stores '\0' in `rune` if `TryCreate` returns false
Rune.TryCreate(text[index - 1], bmpOrNearerSurrogate, out rune);
}
}
else
{
// The code unit at `index` should be a high surrogate and the start of a scalar value (rune) of a supplementary character
if ((uint)(index + 1) < (uint)text.Length)
{
Rune.TryCreate(bmpOrNearerSurrogate, text[index + 1], out rune);
}
}
return rune;
}
/// <summary>
/// Matches the specified text.
/// </summary>
@@ -474,7 +627,7 @@ public struct StringSlice : ICharIterator
return default;
}
#if NETCOREAPP3_1_OR_GREATER
#if NET
return MemoryMarshal.CreateReadOnlySpan(ref Unsafe.Add(ref Unsafe.AsRef(in text.GetPinnableReference()), start), length);
#else
return text.AsSpan(start, length);

View File

@@ -22,9 +22,147 @@ internal static class UnicodeUtility
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void GetUtf16SurrogatesFromSupplementaryPlaneScalar(uint value, out char highSurrogateCodePoint, out char lowSurrogateCodePoint)
{
Debug.Assert(IsValidUnicodeScalar(value) && IsBmpCodePoint(value));
Debug.Assert(IsValidUnicodeScalar(value) && !IsBmpCodePoint(value));
highSurrogateCodePoint = (char)((value + ((0xD800u - 0x40u) << 10)) >> 10);
lowSurrogateCodePoint = (char)((value & 0x3FFu) + 0xDC00u);
}
#if !NETCOREAPP3_0_OR_GREATER
// The following section is used only for the implementation of Rune.
/// <summary>
/// The Unicode replacement character U+FFFD.
/// </summary>
public const uint ReplacementChar = 0xFFFD;
/// <summary>
/// Returns the Unicode plane (0 through 16, inclusive) which contains this code point.
/// </summary>
public static int GetPlane(uint codePoint)
{
UnicodeDebug.AssertIsValidCodePoint(codePoint);
return (int)(codePoint >> 16);
}
/// <summary>
/// Returns a Unicode scalar value from two code points representing a UTF-16 surrogate pair.
/// </summary>
public static uint GetScalarFromUtf16SurrogatePair(uint highSurrogateCodePoint, uint lowSurrogateCodePoint)
{
UnicodeDebug.AssertIsHighSurrogateCodePoint(highSurrogateCodePoint);
UnicodeDebug.AssertIsLowSurrogateCodePoint(lowSurrogateCodePoint);
// This calculation comes from the Unicode specification, Table 3-5.
// Need to remove the D800 marker from the high surrogate and the DC00 marker from the low surrogate,
// then fix up the "wwww = uuuuu - 1" section of the bit distribution. The code is written as below
// to become just two instructions: shl, lea.
return (highSurrogateCodePoint << 10) + lowSurrogateCodePoint - ((0xD800U << 10) + 0xDC00U - (1 << 16));
}
/// <summary>
/// Returns <see langword="true"/> iff <paramref name="value"/> is an ASCII
/// character ([ U+0000..U+007F ]).
/// </summary>
/// <remarks>
/// Per http://www.unicode.org/glossary/#ASCII, ASCII is only U+0000..U+007F.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsAsciiCodePoint(uint value) => value <= 0x7Fu;
/// <summary>
/// Returns <see langword="true"/> iff <paramref name="value"/> is a UTF-16 high surrogate code point,
/// i.e., is in [ U+D800..U+DBFF ], inclusive.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsHighSurrogateCodePoint(uint value) => IsInRangeInclusive(value, 0xD800U, 0xDBFFU);
/// <summary>
/// Returns <see langword="true"/> iff <paramref name="value"/> is between
/// <paramref name="lowerBound"/> and <paramref name="upperBound"/>, inclusive.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsInRangeInclusive(uint value, uint lowerBound, uint upperBound) => (value - lowerBound) <= (upperBound - lowerBound);
/// <summary>
/// Returns <see langword="true"/> iff <paramref name="value"/> is a UTF-16 low surrogate code point,
/// i.e., is in [ U+DC00..U+DFFF ], inclusive.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsLowSurrogateCodePoint(uint value) => IsInRangeInclusive(value, 0xDC00U, 0xDFFFU);
/// <summary>
/// Returns <see langword="true"/> iff <paramref name="value"/> is a UTF-16 surrogate code point,
/// i.e., is in [ U+D800..U+DFFF ], inclusive.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsSurrogateCodePoint(uint value) => IsInRangeInclusive(value, 0xD800U, 0xDFFFU);
/// <summary>
/// Returns <see langword="true"/> iff <paramref name="codePoint"/> is a valid Unicode code
/// point, i.e., is in [ U+0000..U+10FFFF ], inclusive.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsValidCodePoint(uint codePoint) => codePoint <= 0x10FFFFU;
/// <summary>
/// Given a Unicode scalar value, gets the number of UTF-16 code units required to represent this value.
/// </summary>
public static int GetUtf16SequenceLength(uint value)
{
UnicodeDebug.AssertIsValidScalar(value);
value -= 0x10000; // if value < 0x10000, high byte = 0xFF; else high byte = 0x00
value += (2 << 24); // if value < 0x10000, high byte = 0x01; else high byte = 0x02
value >>= 24; // shift high byte down
return (int)value; // and return it
}
/// <summary>
/// Given a Unicode scalar value, gets the number of UTF-8 code units required to represent this value.
/// </summary>
public static int GetUtf8SequenceLength(uint value)
{
UnicodeDebug.AssertIsValidScalar(value);
// The logic below can handle all valid scalar values branchlessly.
// It gives generally good performance across all inputs, and on x86
// it's only six instructions: lea, sar, xor, add, shr, lea.
// 'a' will be -1 if input is < 0x800; else 'a' will be 0
// => 'a' will be -1 if input is 1 or 2 UTF-8 code units; else 'a' will be 0
int a = ((int)value - 0x0800) >> 31;
// The number of UTF-8 code units for a given scalar is as follows:
// - U+0000..U+007F => 1 code unit
// - U+0080..U+07FF => 2 code units
// - U+0800..U+FFFF => 3 code units
// - U+10000+ => 4 code units
//
// If we XOR the incoming scalar with 0xF800, the chart mutates:
// - U+0000..U+F7FF => 3 code units
// - U+F800..U+F87F => 1 code unit
// - U+F880..U+FFFF => 2 code units
// - U+10000+ => 4 code units
//
// Since the 1- and 3-code unit cases are now clustered, they can
// both be checked together very cheaply.
value ^= 0xF800u;
value -= 0xF880u; // if scalar is 1 or 3 code units, high byte = 0xFF; else high byte = 0x00
value += (4 << 24); // if scalar is 1 or 3 code units, high byte = 0x03; else high byte = 0x04
value >>= 24; // shift high byte down
// Final return value:
// - U+0000..U+007F => 3 + (-1) * 2 = 1
// - U+0080..U+07FF => 4 + (-1) * 2 = 2
// - U+0800..U+FFFF => 3 + ( 0) * 2 = 3
// - U+10000+ => 4 + ( 0) * 2 = 4
return (int)value + (a * 2);
}
#endif
}

View File

@@ -25,17 +25,16 @@
</PropertyGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net462' OR '$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Memory" Version="4.6.0" />
<PackageReference Include="System.Memory" />
</ItemGroup>
<ItemGroup>
<None Include="../../img/markdig.png" Pack="true" PackagePath="" />
<None Include="../../readme.md" Pack="true" PackagePath="/"/>
<PackageReference Include="MinVer" Version="4.3.0">
<None Include="../../readme.md" Pack="true" PackagePath="/" />
<PackageReference Include="MinVer">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.*" PrivateAssets="All"/>
</ItemGroup>
<Target Name="PatchVersion" AfterTargets="MinVer">

View File

@@ -538,7 +538,7 @@ public static class MarkdownExtensions
var inlineParser = pipeline.InlineParsers.Find<AutolinkInlineParser>();
if (inlineParser != null)
{
inlineParser.EnableHtmlParsing = false;
inlineParser.Options.EnableHtmlParsing = false;
}
return pipeline;
}

View File

@@ -493,8 +493,34 @@ public class BlockProcessor
ContinueProcessingLine = true;
ResetLine(newLine);
ResetLine(newLine, 0);
Process();
LineIndex++;
}
/// <summary>
/// Processes part of a line.
/// </summary>
/// <param name="line">The line.</param>
/// <param name="column">The column.</param>
public void ProcessLinePart(StringSlice line, int column)
{
CurrentLineStartPosition = line.Start - column;
ContinueProcessingLine = true;
ResetLine(line, column);
Process();
}
/// <summary>
/// Process current string slice.
/// </summary>
private void Process()
{
TryContinueBlocks();
// If the line was not entirely processed by pending blocks, try to process it with any new block
@@ -502,8 +528,6 @@ public class BlockProcessor
// Close blocks that are no longer opened
CloseAll(false);
LineIndex++;
}
internal bool IsOpen(Block block)
@@ -956,18 +980,17 @@ public class BlockProcessor
ContinueProcessingLine = !result.IsDiscard();
}
private void ResetLine(StringSlice newLine)
private void ResetLine(StringSlice newLine, int column)
{
Line = newLine;
Column = 0;
Column = column;
ColumnBeforeIndent = 0;
StartBeforeIndent = Start;
originalLineStart = newLine.Start;
originalLineStart = newLine.Start - column;
TriviaStart = newLine.Start;
}
[MemberNotNull(nameof(Document), nameof(Parsers))]
internal void Setup(MarkdownDocument document, BlockParserList parsers, MarkdownParserContext? context, bool trackTrivia)
{

View File

@@ -3,6 +3,7 @@
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -14,19 +15,21 @@ namespace Markdig.Parsers.Inlines;
/// <seealso cref="InlineParser" />
public class AutolinkInlineParser : InlineParser
{
/// <summary>
/// Initializes a new instance of the <see cref="AutolinkInlineParser"/> class.
/// </summary>
public AutolinkInlineParser()
public AutolinkInlineParser() : this(new AutolinkOptions())
{
OpeningCharacters = ['<'];
EnableHtmlParsing = true;
}
/// <summary>
/// Gets or sets a value indicating whether to enable HTML parsing. Default is <c>true</c>
/// Initializes a new instance of the <see cref="AutolinkInlineParser"/> class.
/// </summary>
public bool EnableHtmlParsing { get; set; }
public AutolinkInlineParser(AutolinkOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
OpeningCharacters = ['<'];
}
public readonly AutolinkOptions Options;
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
@@ -42,8 +45,12 @@ public class AutolinkInlineParser : InlineParser
Line = line,
Column = column
};
if (Options.OpenInNewWindow)
{
processor.Inline.GetAttributes().AddPropertyIfNotExist("target", "_blank");
}
}
else if (EnableHtmlParsing)
else if (Options.EnableHtmlParsing)
{
slice = saved;
if (!HtmlHelper.TryParseHtmlTag(ref slice, out string? htmlTag))
@@ -57,6 +64,10 @@ public class AutolinkInlineParser : InlineParser
Line = line,
Column = column
};
if (Options.OpenInNewWindow)
{
processor.Inline.GetAttributes().AddPropertyIfNotExist("target", "_blank");
}
}
else
{

View File

@@ -0,0 +1,13 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
namespace Markdig.Parsers.Inlines;
public class AutolinkOptions : LinkOptions
{
/// <summary>
/// Gets or sets a value indicating whether to enable HTML parsing. Default is <c>true</c>
/// </summary>
public bool EnableHtmlParsing { get; set; } = true;
}

View File

@@ -4,6 +4,7 @@
using System.Diagnostics;
using Markdig.Extensions.Tables;
using Markdig.Helpers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -35,6 +36,7 @@ public class CodeInlineParser : InlineParser
Debug.Assert(match is not ('\r' or '\n'));
// Match the opened sticks
int openingStart = slice.Start;
int openSticks = slice.CountAndSkipChar(match);
// A backtick string is a string of one or more backtick characters (`) that is neither preceded nor followed by a backtick.
@@ -75,8 +77,22 @@ public class CodeInlineParser : InlineParser
{
break;
}
else if (closeSticks == 0)
if (closeSticks == 0)
{
if (span.TrimStart(['\r', '\n']).StartsWith('|'))
{
// We saw the start of a code inline, but the close sticks are not present on the same line.
// If the next line starts with a pipe character, this is likely an incomplete CodeInline within a table.
// Treat it as regular text to avoid breaking the overall table shape.
// Use ContainsParentOrSiblingOfType to handle both nested and flat pipe table structures.
if (processor.Inline != null && processor.Inline.ContainsParentOrSiblingOfType<PipeTableDelimiterInline>())
{
slice.Start = openingStart;
return false;
}
}
containsNewLines = true;
span = span.Slice(1);
}

View File

@@ -3,12 +3,14 @@
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
using System.Diagnostics;
namespace Markdig.Parsers.Inlines;
/// <summary>
/// Descriptor for an emphasis.
/// </summary>
[DebuggerDisplay("Emphasis Char={Character}, Min={MinimumCount}, Max={MaximumCount}, EnableWithinWord={EnableWithinWord}")]
public sealed class EmphasisDescriptor
{
/// <summary>

View File

@@ -4,7 +4,7 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using Markdig.Helpers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
@@ -125,7 +125,10 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
}
// Follow DelimiterInline (EmphasisDelimiter, TableDelimiter...)
child = delimiterInline.FirstChild;
// If the delimiter has IsClosed=true (e.g., pipe table delimiter), it has no children
// In that case, continue to next sibling instead of stopping
var firstChild = delimiterInline.FirstChild;
child = firstChild ?? delimiterInline.NextSibling;
}
else
{
@@ -150,18 +153,19 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
var delimiterChar = slice.CurrentChar;
var emphasisDesc = emphasisMap![delimiterChar]!;
char pc = (char)0;
Rune pc = (Rune)0;
if (processor.Inline is HtmlEntityInline htmlEntityInline)
{
if (htmlEntityInline.Transcoded.Length > 0)
{
pc = htmlEntityInline.Transcoded[htmlEntityInline.Transcoded.End];
pc = htmlEntityInline.Transcoded.RuneAt(htmlEntityInline.Transcoded.End);
}
}
if (pc == 0)
if (pc.Value == 0)
{
pc = slice.PeekCharExtra(-1);
if (pc == delimiterChar && slice.PeekCharExtra(-2) != '\\')
pc = slice.PeekRuneExtra(-1);
// delimiterChar is BMP, so slice.PeekCharExtra(-2) is (a part of) the character two positions back.
if (pc == (Rune)delimiterChar && slice.PeekCharExtra(-2) != '\\')
{
// If we get here, we determined that either:
// a) there weren't enough delimiters in the delimiter run to satisfy the MinimumCount condition
@@ -179,12 +183,13 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
return false;
}
char c = slice.CurrentChar;
Rune c = slice.CurrentRune;
// The following character is actually an entity, we need to decode it
if (HtmlEntityParser.TryParse(ref slice, out string? htmlString, out int htmlLength))
{
c = htmlString[0];
// Note: c is U+FFFD when decode error
Rune.DecodeFromUtf16(htmlString, out c, out _);
}
// Calculate Open-Close for current character
@@ -233,9 +238,9 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
continue;
}
if ((closeDelimiter.Type & DelimiterType.Close) != 0 && closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount)
if ((closeDelimiter.Type & DelimiterType.Close) != 0)
{
while (true)
while (closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount)
{
// Now, look back in the stack (staying above stack_bottom and the openers_bottom for this delimiter type)
// for the first matching potential opener (“matching” means same delimiter).
@@ -245,8 +250,7 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
{
var previousOpenDelimiter = delimiters[j];
var isOddMatch = ((closeDelimiter.Type & DelimiterType.Open) != 0 ||
(previousOpenDelimiter.Type & DelimiterType.Close) != 0) &&
var isOddMatch = ((closeDelimiter.Type & DelimiterType.Open) != 0 || (previousOpenDelimiter.Type & DelimiterType.Close) != 0) &&
previousOpenDelimiter.DelimiterCount != closeDelimiter.DelimiterCount &&
(previousOpenDelimiter.DelimiterCount + closeDelimiter.DelimiterCount) % 3 == 0 &&
(previousOpenDelimiter.DelimiterCount % 3 != 0 || closeDelimiter.DelimiterCount % 3 != 0);
@@ -357,7 +361,8 @@ public class EmphasisInlineParser : InlineParser, IPostInlineProcessor
}
// The current delimiters are matching
if (openDelimiter.DelimiterCount >= emphasisDesc.MinimumCount)
if (openDelimiter.DelimiterCount >= emphasisDesc.MinimumCount &&
closeDelimiter.DelimiterCount >= emphasisDesc.MinimumCount)
{
goto process_delims;
}

View File

@@ -3,6 +3,7 @@
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -17,11 +18,22 @@ public class LinkInlineParser : InlineParser
/// <summary>
/// Initializes a new instance of the <see cref="LinkInlineParser"/> class.
/// </summary>
public LinkInlineParser()
public LinkInlineParser() : this(new LinkOptions())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LinkInlineParser"/> class.
/// </summary>
public LinkInlineParser(LinkOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
OpeningCharacters = ['[', ']', '!'];
}
public readonly LinkOptions Options;
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// The following methods are inspired by the "An algorithm for parsing nested emphasis and links"
@@ -169,6 +181,11 @@ public class LinkInlineParser : InlineParser
linkInline.LocalLabel = localLabel;
}
if (Options.OpenInNewWindow)
{
linkInline.GetAttributes().AddPropertyIfNotExist("target", "_blank");
}
link = linkInline;
}
@@ -260,7 +277,7 @@ public class LinkInlineParser : InlineParser
link = new LinkInline()
{
Url = HtmlHelper.Unescape(url, removeBackSlash: false),
Title = HtmlHelper.Unescape(title, removeBackSlash: false),
Title = title is null ? null : HtmlHelper.Unescape(title, removeBackSlash: false),
IsImage = openParent.IsImage,
LabelSpan = openParent.LabelSpan,
UrlSpan = inlineState.GetSourcePositionFromLocalSpan(linkSpan),

View File

@@ -0,0 +1,19 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Markdig.Parsers;
public class LinkOptions
{
/// <summary>
/// Should the link open in a new window when clicked (false by default)
/// </summary>
public bool OpenInNewWindow { get; set; }
}

View File

@@ -145,6 +145,7 @@ public class ListBlockParser : BlockParser
if (list.CountBlankLinesReset == 1 && listItem.ColumnWidth < 0)
{
state.Close(listItem);
list.CountBlankLinesReset = 0;
// Leave the list open
list.IsOpen = true;

View File

@@ -38,11 +38,10 @@ public abstract class ParserList<T, TState> : OrderedList<T> where T : notnull,
{
foreach (var openingChar in parser.OpeningCharacters)
{
if (!charCounter.ContainsKey(openingChar))
if (!charCounter.TryAdd(openingChar, 1))
{
charCounter[openingChar] = 0;
charCounter[openingChar]++;
}
charCounter[openingChar]++;
}
}
else

View File

@@ -0,0 +1,23 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
#if !(NETSTANDARD2_1_OR_GREATER || NET)
namespace System.Collections.Generic;
internal static class DictionaryExtensions
{
public static bool TryAdd<TKey, TValue>(this Dictionary<TKey, TValue> dictionary, TKey key, TValue value) where TKey : notnull
{
if (!dictionary.ContainsKey(key))
{
dictionary[key] = value;
return true;
}
return false;
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,14 @@
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
#if NET462 || NETSTANDARD2_0
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace System;
internal static class SpanExtensions
{
#if NET462 || NETSTANDARD2_0
public static bool StartsWith(this ReadOnlySpan<char> span, string prefix, StringComparison comparisonType)
{
Debug.Assert(comparisonType is StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase);
@@ -18,6 +18,15 @@ internal static class SpanExtensions
span.Length >= prefix.Length &&
span.Slice(0, prefix.Length).Equals(prefix.AsSpan(), comparisonType);
}
}
#endif
#endif
#if !NET9_0_OR_GREATER
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool StartsWith(this ReadOnlySpan<char> span, char c)
{
return span.Length > 0 && span[0] == c;
}
#endif
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
// Based on https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Text/UnicodeDebug.cs
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#if !NET
// Used only by Rune as for now
using System.Diagnostics;
namespace System.Text;
internal static class UnicodeDebug
{
[Conditional("DEBUG")]
internal static void AssertIsValidCodePoint(uint codePoint)
{
if (!UnicodeUtility.IsValidCodePoint(codePoint))
{
Debug.Fail($"The value {ToHexString(codePoint)} is not a valid Unicode code point.");
}
}
[Conditional("DEBUG")]
internal static void AssertIsHighSurrogateCodePoint(uint codePoint)
{
if (!UnicodeUtility.IsHighSurrogateCodePoint(codePoint))
{
Debug.Fail($"The value {ToHexString(codePoint)} is not a valid UTF-16 high surrogate code point.");
}
}
[Conditional("DEBUG")]
internal static void AssertIsLowSurrogateCodePoint(uint codePoint)
{
if (!UnicodeUtility.IsLowSurrogateCodePoint(codePoint))
{
Debug.Fail($"The value {ToHexString(codePoint)} is not a valid UTF-16 low surrogate code point.");
}
}
[Conditional("DEBUG")]
internal static void AssertIsValidScalar(uint scalarValue)
{
if (!UnicodeUtility.IsValidUnicodeScalar(scalarValue))
{
Debug.Fail($"The value {ToHexString(scalarValue)} is not a valid Unicode scalar value.");
}
}
[Conditional("DEBUG")]
internal static void AssertIsValidSupplementaryPlaneScalar(uint scalarValue)
{
if (!UnicodeUtility.IsValidUnicodeScalar(scalarValue) || UnicodeUtility.IsBmpCodePoint(scalarValue))
{
Debug.Fail($"The value {ToHexString(scalarValue)} is not a valid supplementary plane Unicode scalar value.");
}
}
/// <summary>
/// Formats a code point as the hex string "U+XXXX".
/// </summary>
/// <remarks>
/// The input value doesn't have to be a real code point in the Unicode codespace. It can be any integer.
/// </remarks>
private static string ToHexString(uint codePoint)
{
return FormattableString.Invariant($"U+{codePoint:X4}");
}
}
#endif

View File

@@ -85,12 +85,12 @@ public abstract class RendererBase : IMarkdownRenderer
public bool IsLastInContainer { get; private set; }
/// <summary>
/// Occurs when before writing an object.
/// Occurs before writing an object.
/// </summary>
public event Action<IMarkdownRenderer, MarkdownObject>? ObjectWriteBefore;
/// <summary>
/// Occurs when after writing an object.
/// Occurs after writing an object.
/// </summary>
public event Action<IMarkdownRenderer, MarkdownObject>? ObjectWriteAfter;

View File

@@ -1,5 +1,5 @@
// Copyright (c) Alexandre Mutel. All rights reserved.
// This file is licensed under the BSD-Clause 2 license.
// This file is licensed under the BSD-Clause 2 license.
// See the license.txt file in the project root for more information.
using Markdig.Helpers;
@@ -28,7 +28,15 @@ public class ListRenderer : RoundtripObjectRenderer<ListBlock>
var bullet = listItem.SourceBullet.ToString();
var delimiter = listBlock.OrderedDelimiter;
renderer.PushIndent(new string[] { $"{bws}{bullet}{delimiter}" });
renderer.WriteChildren(listItem);
if (listItem.Count == 0)
{
renderer.Write(""); // trigger writing of indent
}
else
{
renderer.WriteChildren(listItem);
}
renderer.PopIndent();
renderer.RenderLinesAfter(listItem);
}
}

View File

@@ -92,12 +92,20 @@ public abstract class Block : MarkdownObject, IBlock
/// <summary>
/// Occurs when the process of inlines begin.
/// </summary>
public event ProcessInlineDelegate? ProcessInlinesBegin;
public event ProcessInlineDelegate? ProcessInlinesBegin
{
add => Trivia.ProcessInlinesBegin += value;
remove => _trivia?.ProcessInlinesBegin -= value;
}
/// <summary>
/// Occurs when the process of inlines ends for this instance.
/// </summary>
public event ProcessInlineDelegate? ProcessInlinesEnd;
public event ProcessInlineDelegate? ProcessInlinesEnd
{
add => Trivia.ProcessInlinesEnd += value;
remove => _trivia?.ProcessInlinesEnd -= value;
}
/// <summary>
/// Called when the process of inlines begin.
@@ -105,7 +113,13 @@ public abstract class Block : MarkdownObject, IBlock
/// <param name="state">The inline parser state.</param>
internal void OnProcessInlinesBegin(InlineProcessor state)
{
ProcessInlinesBegin?.Invoke(state, null);
if (_trivia is BlockTriviaProperties trivia)
{
trivia.ProcessInlinesBegin?.Invoke(state, null);
// Not exactly standard 'event' behavior, but these aren't expected to be called more than once.
_trivia.ProcessInlinesBegin = null;
}
}
/// <summary>
@@ -114,7 +128,13 @@ public abstract class Block : MarkdownObject, IBlock
/// <param name="state">The inline parser state.</param>
internal void OnProcessInlinesEnd(InlineProcessor state)
{
ProcessInlinesEnd?.Invoke(state, null);
if (_trivia is BlockTriviaProperties trivia)
{
trivia.ProcessInlinesEnd?.Invoke(state, null);
// Not exactly standard 'event' behavior, but these aren't expected to be called more than once.
_trivia.ProcessInlinesEnd = null;
}
}
public void UpdateSpanEnd(int spanEnd)
@@ -156,6 +176,11 @@ public abstract class Block : MarkdownObject, IBlock
// Used by derived types to store their own TriviaProperties
public object? DerivedTriviaSlot;
// These callbacks are set on a tiny subset of blocks (usually only the main MarkdownDocument),
// so we store them in a lazily-allocated container to save memory for the majority of blocks.
public ProcessInlineDelegate? ProcessInlinesBegin;
public ProcessInlineDelegate? ProcessInlinesEnd;
public StringSlice TriviaBefore;
public StringSlice TriviaAfter;
public List<StringSlice>? LinesBefore;

View File

@@ -216,6 +216,47 @@ public abstract class Inline : MarkdownObject, IInline
return false;
}
/// <summary>
/// Determines whether there is a sibling of the specified type among root-level siblings.
/// This walks up to find the root container, then checks all siblings.
/// </summary>
/// <typeparam name="T">Type of the sibling to check</typeparam>
/// <returns><c>true</c> if a sibling of the specified type exists; <c>false</c> otherwise</returns>
public bool ContainsParentOrSiblingOfType<T>() where T : Inline
{
// First check parents (handles nested case)
if (ContainsParentOfType<T>())
{
return true;
}
// Then check siblings at root level (handles flat case)
// Find the root container
var root = Parent;
while (root?.Parent != null)
{
root = root.Parent;
}
if (root is not ContainerInline container)
{
return false;
}
// Walk siblings looking for the type
var sibling = container.FirstChild;
while (sibling != null)
{
if (sibling is T)
{
return true;
}
sibling = sibling.NextSibling;
}
return false;
}
/// <summary>
/// Iterates on parents of the specified type.
/// </summary>

View File

@@ -40,10 +40,7 @@ public class LinkReferenceDefinitionGroup : ContainerBlock
if (!Contains(link))
{
Add(link);
if (!Links.ContainsKey(label))
{
Links[label] = link;
}
Links.TryAdd(label, link);
}
}

View File

@@ -5,6 +5,7 @@
<TargetFrameworks>net6.0;net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);NETSDK1138</NoWarn>
</PropertyGroup>
</Project>

View File

@@ -1,5 +1,5 @@
[msbuild]
project = ["markdig.sln", "./Markdig.Signed/Markdig.Signed.csproj"]
project = ["markdig.slnx", "./Markdig.Signed/Markdig.Signed.csproj"]
build_debug = true
[github]
user = "xoofx"

View File

@@ -1,79 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.32112.339
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{061866E2-005C-4D13-A338-EA464BBEC60F}"
ProjectSection(SolutionItems) = preProject
..\.editorconfig = ..\.editorconfig
..\.gitattributes = ..\.gitattributes
..\.gitignore = ..\.gitignore
..\changelog.md = ..\changelog.md
..\.github\workflows\ci.yml = ..\.github\workflows\ci.yml
global.json = global.json
..\license.txt = ..\license.txt
..\readme.md = ..\readme.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdig", "Markdig\Markdig.csproj", "{8A58A7E2-627C-4F41-933F-5AC92ADFAB48}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdig.Tests", "Markdig.Tests\Markdig.Tests.csproj", "{A0C5CB5F-5568-40AB-B945-D6D2664D51B0}"
ProjectSection(ProjectDependencies) = postProject
{8A58A7E2-627C-4F41-933F-5AC92ADFAB48} = {8A58A7E2-627C-4F41-933F-5AC92ADFAB48}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdig.Benchmarks", "Markdig.Benchmarks\Markdig.Benchmarks.csproj", "{6A19F040-BC7C-4283-873A-177B5324F1ED}"
ProjectSection(ProjectDependencies) = postProject
{8A58A7E2-627C-4F41-933F-5AC92ADFAB48} = {8A58A7E2-627C-4F41-933F-5AC92ADFAB48}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdig.WebApp", "Markdig.WebApp\Markdig.WebApp.csproj", "{3CAD9801-9976-46BE-BACA-F6D0D21FDC00}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnicodeNormDApp", "UnicodeNormDApp\UnicodeNormDApp.csproj", "{33FFC0B9-0187-44F9-9424-BB5AF5B4FB84}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "mdtoc", "mdtoc\mdtoc.csproj", "{E3CDFF0F-5BFC-42E9-BDBA-2797651900A2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SpecFileGen", "SpecFileGen\SpecFileGen.csproj", "{DB6E2ED5-7884-4E97-84AF-35E2480CF685}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8A58A7E2-627C-4F41-933F-5AC92ADFAB48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A58A7E2-627C-4F41-933F-5AC92ADFAB48}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A58A7E2-627C-4F41-933F-5AC92ADFAB48}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A58A7E2-627C-4F41-933F-5AC92ADFAB48}.Release|Any CPU.Build.0 = Release|Any CPU
{A0C5CB5F-5568-40AB-B945-D6D2664D51B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0C5CB5F-5568-40AB-B945-D6D2664D51B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0C5CB5F-5568-40AB-B945-D6D2664D51B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0C5CB5F-5568-40AB-B945-D6D2664D51B0}.Release|Any CPU.Build.0 = Release|Any CPU
{6A19F040-BC7C-4283-873A-177B5324F1ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A19F040-BC7C-4283-873A-177B5324F1ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A19F040-BC7C-4283-873A-177B5324F1ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A19F040-BC7C-4283-873A-177B5324F1ED}.Release|Any CPU.Build.0 = Release|Any CPU
{3CAD9801-9976-46BE-BACA-F6D0D21FDC00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3CAD9801-9976-46BE-BACA-F6D0D21FDC00}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3CAD9801-9976-46BE-BACA-F6D0D21FDC00}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3CAD9801-9976-46BE-BACA-F6D0D21FDC00}.Release|Any CPU.Build.0 = Release|Any CPU
{33FFC0B9-0187-44F9-9424-BB5AF5B4FB84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33FFC0B9-0187-44F9-9424-BB5AF5B4FB84}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33FFC0B9-0187-44F9-9424-BB5AF5B4FB84}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33FFC0B9-0187-44F9-9424-BB5AF5B4FB84}.Release|Any CPU.Build.0 = Release|Any CPU
{E3CDFF0F-5BFC-42E9-BDBA-2797651900A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E3CDFF0F-5BFC-42E9-BDBA-2797651900A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E3CDFF0F-5BFC-42E9-BDBA-2797651900A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3CDFF0F-5BFC-42E9-BDBA-2797651900A2}.Release|Any CPU.Build.0 = Release|Any CPU
{DB6E2ED5-7884-4E97-84AF-35E2480CF685}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DB6E2ED5-7884-4E97-84AF-35E2480CF685}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DB6E2ED5-7884-4E97-84AF-35E2480CF685}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DB6E2ED5-7884-4E97-84AF-35E2480CF685}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D068F7B6-6ACC-456C-A2E1-10EA746D956D}
EndGlobalSection
EndGlobal

View File

@@ -1,12 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/FileHeader/FileHeaderText/@EntryValue">Copyright (c) Alexandre Mutel. All rights reserved.&#xD;
This file is licensed under the BSD-Clause 2 license. &#xD;
See the license.txt file in the project root for more information.</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateInstanceFields/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=4a98fdf6_002D7d98_002D4f5a_002Dafeb_002Dea44ad98c70c/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002ECodeCleanup_002EFileHeader_002EFileHeaderSettingsMigrate/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/Environment/UnitTesting/NUnitProvider/SetCurrentDirectoryTo/@EntryValue">TestFolder</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Autolink/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Inlines/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Markdig/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

30
src/markdig.slnx Normal file
View File

@@ -0,0 +1,30 @@
<Solution>
<Folder Name="/Build/">
<File Path="../.editorconfig" />
<File Path="../.gitattributes" />
<File Path="../.gitignore" />
<File Path="../changelog.md" />
<File Path="../license.txt" />
<File Path="../readme.md" />
<File Path="Directory.Packages.props" />
<File Path="global.json" />
</Folder>
<Folder Name="/Build/GitHub Actions/">
<File Path="../.github/workflows/ci.yml" />
<File Path="../.github/workflows/test-netstandard.yml" />
</Folder>
<Project Path="Markdig.Benchmarks/Markdig.Benchmarks.csproj">
<BuildDependency Project="Markdig/Markdig.csproj" />
</Project>
<Project Path="Markdig.Fuzzing/Markdig.Fuzzing.csproj">
<BuildDependency Project="Markdig/Markdig.csproj" />
</Project>
<Project Path="Markdig.Tests/Markdig.Tests.csproj">
<BuildDependency Project="Markdig/Markdig.csproj" />
</Project>
<Project Path="Markdig.WebApp/Markdig.WebApp.csproj" />
<Project Path="Markdig/Markdig.csproj" />
<Project Path="mdtoc/mdtoc.csproj" />
<Project Path="SpecFileGen/SpecFileGen.csproj" />
<Project Path="UnicodeNormDApp/UnicodeNormDApp.csproj" />
</Solution>