22// Licensed under the BSD-Clause 2 license.
33// See license.txt file in the project root for full license information.
44
5+ using System . IO ;
56using System . Text ;
67using Markdig . Extensions . Alerts ;
78using Markdig . Extensions . Tables ;
@@ -23,6 +24,7 @@ internal sealed class MarkdownDocumentBuilder
2324 private readonly MarkdownStyle _style ;
2425 private readonly MarkdownRenderOptions _options ;
2526 private readonly Uri ? _baseUri ;
27+ private readonly string ? _localFileRootPath ;
2628 private readonly List < DocumentFlowBlock > _blocks ;
2729 private readonly int _headingSpacingBefore ;
2830 private readonly int _headingSpacingAfter ;
@@ -36,6 +38,7 @@ public MarkdownDocumentBuilder(MarkdownStyle style, MarkdownRenderOptions option
3638 _style = style ;
3739 _options = options ;
3840 _baseUri = baseUri ;
41+ _localFileRootPath = NormalizeLocalFileRootPath ( options . LocalFileRootPath ) ;
3942 _blocks = new List < DocumentFlowBlock > ( 64 ) ;
4043 _headingSpacingBefore = Math . Max ( 0 , _options . HeadingSpacingBefore ) ;
4144 _headingSpacingAfter = Math . Max ( 0 , _options . HeadingSpacingAfter ) ;
@@ -711,17 +714,179 @@ private static string ExtractInlinePlainText(ContainerInline inline)
711714 return null ;
712715 }
713716
714- if ( Uri . TryCreate ( link , UriKind . Absolute , out var absolute ) )
717+ var trimmed = link . Trim ( ) ;
718+
719+ if ( TryResolveLocalFileUri ( trimmed , out var localFileUri ) )
720+ {
721+ return localFileUri ;
722+ }
723+
724+ if ( Uri . TryCreate ( trimmed , UriKind . Absolute , out var absolute ) )
715725 {
716726 return absolute . ToString ( ) ;
717727 }
718728
719- if ( _baseUri is not null && Uri . TryCreate ( _baseUri , link , out var relative ) )
729+ if ( _baseUri is not null && Uri . TryCreate ( _baseUri , trimmed , out var relative ) )
720730 {
721731 return relative . ToString ( ) ;
722732 }
723733
724- return link ;
734+ return trimmed ;
735+ }
736+
737+ private bool TryResolveLocalFileUri ( string link , out string ? uri )
738+ {
739+ uri = null ;
740+ if ( IsFragmentOrQueryOnly ( link ) )
741+ {
742+ return false ;
743+ }
744+
745+ if ( TryResolveAbsoluteLocalFileUri ( link , out uri ) )
746+ {
747+ return true ;
748+ }
749+
750+ if ( _localFileRootPath is null )
751+ {
752+ return false ;
753+ }
754+
755+ SplitPathAndSuffix ( link , out var pathPart , out var suffix ) ;
756+ if ( string . IsNullOrWhiteSpace ( pathPart ) )
757+ {
758+ return false ;
759+ }
760+
761+ var normalizedRelativePath = NormalizeRelativeFilePath ( pathPart ) ;
762+ var combinedPath = Path . GetFullPath ( Path . Combine ( _localFileRootPath , normalizedRelativePath ) ) ;
763+ uri = CreateFileUri ( combinedPath , suffix ) ;
764+ return true ;
765+ }
766+
767+ private static string ? NormalizeLocalFileRootPath ( string ? rootPath )
768+ {
769+ if ( string . IsNullOrWhiteSpace ( rootPath ) )
770+ {
771+ return null ;
772+ }
773+
774+ return Path . GetFullPath ( rootPath ) ;
775+ }
776+
777+ private static bool TryResolveAbsoluteLocalFileUri ( string link , out string ? uri )
778+ {
779+ SplitPathAndSuffix ( link , out var pathPart , out var suffix ) ;
780+
781+ if ( IsWindowsDrivePath ( pathPart ) )
782+ {
783+ uri = CreateFileUri ( pathPart . Replace ( '/' , '\\ ' ) , suffix ) ;
784+ return true ;
785+ }
786+
787+ if ( IsWindowsUncPath ( pathPart ) )
788+ {
789+ uri = CreateFileUri ( pathPart , suffix ) ;
790+ return true ;
791+ }
792+
793+ if ( IsUnixAbsolutePath ( pathPart ) )
794+ {
795+ uri = CreateFileUri ( pathPart , suffix ) ;
796+ return true ;
797+ }
798+
799+ uri = null ;
800+ return false ;
801+ }
802+
803+ private static bool IsWindowsDrivePath ( string path )
804+ {
805+ return path . Length >= 3
806+ && IsAsciiLetter ( path [ 0 ] )
807+ && path [ 1 ] == ':'
808+ && IsDirectorySeparator ( path [ 2 ] ) ;
809+ }
810+
811+ private static bool IsWindowsUncPath ( string path )
812+ {
813+ return path . Length >= 2 && path [ 0 ] == '\\ ' && path [ 1 ] == '\\ ' ;
814+ }
815+
816+ private static bool IsUnixAbsolutePath ( string path )
817+ {
818+ return ! OperatingSystem . IsWindows ( ) && path . Length > 0 && path [ 0 ] == '/' ;
819+ }
820+
821+ private static bool IsAsciiLetter ( char c )
822+ {
823+ c = char . ToUpperInvariant ( c ) ;
824+ return c >= 'A' && c <= 'Z' ;
825+ }
826+
827+ private static bool IsDirectorySeparator ( char c ) => c is '\\ ' or '/' ;
828+
829+ private static bool IsFragmentOrQueryOnly ( string link ) => link . Length > 0 && link [ 0 ] is '#' or '?' ;
830+
831+ private static string NormalizeRelativeFilePath ( string path )
832+ {
833+ var normalized = path ;
834+ if ( Path . DirectorySeparatorChar == '\\ ' )
835+ {
836+ normalized = normalized . Replace ( '/' , '\\ ' ) ;
837+ }
838+ else
839+ {
840+ normalized = normalized . Replace ( '\\ ' , '/' ) ;
841+ }
842+
843+ return normalized . Replace ( Path . AltDirectorySeparatorChar , Path . DirectorySeparatorChar ) ;
844+ }
845+
846+ private static void SplitPathAndSuffix ( string link , out string pathPart , out string suffix )
847+ {
848+ var queryIndex = link . IndexOf ( '?' ) ;
849+ var fragmentIndex = link . IndexOf ( '#' ) ;
850+
851+ var suffixIndex = - 1 ;
852+ if ( queryIndex >= 0 && fragmentIndex >= 0 )
853+ {
854+ suffixIndex = Math . Min ( queryIndex , fragmentIndex ) ;
855+ }
856+ else if ( queryIndex >= 0 )
857+ {
858+ suffixIndex = queryIndex ;
859+ }
860+ else if ( fragmentIndex >= 0 )
861+ {
862+ suffixIndex = fragmentIndex ;
863+ }
864+
865+ if ( suffixIndex < 0 )
866+ {
867+ pathPart = link ;
868+ suffix = string . Empty ;
869+ return ;
870+ }
871+
872+ pathPart = link [ ..suffixIndex ] ;
873+ suffix = link [ suffixIndex ..] ;
874+ }
875+
876+ private static string CreateFileUri ( string path , string suffix )
877+ {
878+ var fileUri = new UriBuilder ( Uri . UriSchemeFile , string . Empty , - 1 , path ) . Uri ;
879+ if ( string . IsNullOrEmpty ( suffix ) )
880+ {
881+ return fileUri . AbsoluteUri ;
882+ }
883+
884+ if ( Uri . TryCreate ( fileUri , suffix , out var resolved ) )
885+ {
886+ return resolved . AbsoluteUri ;
887+ }
888+
889+ return string . Concat ( fileUri . AbsoluteUri , suffix ) ;
725890 }
726891
727892 private readonly record struct InlineRenderResult ( string Text , StyledRun [ ] Runs , HyperlinkRun [ ] Hyperlinks ) ;
0 commit comments