mirror of https://github.com/abpframework/abp.git
39 changed files with 1171 additions and 459 deletions
@ -1,6 +1,6 @@ |
|||||
using Volo.Abp.BlobStoring; |
using Volo.Abp.BlobStoring; |
||||
|
|
||||
namespace Volo.Docs.Common; |
namespace Volo.Docs.Common.Documents; |
||||
|
|
||||
[BlobContainerName("docs-document-pdf")] |
[BlobContainerName("docs-document-pdf")] |
||||
public class DocsDocumentPdfContainer |
public class DocsDocumentPdfContainer |
||||
@ -0,0 +1,64 @@ |
|||||
|
namespace Volo.Docs.Common.Documents; |
||||
|
|
||||
|
public class DocsDocumentPdfGeneratorOptions |
||||
|
{ |
||||
|
public string HtmlLayout { get; set; } |
||||
|
public string HtmlStyles { get; set; } |
||||
|
|
||||
|
public string BaseUrl { get; set; } |
||||
|
|
||||
|
public DocsDocumentPdfGeneratorOptions() |
||||
|
{ |
||||
|
HtmlLayout = @"
|
||||
|
<!DOCTYPE html> |
||||
|
<head> |
||||
|
<meta charset='utf-8' /> |
||||
|
<style> |
||||
|
{{style-placeholder}} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
{{content-placeholder}} |
||||
|
</body> |
||||
|
</html>";
|
||||
|
|
||||
|
HtmlStyles = @"
|
||||
|
body { margin: 20px; line-height: 1.6; font-family: Arial, sans-serif;} |
||||
|
a { text-decoration: none; } |
||||
|
.page { |
||||
|
page-break-after: always; |
||||
|
margin-bottom: 30px; |
||||
|
padding: 15px; |
||||
|
} |
||||
|
img { |
||||
|
max-width:100%; |
||||
|
height: auto; |
||||
|
display: block; |
||||
|
} |
||||
|
code, pre { |
||||
|
background: #f8fafc; |
||||
|
white-space: pre-wrap; |
||||
|
word-wrap: break-word; |
||||
|
} |
||||
|
pre code { padding: 0; border: none; } |
||||
|
code { |
||||
|
padding: 2px 6px; |
||||
|
border-radius: 4px; |
||||
|
border: 1px solid #e2e8f0; |
||||
|
font-family: Consolas, monospace; |
||||
|
} |
||||
|
pre { |
||||
|
position: relative; |
||||
|
padding: 16px; |
||||
|
padding-top: calc(16px * 2.5); |
||||
|
border-radius: 8px; |
||||
|
border: 1px solid #e2e8f0; |
||||
|
} |
||||
|
blockquote { |
||||
|
border-left: 4px solid #e2e8f0; |
||||
|
margin: 20px 0; |
||||
|
padding: 10px 20px; |
||||
|
background: #f8fafc; |
||||
|
}";
|
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,419 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Text; |
||||
|
using System.Text.RegularExpressions; |
||||
|
using System.Threading.Tasks; |
||||
|
using System.Web; |
||||
|
using iText.Html2pdf; |
||||
|
using iText.Kernel.Pdf; |
||||
|
using iText.Kernel.Pdf.Action; |
||||
|
using Markdig; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Abp.Content; |
||||
|
using Volo.Docs.Documents; |
||||
|
using Volo.Docs.Documents.Rendering; |
||||
|
using Volo.Docs.Projects; |
||||
|
using Volo.Extensions; |
||||
|
using ITextDocument = iText.Layout.Document; |
||||
|
|
||||
|
namespace Volo.Docs.Common.Documents; |
||||
|
|
||||
|
public class DocumentPdfGenerator : IDocumentPdfGenerator |
||||
|
{ |
||||
|
protected IDocumentSourceFactory DocumentStoreFactory { get; } |
||||
|
protected IDocumentRepository DocumentRepository { get; } |
||||
|
protected IDocumentSectionRenderer DocumentSectionRenderer { get; } |
||||
|
protected IOptions<DocsDocumentPdfGeneratorOptions> Options { get; } |
||||
|
protected ILogger<DocumentPdfGenerator> Logger { get; set; } |
||||
|
protected IDocumentSource DocumentSource { get; set; } |
||||
|
protected DocumentParams DocumentParams { get; set; } |
||||
|
protected List<PdfDocumentNode> AllPdfDocumentNodes { get; set; } = new(); |
||||
|
protected MarkdownPipeline MarkdownPipeline { get; } = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); |
||||
|
|
||||
|
public DocumentPdfGenerator( |
||||
|
IDocumentSourceFactory documentStoreFactory, |
||||
|
IDocumentRepository documentRepository, |
||||
|
IOptions<DocsDocumentPdfGeneratorOptions> options, |
||||
|
IDocumentSectionRenderer documentSectionRenderer) |
||||
|
{ |
||||
|
DocumentStoreFactory = documentStoreFactory; |
||||
|
DocumentRepository = documentRepository; |
||||
|
Options = options; |
||||
|
DocumentSectionRenderer = documentSectionRenderer; |
||||
|
Logger = NullLogger<DocumentPdfGenerator>.Instance; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<IRemoteStreamContent> GenerateAsync(Project project, string version, string languageCode) |
||||
|
{ |
||||
|
DocumentSource = DocumentStoreFactory.Create(project.DocumentStoreType); |
||||
|
DocumentParams = await GetDocumentParamsAsync(project, version, languageCode); |
||||
|
var navigation = await GetNavigationAsync(project, version, languageCode); |
||||
|
|
||||
|
await SetAllPdfDocumentNodesAsync(navigation.Items, project, version, languageCode); |
||||
|
var htmlContent = await ConvertDocumentsToHtmlAsync(AllPdfDocumentNodes); |
||||
|
|
||||
|
var pdfStream = await GeneratePdfAsync(htmlContent); |
||||
|
|
||||
|
return new RemoteStreamContent(pdfStream, CalculateDocumentPdfFileName(project.ShortName, version, languageCode), "application/pdf"); |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<string> ConvertDocumentsToHtmlAsync(List<PdfDocumentNode> pdfDocumentNodes) |
||||
|
{ |
||||
|
var contentBuilder = new StringBuilder(); |
||||
|
|
||||
|
foreach (var pdfDocumentNode in pdfDocumentNodes) |
||||
|
{ |
||||
|
if (pdfDocumentNode.Document != null) |
||||
|
{ |
||||
|
var renderedDocument = await RenderDocumentAsync(pdfDocumentNode); |
||||
|
renderedDocument = NormalizeHtmlContent(pdfDocumentNode, Markdown.ToHtml(renderedDocument, MarkdownPipeline)); |
||||
|
contentBuilder.AppendLine(renderedDocument); |
||||
|
} |
||||
|
|
||||
|
if (pdfDocumentNode.HasChildren) |
||||
|
{ |
||||
|
contentBuilder.AppendLine(await ConvertDocumentsToHtmlAsync(pdfDocumentNode.Children)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return contentBuilder.ToString(); |
||||
|
} |
||||
|
|
||||
|
protected virtual Task<MemoryStream> GeneratePdfAsync(string htmlContent) |
||||
|
{ |
||||
|
var pdfStream = new MemoryStream(); |
||||
|
var pdfWrite = new PdfWriter(pdfStream); |
||||
|
var pdfDocument = new PdfDocument(pdfWrite); |
||||
|
var textDocument = new ITextDocument(pdfDocument); |
||||
|
pdfWrite.SetCloseStream(false); |
||||
|
|
||||
|
var htmlBuilder = new StringBuilder(); |
||||
|
htmlBuilder.Append(Options.Value.HtmlLayout); |
||||
|
htmlBuilder.Replace("{{style-placeholder}}", Options.Value.HtmlStyles); |
||||
|
htmlBuilder.Replace("{{content-placeholder}}", htmlContent); |
||||
|
|
||||
|
var converter = new ConverterProperties(); |
||||
|
var tagWorkerFactory = new HtmlIdTagWorkerFactory(pdfDocument); |
||||
|
converter.SetTagWorkerFactory(tagWorkerFactory); |
||||
|
|
||||
|
HtmlConverter.ConvertToDocument(htmlBuilder.ToString(), pdfDocument, converter); |
||||
|
|
||||
|
tagWorkerFactory.AddNamedDestinations(); |
||||
|
var pdfOutlines = pdfDocument.GetOutlines(false); |
||||
|
BuildPdfOutlines(pdfOutlines, AllPdfDocumentNodes); |
||||
|
|
||||
|
textDocument.Close(); |
||||
|
pdfStream.Position = 0; |
||||
|
return Task.FromResult(pdfStream); |
||||
|
} |
||||
|
|
||||
|
private void BuildPdfOutlines(PdfOutline parentOutline, List<PdfDocumentNode> pdfDocumentNodes) |
||||
|
{ |
||||
|
foreach (var pdfDocumentNode in pdfDocumentNodes) |
||||
|
{ |
||||
|
if (pdfDocumentNode.IgnoreOnOutline) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var outline = parentOutline.AddOutline(pdfDocumentNode.Title); |
||||
|
if (!pdfDocumentNode.Id.IsNullOrWhiteSpace()) |
||||
|
{ |
||||
|
outline.AddAction(PdfAction.CreateGoTo(pdfDocumentNode.Id)); |
||||
|
} |
||||
|
|
||||
|
if (pdfDocumentNode.HasChildren) |
||||
|
{ |
||||
|
BuildPdfOutlines(outline, pdfDocumentNode.Children); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<string> RenderDocumentAsync(PdfDocumentNode pdfDocumentNode) |
||||
|
{ |
||||
|
var renderedDocument = await DocumentSectionRenderer.RenderAsync(pdfDocumentNode.Document.Content, pdfDocumentNode.RenderParameters); |
||||
|
if (pdfDocumentNode.RenderParameters != null) |
||||
|
{ |
||||
|
renderedDocument = SetDocumentTitle(renderedDocument, pdfDocumentNode.Title); |
||||
|
} |
||||
|
|
||||
|
return renderedDocument; |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task SetAllPdfDocumentNodesAsync( |
||||
|
List<NavigationNode> navigations, |
||||
|
Project project, |
||||
|
string version, |
||||
|
string languageCode, |
||||
|
PdfDocumentNode parentPdfDocumentNode = null) |
||||
|
{ |
||||
|
var parameterCombinationsDocuments = new List<PdfDocumentNode>(); |
||||
|
foreach (var navigation in navigations) |
||||
|
{ |
||||
|
if (navigation.IgnoreOnDownload) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
try |
||||
|
{ |
||||
|
var pdfDocumentNode = new PdfDocumentNode |
||||
|
{ |
||||
|
Title = navigation.Text, |
||||
|
IgnoreOnOutline = navigation.Path == "index.md" |
||||
|
}; |
||||
|
|
||||
|
if (!navigation.Path.IsNullOrWhiteSpace()) |
||||
|
{ |
||||
|
var path = NormalizeNavigationPath(navigation.Path); |
||||
|
var document = await GetDocumentAsync(project, path, version, languageCode); |
||||
|
var parameters = await DocumentSectionRenderer.GetAvailableParametersAsync(document.Content); |
||||
|
var parameterCombinations = GenerateAllParameterCombinations(parameters.Keys.ToList(), parameters); |
||||
|
var firstParameterCombination = parameterCombinations.FirstOrDefault(); |
||||
|
|
||||
|
pdfDocumentNode.Document = document; |
||||
|
pdfDocumentNode.Title = GetDocumentTitle(navigation.Text, document.Content, firstParameterCombination); |
||||
|
pdfDocumentNode.Id = GetDocumentId(navigation.Path, firstParameterCombination); |
||||
|
pdfDocumentNode.RenderParameters = firstParameterCombination; |
||||
|
|
||||
|
if(parameters.Count <= 1) |
||||
|
{ |
||||
|
if (parentPdfDocumentNode == null) |
||||
|
{ |
||||
|
AllPdfDocumentNodes.AddRange(parameterCombinationsDocuments); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
parentPdfDocumentNode.Children.AddRange(parameterCombinationsDocuments); |
||||
|
} |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
for (var i = 1; i < parameterCombinations.Count; i++) |
||||
|
{ |
||||
|
var parameterCombination = parameterCombinations[i]; |
||||
|
parameterCombinationsDocuments.Add(new PdfDocumentNode |
||||
|
{ |
||||
|
Document = document, |
||||
|
Title = GetDocumentTitle(navigation.Text, document.Content, parameterCombination), |
||||
|
Id = GetDocumentId(navigation.Path, parameterCombination), |
||||
|
RenderParameters = parameterCombination |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (parentPdfDocumentNode == null) |
||||
|
{ |
||||
|
AllPdfDocumentNodes.Add(pdfDocumentNode); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
parentPdfDocumentNode.Children.Add(pdfDocumentNode); |
||||
|
} |
||||
|
|
||||
|
if (navigation.HasChildItems) |
||||
|
{ |
||||
|
await SetAllPdfDocumentNodesAsync(navigation.Items, project, version, languageCode, pdfDocumentNode); |
||||
|
} |
||||
|
} |
||||
|
catch (Exception e) |
||||
|
{ |
||||
|
Logger.LogWarning(e, $"Cannot get document for the path '{navigation.Path}' in the project {project.Name}."); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected virtual string CalculateDocumentPdfFileName(string projectName, string version, string languageCode) |
||||
|
{ |
||||
|
return $"{projectName.ToLower()}-{languageCode.ToLower()}.pdf"; |
||||
|
} |
||||
|
|
||||
|
private string NormalizeNavigationPath(string path) |
||||
|
{ |
||||
|
return !path.EndsWith(".md") ? Path.Combine(path, "index.md") : path; |
||||
|
} |
||||
|
|
||||
|
private async Task<NavigationNode> GetNavigationAsync( |
||||
|
Project project, |
||||
|
string version, |
||||
|
string languageCode) |
||||
|
{ |
||||
|
var navigationDocument = await GetDocumentAsync(project, project.NavigationDocumentName, version, languageCode); |
||||
|
|
||||
|
if (!DocsJsonSerializerHelper.TryDeserialize<NavigationNode>(navigationDocument.Content, out var navigation)) |
||||
|
{ |
||||
|
throw new UserFriendlyException($"Cannot validate navigation file '{project.NavigationDocumentName}' for the project {project.Name}."); |
||||
|
} |
||||
|
|
||||
|
navigation.Items.Insert(0, new NavigationNode |
||||
|
{ |
||||
|
Text = "Index", |
||||
|
Path = "index.md" |
||||
|
}); |
||||
|
|
||||
|
return navigation; |
||||
|
} |
||||
|
|
||||
|
private async Task<DocumentParams> GetDocumentParamsAsync( |
||||
|
Project project, |
||||
|
string version, |
||||
|
string languageCode) |
||||
|
{ |
||||
|
if (project.ParametersDocumentName.IsNullOrWhiteSpace()) |
||||
|
{ |
||||
|
return new DocumentParams(); |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var documentParamsDocument = await GetDocumentAsync(project, project.ParametersDocumentName, version, languageCode); |
||||
|
|
||||
|
if (!DocsJsonSerializerHelper.TryDeserialize<DocumentParams>(documentParamsDocument.Content, out var documentParams)) |
||||
|
{ |
||||
|
throw new UserFriendlyException($"Cannot validate document params file '{project.ParametersDocumentName}' for the project {project.Name}."); |
||||
|
} |
||||
|
|
||||
|
return documentParams; |
||||
|
} |
||||
|
catch (Exception e) |
||||
|
{ |
||||
|
Logger.LogError(e, $"Cannot get document params for the project {project.Name}.", e); |
||||
|
return new DocumentParams(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async Task<Document> GetDocumentAsync( |
||||
|
Project project, |
||||
|
string documentName, |
||||
|
string version, |
||||
|
string languageCode) |
||||
|
{ |
||||
|
version = string.IsNullOrWhiteSpace(version) ? project.LatestVersionBranchName : version; |
||||
|
var document = await DocumentRepository.FindAsync(project.Id, documentName, version, languageCode); |
||||
|
|
||||
|
if (document != null) |
||||
|
{ |
||||
|
return document; |
||||
|
} |
||||
|
|
||||
|
document = await DocumentSource.GetDocumentAsync(project, documentName, languageCode, version); |
||||
|
|
||||
|
return document; |
||||
|
} |
||||
|
|
||||
|
protected virtual List<DocumentRenderParameters> GenerateAllParameterCombinations(List<string> parameterKeys, Dictionary<string, List<string>> parameters) |
||||
|
{ |
||||
|
var parameterCombinations = new List<DocumentRenderParameters>(); |
||||
|
|
||||
|
if (parameters.Count <= 0) |
||||
|
{ |
||||
|
return parameterCombinations; |
||||
|
} |
||||
|
|
||||
|
GenerateCombinations(0, new DocumentRenderParameters()); |
||||
|
return parameterCombinations; |
||||
|
|
||||
|
void GenerateCombinations(int keyIndex, DocumentRenderParameters currentCombination) |
||||
|
{ |
||||
|
if (keyIndex == parameterKeys.Count) |
||||
|
{ |
||||
|
parameterCombinations.Add(new DocumentRenderParameters(currentCombination)); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var currentKey = parameterKeys[keyIndex]; |
||||
|
foreach (var value in parameters[currentKey]) |
||||
|
{ |
||||
|
currentCombination[currentKey] = value; |
||||
|
GenerateCombinations(keyIndex + 1, currentCombination); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private string SetDocumentTitle(string content, string title) |
||||
|
{ |
||||
|
var titleLine = content.Split('\n').FirstOrDefault(x => x.TrimStart().StartsWith("#")); |
||||
|
if (titleLine == null) |
||||
|
{ |
||||
|
return content; |
||||
|
} |
||||
|
|
||||
|
var newTitle = $"# {title}"; |
||||
|
return content.Replace(titleLine, newTitle); |
||||
|
} |
||||
|
|
||||
|
private string GetDocumentTitle(string title, string content, DocumentRenderParameters parameters) |
||||
|
{ |
||||
|
if (parameters == null || parameters.Count <= 0) |
||||
|
{ |
||||
|
return title; |
||||
|
} |
||||
|
|
||||
|
var titleLine = content.Split('\n').FirstOrDefault(x => x.TrimStart().StartsWith("#")); |
||||
|
if (titleLine == null) |
||||
|
{ |
||||
|
return title; |
||||
|
} |
||||
|
|
||||
|
var paramValues = parameters.Select(x => $"{DocumentParams.Parameters.FirstOrDefault(p => p.Name == x.Key)?.DisplayName ?? x.Key}: {x.Value}").ToList(); |
||||
|
return titleLine.TrimStart('#').Trim() + $" ({string.Join(", ", paramValues)})"; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
private string GetDocumentId(string path, DocumentRenderParameters parameters) |
||||
|
{ |
||||
|
var id = path.Replace(".md",string.Empty).Replace("/","-").Replace(" ", "-").ToLower(); |
||||
|
if (parameters != null) |
||||
|
{ |
||||
|
id = $"{id}{parameters.Select(x => $"{x.Key}_{x.Value}").JoinAsString("-")}"; |
||||
|
} |
||||
|
return id; |
||||
|
} |
||||
|
|
||||
|
private string NormalizeHtmlContent(PdfDocumentNode pdfDocumentNode, string htmlContent) |
||||
|
{ |
||||
|
htmlContent = $"<div class='page' id='{pdfDocumentNode.Id}'>{htmlContent}</div>"; |
||||
|
|
||||
|
htmlContent = Regex.Replace(htmlContent, @"(<img\s+[^>]*)src=""([^""]*)""([^>]*>)", delegate (Match match) |
||||
|
{ |
||||
|
if (IsExternalLink(match.Groups[2].Value)) |
||||
|
{ |
||||
|
return match.Value; |
||||
|
} |
||||
|
|
||||
|
var rootUrl = IsExternalLink(pdfDocumentNode.Document.RawRootUrl) |
||||
|
? pdfDocumentNode.Document.RawRootUrl.EnsureEndsWith('/') |
||||
|
: Options.Value.BaseUrl.EnsureEndsWith('/') + pdfDocumentNode.Document.RawRootUrl.TrimStart('/').EnsureEndsWith('/'); |
||||
|
var newImageSource = rootUrl + |
||||
|
(pdfDocumentNode.Document.LocalDirectory.IsNullOrEmpty() ? "" : pdfDocumentNode.Document.LocalDirectory.TrimStart('/').EnsureEndsWith('/')) + |
||||
|
match.Groups[2].Value.TrimStart('/'); |
||||
|
|
||||
|
return match.Groups[1] + " src=\"" + HttpUtility.HtmlEncode(newImageSource) + "\" " + match.Groups[3]; |
||||
|
|
||||
|
}, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Multiline); |
||||
|
|
||||
|
return htmlContent; |
||||
|
} |
||||
|
|
||||
|
private bool IsExternalLink(string link) |
||||
|
{ |
||||
|
return link.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || |
||||
|
link.StartsWith("https://", StringComparison.OrdinalIgnoreCase); |
||||
|
} |
||||
|
|
||||
|
protected class PdfDocumentNode |
||||
|
{ |
||||
|
public Document Document { get; set; } |
||||
|
public string Title { get; set; } |
||||
|
public string Id { get; set; } |
||||
|
public List<PdfDocumentNode> Children { get; set; } = []; |
||||
|
public DocumentRenderParameters RenderParameters { get; set; } |
||||
|
public bool HasChildren => Children.Any(); |
||||
|
public bool IgnoreOnOutline { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -1,88 +1,28 @@ |
|||||
using System.Threading.Tasks; |
using System.Threading.Tasks; |
||||
using Volo.Abp; |
using Microsoft.AspNetCore.Authorization; |
||||
using Volo.Abp.Application.Services; |
using Volo.Abp.Application.Services; |
||||
using Volo.Abp.BlobStoring; |
|
||||
using Volo.Abp.Content; |
using Volo.Abp.Content; |
||||
using Volo.Docs.Documents; |
|
||||
using Volo.Docs.Projects; |
using Volo.Docs.Projects; |
||||
using Volo.Extensions; |
|
||||
|
|
||||
namespace Volo.Docs.Common.Documents; |
namespace Volo.Docs.Common.Documents; |
||||
|
|
||||
|
[Authorize(DocsCommonPermissions.Documents.PdfGeneration)] |
||||
public class DocumentPdfGeneratorAppService : ApplicationService, IDocumentPdfGeneratorAppService |
public class DocumentPdfGeneratorAppService : ApplicationService, IDocumentPdfGeneratorAppService |
||||
{ |
{ |
||||
protected IBlobContainer<DocsDocumentPdfContainer> BlobContainer { get; } |
private readonly IDocumentPdfGenerator _documentPdfGenerator; |
||||
protected IProjectRepository ProjectRepository { get; } |
private readonly IProjectRepository _projectRepository; |
||||
protected IDocumentSourceFactory DocumentStoreFactory { get; } |
|
||||
protected IDocumentRepository DocumentRepository { get; } |
|
||||
|
|
||||
public DocumentPdfGeneratorAppService( |
public DocumentPdfGeneratorAppService( |
||||
IBlobContainer<DocsDocumentPdfContainer> blobContainer, |
IDocumentPdfGenerator documentPdfGenerator, |
||||
IProjectRepository projectRepository, |
IProjectRepository projectRepository) |
||||
IDocumentSourceFactory documentStoreFactory, |
|
||||
IDocumentRepository documentRepository) |
|
||||
{ |
{ |
||||
BlobContainer = blobContainer; |
_documentPdfGenerator = documentPdfGenerator; |
||||
ProjectRepository = projectRepository; |
_projectRepository = projectRepository; |
||||
DocumentStoreFactory = documentStoreFactory; |
|
||||
DocumentRepository = documentRepository; |
|
||||
} |
} |
||||
|
|
||||
public virtual async Task<IRemoteStreamContent> GeneratePdfAsync(DocumentPdfGeneratorInput input) |
|
||||
{ |
|
||||
var project = await ProjectRepository.GetAsync(input.ProjectId); |
|
||||
var fileName = CalculateDocumentPdfFileName(project.Name, input.Version, input.LanguageCode); |
|
||||
var stream = await BlobContainer.GetOrNullAsync(fileName); |
|
||||
|
|
||||
if(stream != null) |
|
||||
{ |
|
||||
return new RemoteStreamContent(stream, fileName , "application/pdf"); |
|
||||
} |
|
||||
|
|
||||
var documentStore = DocumentStoreFactory.Create(project.DocumentStoreType); |
public async Task<IRemoteStreamContent> GeneratePdfAsync(DocumentPdfGeneratorInput input) |
||||
var navigation = await GetNavigationAsync(documentStore, project, input.LanguageCode, input.Version); |
|
||||
|
|
||||
|
|
||||
} |
|
||||
|
|
||||
protected virtual string CalculateDocumentPdfFileName(string projectName, string version, string languageCode) |
|
||||
{ |
|
||||
return $"{projectName.ToLower()}-{version.ToLower()}--{languageCode.ToLower()}"; |
|
||||
} |
|
||||
|
|
||||
private async Task<NavigationNode> GetNavigationAsync( |
|
||||
IDocumentSource documentStore, |
|
||||
Project project, |
|
||||
string languageCode, |
|
||||
string version) |
|
||||
{ |
{ |
||||
var navigationDocument = await GetDocumentAsync(documentStore, project, project.NavigationDocumentName, languageCode, version); |
var project = await _projectRepository.GetAsync(input.ProjectId); |
||||
|
return await _documentPdfGenerator.GenerateAsync(project, input.Version, input.LanguageCode); |
||||
if (!DocsJsonSerializerHelper.TryDeserialize<NavigationNode>(navigationDocument.Content, out var navigation)) |
|
||||
{ |
|
||||
throw new UserFriendlyException($"Cannot validate navigation file '{project.NavigationDocumentName}' for the project {project.Name}."); |
|
||||
} |
|
||||
|
|
||||
return navigation; |
|
||||
} |
|
||||
|
|
||||
private async Task<Document> GetDocumentAsync( |
|
||||
IDocumentSource documentStore, |
|
||||
Project project, |
|
||||
string documentName, |
|
||||
string languageCode, |
|
||||
string version) |
|
||||
{ |
|
||||
version = string.IsNullOrWhiteSpace(version) ? project.LatestVersionBranchName : version; |
|
||||
var document = await DocumentRepository.FindAsync(project.Id, documentName, version, languageCode); |
|
||||
|
|
||||
if (document != null) |
|
||||
{ |
|
||||
return document; |
|
||||
} |
|
||||
|
|
||||
document = await documentStore.GetDocumentAsync(project, documentName, languageCode, version); |
|
||||
|
|
||||
return document; |
|
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using iText.Html2pdf.Attach; |
||||
|
using iText.Html2pdf.Attach.Impl; |
||||
|
using iText.Kernel.Pdf; |
||||
|
using iText.Kernel.Pdf.Navigation; |
||||
|
using iText.StyledXmlParser.Node; |
||||
|
|
||||
|
namespace Volo.Docs.Common.Documents; |
||||
|
|
||||
|
public class HtmlIdTagWorkerFactory: DefaultTagWorkerFactory |
||||
|
{ |
||||
|
private readonly PdfDocument _pdfDocument; |
||||
|
private readonly Dictionary<string, int> _pageDestinations = new(); |
||||
|
|
||||
|
public HtmlIdTagWorkerFactory(PdfDocument pdfDocument) |
||||
|
{ |
||||
|
_pdfDocument = pdfDocument; |
||||
|
} |
||||
|
|
||||
|
public override ITagWorker GetCustomTagWorker(IElementNode tag, ProcessorContext context) |
||||
|
{ |
||||
|
var tagClass = tag.GetAttribute("class"); |
||||
|
if (tag.Name().Equals("div") && tagClass =="page") |
||||
|
{ |
||||
|
var id = tag.GetAttribute("id"); |
||||
|
_pageDestinations[id] = _pdfDocument.GetNumberOfPages() + 1; |
||||
|
} |
||||
|
|
||||
|
return base.GetCustomTagWorker(tag, context); |
||||
|
} |
||||
|
|
||||
|
public void AddNamedDestinations() |
||||
|
{ |
||||
|
foreach (var pageDestination in _pageDestinations) |
||||
|
{ |
||||
|
var page = _pdfDocument.GetPage(pageDestination.Value); |
||||
|
var destination = PdfExplicitDestination.CreateFit(page); |
||||
|
_pdfDocument.AddNamedDestination(pageDestination.Key, destination.GetPdfObject()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.Content; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Docs.Projects; |
||||
|
|
||||
|
namespace Volo.Docs.Common.Documents; |
||||
|
|
||||
|
public interface IDocumentPdfGenerator : ITransientDependency |
||||
|
{ |
||||
|
Task<IRemoteStreamContent> GenerateAsync(Project project, string version, string languageCode); |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<Import Project="..\..\..\..\configureawait.props" /> |
||||
|
<Import Project="..\..\..\..\common.props" /> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0</TargetFrameworks> |
||||
|
<AssemblyName>Volo.Docs.Common.HttpApi.Client</AssemblyName> |
||||
|
<PackageId>Volo.Docs.Common.HttpApi.Client</PackageId> |
||||
|
<RootNamespace /> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\Volo.Docs.Common.Application.Contracts\Volo.Docs.Common.Application.Contracts.csproj" /> |
||||
|
<ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.Http.Client\Volo.Abp.Http.Client.csproj" /> |
||||
|
|
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,18 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<Import Project="..\..\..\..\configureawait.props" /> |
||||
|
<Import Project="..\..\..\..\common.props" /> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>net9.0</TargetFramework> |
||||
|
<AssemblyName>Volo.Docs.Common.HttpApi</AssemblyName> |
||||
|
<PackageId>Volo.Docs.Common.HttpApi</PackageId> |
||||
|
<RootNamespace /> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\Volo.Docs.Common.Application.Contracts\Volo.Docs.Common.Application.Contracts.csproj" /> |
||||
|
<ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.AspNetCore.Mvc\Volo.Abp.AspNetCore.Mvc.csproj" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,35 @@ |
|||||
|
using Localization.Resources.AbpUi; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Volo.Abp.AspNetCore.Mvc; |
||||
|
using Volo.Abp.Localization; |
||||
|
using Volo.Abp.Modularity; |
||||
|
using Volo.Docs.Common; |
||||
|
using Volo.Docs.Localization; |
||||
|
|
||||
|
namespace Volo.Docs |
||||
|
{ |
||||
|
[DependsOn( |
||||
|
typeof(DocsCommonApplicationContractsModule), |
||||
|
typeof(AbpAspNetCoreMvcModule) |
||||
|
)] |
||||
|
public class DocsCommonHttpApiModule : AbpModule |
||||
|
{ |
||||
|
public override void PreConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
PreConfigure<IMvcBuilder>(mvcBuilder => |
||||
|
{ |
||||
|
mvcBuilder.AddApplicationPartIfNotExists(typeof(DocsCommonHttpApiModule).Assembly); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
Configure<AbpLocalizationOptions>(options => |
||||
|
{ |
||||
|
options.Resources |
||||
|
.Get<DocsResource>() |
||||
|
.AddBaseTypes(typeof(AbpUiResource)); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
using Volo.Abp.AspNetCore.Mvc; |
||||
|
using Volo.Docs.Localization; |
||||
|
|
||||
|
namespace Volo.Docs; |
||||
|
|
||||
|
public abstract class DocsControllerBase : AbpControllerBase |
||||
|
{ |
||||
|
protected DocsControllerBase() |
||||
|
{ |
||||
|
LocalizationResource = typeof(DocsResource); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
using Asp.Versioning; |
||||
|
using Microsoft.AspNetCore.Mvc; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Abp.Content; |
||||
|
using Volo.Docs.Common; |
||||
|
using Volo.Docs.Common.Documents; |
||||
|
|
||||
|
namespace Volo.Docs.Documents; |
||||
|
|
||||
|
[RemoteService(Name = DocsCommonRemoteServiceConsts.RemoteServiceName)] |
||||
|
[Area(DocsCommonRemoteServiceConsts.ModuleName)] |
||||
|
[ControllerName("Document")] |
||||
|
[Route("api/docs/documents")] |
||||
|
public class DocsDocumentPdfGeneratorController : DocsControllerBase, IDocumentPdfGeneratorAppService |
||||
|
{ |
||||
|
protected IDocumentPdfGeneratorAppService DocumentPdfGeneratorAppService { get; } |
||||
|
|
||||
|
public DocsDocumentPdfGeneratorController(IDocumentPdfGeneratorAppService documentPdfGeneratorAppService) |
||||
|
{ |
||||
|
DocumentPdfGeneratorAppService = documentPdfGeneratorAppService; |
||||
|
} |
||||
|
|
||||
|
[HttpGet] |
||||
|
[Route("pdf")] |
||||
|
public Task<IRemoteStreamContent> GeneratePdfAsync(DocumentPdfGeneratorInput input) |
||||
|
{ |
||||
|
return DocumentPdfGeneratorAppService.GeneratePdfAsync(input); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Text.Json.Serialization; |
||||
|
|
||||
|
namespace Volo.Docs.Documents; |
||||
|
|
||||
|
public class DocumentParams |
||||
|
{ |
||||
|
[JsonPropertyName("Parameters")] |
||||
|
public List<DocumentParameter> Parameters { get; set; } = new(); |
||||
|
|
||||
|
|
||||
|
public class DocumentParameter |
||||
|
{ |
||||
|
[JsonPropertyName("name")] |
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
[JsonPropertyName("displayName")] |
||||
|
public string DisplayName { get; set; } |
||||
|
|
||||
|
[JsonPropertyName("values")] |
||||
|
public List<Dictionary<string, string>> Values { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -1,4 +1,4 @@ |
|||||
namespace Volo.Docs.HtmlConverting |
namespace Volo.Docs.Documents.Rendering |
||||
{ |
{ |
||||
public class DocumentPartialTemplateContent |
public class DocumentPartialTemplateContent |
||||
{ |
{ |
||||
@ -0,0 +1,10 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Volo.Docs.Documents.Rendering; |
||||
|
|
||||
|
public class DocumentPartialTemplateWithValues |
||||
|
{ |
||||
|
public string Path { get; set; } |
||||
|
|
||||
|
public Dictionary<string, string> Parameters { get; set; } |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Volo.Docs.Documents.Rendering |
||||
|
{ |
||||
|
public class DocumentRenderParameters : Dictionary<string,string> |
||||
|
{ |
||||
|
public DocumentRenderParameters() |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
|
||||
|
public DocumentRenderParameters(DocumentRenderParameters renderParameters) |
||||
|
{ |
||||
|
foreach (var parameter in renderParameters) |
||||
|
{ |
||||
|
Add(parameter.Key, parameter.Value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Docs.Documents.Rendering; |
||||
|
|
||||
|
public interface IDocumentSectionRenderer : ITransientDependency |
||||
|
{ |
||||
|
Task<string> RenderAsync(string document, DocumentRenderParameters parameters = null, List<DocumentPartialTemplateContent> partialTemplates = null); |
||||
|
|
||||
|
Task<Dictionary<string, List<string>>> GetAvailableParametersAsync(string document); |
||||
|
} |
||||
@ -0,0 +1,302 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Text; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using Scriban; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Extensions; |
||||
|
|
||||
|
namespace Volo.Docs.Documents.Rendering; |
||||
|
|
||||
|
public class ScribanDocumentSectionRenderer : IDocumentSectionRenderer |
||||
|
{ |
||||
|
protected readonly static List<DocsJsonSection> DocsJsonSections = |
||||
|
[ |
||||
|
new("````json", "````"), |
||||
|
new("```json", "```") |
||||
|
]; |
||||
|
|
||||
|
protected const string DocsParam = "//[doc-params]"; |
||||
|
protected const string DocsTemplates = "//[doc-template]"; |
||||
|
protected const string DocsNav = "//[doc-nav]"; |
||||
|
|
||||
|
public ILogger<ScribanDocumentSectionRenderer> Logger { get; set; } |
||||
|
|
||||
|
public ScribanDocumentSectionRenderer() |
||||
|
{ |
||||
|
Logger = NullLogger<ScribanDocumentSectionRenderer>.Instance; |
||||
|
} |
||||
|
|
||||
|
public async Task<string> RenderAsync( |
||||
|
string document, |
||||
|
DocumentRenderParameters parameters = null, |
||||
|
List<DocumentPartialTemplateContent> partialTemplates = null) |
||||
|
{ |
||||
|
if (partialTemplates != null && partialTemplates.Any()) |
||||
|
{ |
||||
|
document = SetPartialTemplates(document, partialTemplates); |
||||
|
} |
||||
|
|
||||
|
var scribanTemplate = Template.Parse(document); |
||||
|
|
||||
|
if (parameters == null) |
||||
|
{ |
||||
|
return await scribanTemplate.RenderAsync(); |
||||
|
} |
||||
|
|
||||
|
var result = await scribanTemplate.RenderAsync(parameters); |
||||
|
|
||||
|
return RemoveOptionsJson(result, DocsParam, DocsNav); |
||||
|
} |
||||
|
|
||||
|
public Task<Dictionary<string, List<string>>> GetAvailableParametersAsync(string document) |
||||
|
{ |
||||
|
return GetSectionAsync<Dictionary<string, List<string>>>(document, DocsParam); |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<T> GetSectionAsync<T>(string document, string sectionName) where T : new() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
if (!HasJsonSection(document) || !document.Contains(sectionName)) |
||||
|
{ |
||||
|
return new T(); |
||||
|
} |
||||
|
|
||||
|
var (jsonBeginningIndex, jsonEndingIndex, insideJsonSection, _) = |
||||
|
GetJsonBeginEndIndexesAndPureJson(document, sectionName); |
||||
|
|
||||
|
if (jsonBeginningIndex < 0 || jsonEndingIndex <= 0 || string.IsNullOrWhiteSpace(insideJsonSection)) |
||||
|
{ |
||||
|
return new T(); |
||||
|
} |
||||
|
|
||||
|
var pureJson = insideJsonSection.Replace(sectionName, "").Trim(); |
||||
|
|
||||
|
if (!DocsJsonSerializerHelper.TryDeserialize<T>(pureJson, out var section)) |
||||
|
{ |
||||
|
throw new UserFriendlyException($"ERROR-20200327: Cannot validate JSON content for `{sectionName}`!"); |
||||
|
} |
||||
|
|
||||
|
return await Task.FromResult(section); |
||||
|
} |
||||
|
catch (Exception) |
||||
|
{ |
||||
|
Logger.LogWarning("Unable to parse parameters of document."); |
||||
|
return new T(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected static string RemoveOptionsJson(string document, params string[] sectionNames) |
||||
|
{ |
||||
|
foreach (var sectionName in sectionNames) |
||||
|
{ |
||||
|
var orgDocument = document; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
if (!HasJsonSection(document) || !document.Contains(sectionName)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var (jsonBeginningIndex, jsonEndingIndex, insideJsonSection, jsonSection) = |
||||
|
GetJsonBeginEndIndexesAndPureJson(document, sectionName); |
||||
|
|
||||
|
if (jsonBeginningIndex < 0 || jsonEndingIndex <= 0 || string.IsNullOrWhiteSpace(insideJsonSection)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
document = document.Remove( |
||||
|
jsonBeginningIndex - jsonSection.Opener.Length, |
||||
|
(jsonEndingIndex + jsonSection.Closer.Length) - (jsonBeginningIndex - jsonSection.Opener.Length) |
||||
|
); |
||||
|
} |
||||
|
catch (Exception) |
||||
|
{ |
||||
|
document = orgDocument; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return document; |
||||
|
} |
||||
|
|
||||
|
protected static bool HasJsonSection(string document) |
||||
|
{ |
||||
|
return DocsJsonSections.Any(section => document.Contains(section.Opener) && document.Contains(section.Closer)); |
||||
|
} |
||||
|
|
||||
|
protected static (int, int, string, DocsJsonSection) GetJsonBeginEndIndexesAndPureJson(string document, string sectionName) |
||||
|
{ |
||||
|
foreach (var section in DocsJsonSections) |
||||
|
{ |
||||
|
var (jsonBeginningIndex, jsonEndingIndex, insideJsonSection) = |
||||
|
section.GetJsonBeginEndIndexesAndPureJson(document, sectionName); |
||||
|
|
||||
|
if (jsonBeginningIndex >= 0 && jsonEndingIndex > 0 && !string.IsNullOrWhiteSpace(insideJsonSection)) |
||||
|
{ |
||||
|
return (jsonBeginningIndex, jsonEndingIndex, insideJsonSection, section); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return (-1, -1, "", null); |
||||
|
} |
||||
|
|
||||
|
protected static string SetPartialTemplates(string document, IReadOnlyCollection<DocumentPartialTemplateContent> templates) |
||||
|
{ |
||||
|
foreach (var section in DocsJsonSections) |
||||
|
{ |
||||
|
document = section.SetPartialTemplates(document, templates); |
||||
|
} |
||||
|
|
||||
|
return document; |
||||
|
} |
||||
|
|
||||
|
protected class DocsJsonSection |
||||
|
{ |
||||
|
public string Opener { get; } |
||||
|
public string Closer { get; } |
||||
|
|
||||
|
public DocsJsonSection(string opener, string closer) |
||||
|
{ |
||||
|
Opener = opener; |
||||
|
Closer = closer; |
||||
|
} |
||||
|
|
||||
|
public (int, int, string) GetJsonBeginEndIndexesAndPureJson(string document, string sectionName) |
||||
|
{ |
||||
|
var searchedIndex = 0; |
||||
|
|
||||
|
while (searchedIndex < document.Length) |
||||
|
{ |
||||
|
var jsonBeginningIndex = document.Substring(searchedIndex).IndexOf(Opener, StringComparison.Ordinal); |
||||
|
|
||||
|
if (jsonBeginningIndex < 0) |
||||
|
{ |
||||
|
return (-1, -1, ""); |
||||
|
} |
||||
|
|
||||
|
jsonBeginningIndex += Opener.Length + searchedIndex; |
||||
|
|
||||
|
var jsonEndingIndex = document.Substring(jsonBeginningIndex).IndexOf(Closer, StringComparison.Ordinal); |
||||
|
if (jsonEndingIndex < 0) |
||||
|
{ |
||||
|
return (-1, -1, ""); |
||||
|
} |
||||
|
|
||||
|
jsonEndingIndex += jsonBeginningIndex; |
||||
|
var insideJsonSection = document.Substring(jsonBeginningIndex, jsonEndingIndex - jsonBeginningIndex); |
||||
|
|
||||
|
if (insideJsonSection.IndexOf(sectionName, StringComparison.Ordinal) < 0) |
||||
|
{ |
||||
|
searchedIndex = jsonEndingIndex + Closer.Length; |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
return (jsonBeginningIndex, jsonEndingIndex, insideJsonSection); |
||||
|
} |
||||
|
|
||||
|
return (-1, -1, ""); |
||||
|
} |
||||
|
|
||||
|
public async Task<List<DocumentPartialTemplateWithValues>> GetPartialTemplatesInDocumentAsync(string documentContent) |
||||
|
{ |
||||
|
var templates = new List<DocumentPartialTemplateWithValues>(); |
||||
|
|
||||
|
while (documentContent.Contains(Opener)) |
||||
|
{ |
||||
|
var afterJsonOpener = documentContent.Substring( |
||||
|
documentContent.IndexOf(Opener, StringComparison.Ordinal) + Opener.Length |
||||
|
); |
||||
|
|
||||
|
var betweenJsonOpenerAndCloser = afterJsonOpener.Substring(0, |
||||
|
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) |
||||
|
); |
||||
|
|
||||
|
documentContent = afterJsonOpener.Substring( |
||||
|
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) + Closer.Length |
||||
|
); |
||||
|
|
||||
|
if (!betweenJsonOpenerAndCloser.Contains(DocsTemplates)) |
||||
|
{ |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var json = betweenJsonOpenerAndCloser.Substring( |
||||
|
betweenJsonOpenerAndCloser.IndexOf(DocsTemplates, StringComparison.Ordinal) + |
||||
|
DocsTemplates.Length); |
||||
|
|
||||
|
if (!DocsJsonSerializerHelper.TryDeserialize<DocumentPartialTemplateWithValues>(json, |
||||
|
out var template)) |
||||
|
{ |
||||
|
throw new UserFriendlyException( |
||||
|
$"ERROR-20200327: Cannot validate JSON content for `AvailableParameters`!"); |
||||
|
} |
||||
|
|
||||
|
templates.Add(template); |
||||
|
} |
||||
|
|
||||
|
return await Task.FromResult(templates); |
||||
|
} |
||||
|
|
||||
|
public string SetPartialTemplates(string document, IReadOnlyCollection<DocumentPartialTemplateContent> templates) |
||||
|
{ |
||||
|
var newDocument = new StringBuilder(); |
||||
|
|
||||
|
while (document.Contains(Opener)) |
||||
|
{ |
||||
|
var beforeJson = document.Substring(0, |
||||
|
document.IndexOf(Opener, StringComparison.Ordinal) + Opener.Length |
||||
|
); |
||||
|
|
||||
|
var afterJsonOpener = document.Substring( |
||||
|
document.IndexOf(Opener, StringComparison.Ordinal) + Opener.Length |
||||
|
); |
||||
|
|
||||
|
var betweenJsonOpenerAndCloser = afterJsonOpener.Substring(0, |
||||
|
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) |
||||
|
); |
||||
|
|
||||
|
if (!betweenJsonOpenerAndCloser.Contains(DocsTemplates)) |
||||
|
{ |
||||
|
document = afterJsonOpener.Substring( |
||||
|
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) + Closer.Length |
||||
|
); |
||||
|
|
||||
|
newDocument.Append(beforeJson + betweenJsonOpenerAndCloser + Closer); |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
var json = betweenJsonOpenerAndCloser.Substring( |
||||
|
betweenJsonOpenerAndCloser.IndexOf(DocsTemplates, StringComparison.Ordinal) + |
||||
|
DocsTemplates.Length |
||||
|
); |
||||
|
|
||||
|
if (DocsJsonSerializerHelper.TryDeserialize<DocumentPartialTemplateWithValues>(json, |
||||
|
out var documentPartialTemplateWithValuesDto)) |
||||
|
{ |
||||
|
var template = |
||||
|
templates.FirstOrDefault(t => t.Path == documentPartialTemplateWithValuesDto.Path); |
||||
|
|
||||
|
var beforeTemplate = document.Substring(0, |
||||
|
document.IndexOf(Opener, StringComparison.Ordinal) |
||||
|
); |
||||
|
|
||||
|
newDocument.Append(beforeTemplate + template?.Content + Closer); |
||||
|
|
||||
|
document = afterJsonOpener.Substring( |
||||
|
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) + Closer.Length |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
newDocument.Append(document); |
||||
|
|
||||
|
return newDocument.ToString(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,11 +0,0 @@ |
|||||
using System.Collections.Generic; |
|
||||
|
|
||||
namespace Volo.Docs.HtmlConverting |
|
||||
{ |
|
||||
public class DocumentPartialTemplateWithValuesDto |
|
||||
{ |
|
||||
public string Path { get; set; } |
|
||||
|
|
||||
public Dictionary<string, string> Parameters { get; set; } |
|
||||
} |
|
||||
} |
|
||||
@ -1,8 +0,0 @@ |
|||||
using System.Collections.Generic; |
|
||||
|
|
||||
namespace Volo.Docs.HtmlConverting |
|
||||
{ |
|
||||
public class DocumentRenderParameters : Dictionary<string,string> |
|
||||
{ |
|
||||
} |
|
||||
} |
|
||||
@ -1,17 +0,0 @@ |
|||||
using System.Collections.Generic; |
|
||||
using System.Threading.Tasks; |
|
||||
using Volo.Abp.DependencyInjection; |
|
||||
|
|
||||
namespace Volo.Docs.HtmlConverting |
|
||||
{ |
|
||||
public interface IDocumentSectionRenderer: ITransientDependency |
|
||||
{ |
|
||||
Task<string> RenderAsync(string doucment, DocumentRenderParameters parameters = null, List<DocumentPartialTemplateContent> partialTemplates = null); |
|
||||
|
|
||||
Task<Dictionary<string, List<string>>> GetAvailableParametersAsync(string document); |
|
||||
|
|
||||
Task<List<DocumentPartialTemplateWithValuesDto>> GetPartialTemplatesInDocumentAsync(string documentContent); |
|
||||
|
|
||||
Task<DocumentNavigationsDto> GetDocumentNavigationsAsync(string documentContent); |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,14 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Docs.Documents.Rendering; |
||||
|
|
||||
|
namespace Volo.Docs.HtmlConverting |
||||
|
{ |
||||
|
public interface IWebDocumentSectionRenderer: IDocumentSectionRenderer |
||||
|
{ |
||||
|
Task<List<DocumentPartialTemplateWithValues>> GetPartialTemplatesInDocumentAsync(string documentContent); |
||||
|
|
||||
|
Task<DocumentNavigationsDto> GetDocumentNavigationsAsync(string documentContent); |
||||
|
} |
||||
|
} |
||||
@ -1,316 +0,0 @@ |
|||||
using System; |
|
||||
using System.Collections.Generic; |
|
||||
using System.Linq; |
|
||||
using System.Text; |
|
||||
using System.Threading.Tasks; |
|
||||
using Scriban; |
|
||||
using Microsoft.Extensions.Logging; |
|
||||
using Microsoft.Extensions.Logging.Abstractions; |
|
||||
using Volo.Abp; |
|
||||
using Volo.Extensions; |
|
||||
|
|
||||
namespace Volo.Docs.HtmlConverting |
|
||||
{ |
|
||||
public class ScribanDocumentSectionRenderer : IDocumentSectionRenderer |
|
||||
{ |
|
||||
private readonly static List<DocsJsonSection> DocsJsonSections = new List<DocsJsonSection> |
|
||||
{ |
|
||||
new DocsJsonSection("````json", "````"), |
|
||||
new DocsJsonSection("```json", "```") |
|
||||
}; |
|
||||
private const string DocsParam = "//[doc-params]"; |
|
||||
private const string DocsTemplates = "//[doc-template]"; |
|
||||
private const string DocsNav = "//[doc-nav]"; |
|
||||
|
|
||||
public ILogger<ScribanDocumentSectionRenderer> Logger { get; set; } |
|
||||
|
|
||||
public ScribanDocumentSectionRenderer() |
|
||||
{ |
|
||||
Logger = NullLogger<ScribanDocumentSectionRenderer>.Instance; |
|
||||
} |
|
||||
|
|
||||
public async Task<string> RenderAsync(string document, DocumentRenderParameters parameters = null, List<DocumentPartialTemplateContent> partialTemplates = null) |
|
||||
{ |
|
||||
if (partialTemplates != null && partialTemplates.Any()) |
|
||||
{ |
|
||||
document = SetPartialTemplates(document, partialTemplates); |
|
||||
} |
|
||||
|
|
||||
var scribanTemplate = Template.Parse(document); |
|
||||
|
|
||||
if (parameters == null) |
|
||||
{ |
|
||||
return await scribanTemplate.RenderAsync(); |
|
||||
} |
|
||||
|
|
||||
var result = await scribanTemplate.RenderAsync(parameters); |
|
||||
|
|
||||
return RemoveOptionsJson(result, DocsParam, DocsNav); |
|
||||
} |
|
||||
|
|
||||
public Task<Dictionary<string, List<string>>> GetAvailableParametersAsync(string document) |
|
||||
{ |
|
||||
return GetSectionAsync<Dictionary<string, List<string>>>(document, DocsParam); |
|
||||
} |
|
||||
|
|
||||
public Task<DocumentNavigationsDto> GetDocumentNavigationsAsync(string documentContent) |
|
||||
{ |
|
||||
return GetSectionAsync<DocumentNavigationsDto>(documentContent, DocsNav); |
|
||||
} |
|
||||
|
|
||||
protected virtual async Task<T> GetSectionAsync<T>(string document, string sectionName) where T : new() |
|
||||
{ |
|
||||
try |
|
||||
{ |
|
||||
if (!HasJsonSection(document) || !document.Contains(sectionName)) |
|
||||
{ |
|
||||
return new T(); |
|
||||
} |
|
||||
|
|
||||
var (jsonBeginningIndex, jsonEndingIndex, insideJsonSection, _) = GetJsonBeginEndIndexesAndPureJson(document, sectionName); |
|
||||
|
|
||||
if (jsonBeginningIndex < 0 || jsonEndingIndex <= 0 || string.IsNullOrWhiteSpace(insideJsonSection)) |
|
||||
{ |
|
||||
return new T(); |
|
||||
} |
|
||||
|
|
||||
var pureJson = insideJsonSection.Replace(sectionName, "").Trim(); |
|
||||
|
|
||||
if (!DocsJsonSerializerHelper.TryDeserialize<T>(pureJson, out var section)) |
|
||||
{ |
|
||||
throw new UserFriendlyException($"ERROR-20200327: Cannot validate JSON content for `{sectionName}`!"); |
|
||||
} |
|
||||
|
|
||||
return await Task.FromResult(section); |
|
||||
} |
|
||||
catch (Exception) |
|
||||
{ |
|
||||
Logger.LogWarning("Unable to parse parameters of document."); |
|
||||
return new T(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private static string RemoveOptionsJson(string document, params string[] sectionNames) |
|
||||
{ |
|
||||
|
|
||||
foreach (var sectionName in sectionNames) |
|
||||
{ |
|
||||
var orgDocument = document; |
|
||||
|
|
||||
try |
|
||||
{ |
|
||||
if (!HasJsonSection(document) || !document.Contains(sectionName)) |
|
||||
{ |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
var (jsonBeginningIndex, jsonEndingIndex, insideJsonSection, jsonSection) = GetJsonBeginEndIndexesAndPureJson(document, sectionName); |
|
||||
|
|
||||
if (jsonBeginningIndex < 0 || jsonEndingIndex <= 0 || string.IsNullOrWhiteSpace(insideJsonSection)) |
|
||||
{ |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
document = document.Remove( |
|
||||
jsonBeginningIndex - jsonSection.Opener.Length, |
|
||||
(jsonEndingIndex + jsonSection.Closer.Length) - (jsonBeginningIndex - jsonSection.Opener.Length) |
|
||||
); |
|
||||
} |
|
||||
catch (Exception) |
|
||||
{ |
|
||||
document = orgDocument; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return document; |
|
||||
} |
|
||||
|
|
||||
private static bool HasJsonSection(string document) |
|
||||
{ |
|
||||
return DocsJsonSections.Any(section => document.Contains(section.Opener) && document.Contains(section.Closer)); |
|
||||
} |
|
||||
|
|
||||
private static (int, int, string, DocsJsonSection) GetJsonBeginEndIndexesAndPureJson(string document, string sectionName) |
|
||||
{ |
|
||||
foreach (var section in DocsJsonSections) |
|
||||
{ |
|
||||
var (jsonBeginningIndex, jsonEndingIndex, insideJsonSection) = section.GetJsonBeginEndIndexesAndPureJson(document, sectionName); |
|
||||
|
|
||||
if (jsonBeginningIndex >= 0 && jsonEndingIndex > 0 && !string.IsNullOrWhiteSpace(insideJsonSection)) |
|
||||
{ |
|
||||
return (jsonBeginningIndex, jsonEndingIndex, insideJsonSection, section); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return (-1, -1, "", null); |
|
||||
} |
|
||||
|
|
||||
public async Task<List<DocumentPartialTemplateWithValuesDto>> GetPartialTemplatesInDocumentAsync(string documentContent) |
|
||||
{ |
|
||||
var templates = new List<DocumentPartialTemplateWithValuesDto>(); |
|
||||
|
|
||||
foreach (var section in DocsJsonSections) |
|
||||
{ |
|
||||
templates.AddRange(await section.GetPartialTemplatesInDocumentAsync(documentContent)); |
|
||||
} |
|
||||
|
|
||||
return templates; |
|
||||
} |
|
||||
|
|
||||
private static string SetPartialTemplates(string document, IReadOnlyCollection<DocumentPartialTemplateContent> templates) |
|
||||
{ |
|
||||
foreach (var section in DocsJsonSections) |
|
||||
{ |
|
||||
document = section.SetPartialTemplates(document, templates); |
|
||||
} |
|
||||
|
|
||||
return document; |
|
||||
} |
|
||||
|
|
||||
private class DocsJsonSection |
|
||||
{ |
|
||||
public string Opener { get; } |
|
||||
public string Closer { get; } |
|
||||
|
|
||||
public DocsJsonSection(string opener, string closer) |
|
||||
{ |
|
||||
Opener = opener; |
|
||||
Closer = closer; |
|
||||
} |
|
||||
|
|
||||
public (int, int, string) GetJsonBeginEndIndexesAndPureJson(string document, string sectionName) |
|
||||
{ |
|
||||
var searchedIndex = 0; |
|
||||
|
|
||||
while (searchedIndex < document.Length) |
|
||||
{ |
|
||||
var jsonBeginningIndex = document.Substring(searchedIndex).IndexOf(Opener, StringComparison.Ordinal); |
|
||||
|
|
||||
if (jsonBeginningIndex < 0) |
|
||||
{ |
|
||||
return (-1, -1, ""); |
|
||||
} |
|
||||
|
|
||||
jsonBeginningIndex += Opener.Length + searchedIndex; |
|
||||
|
|
||||
var jsonEndingIndex = document.Substring(jsonBeginningIndex).IndexOf(Closer, StringComparison.Ordinal); |
|
||||
if (jsonEndingIndex < 0) |
|
||||
{ |
|
||||
return (-1, -1, ""); |
|
||||
} |
|
||||
|
|
||||
jsonEndingIndex += jsonBeginningIndex; |
|
||||
var insideJsonSection = document[jsonBeginningIndex..jsonEndingIndex]; |
|
||||
|
|
||||
if (insideJsonSection.IndexOf(sectionName, StringComparison.Ordinal) < 0) |
|
||||
{ |
|
||||
searchedIndex = jsonEndingIndex + Closer.Length; |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
return (jsonBeginningIndex, jsonEndingIndex, insideJsonSection); |
|
||||
} |
|
||||
|
|
||||
return (-1, -1, ""); |
|
||||
} |
|
||||
|
|
||||
public async Task<List<DocumentPartialTemplateWithValuesDto>> GetPartialTemplatesInDocumentAsync( |
|
||||
string documentContent) |
|
||||
{ |
|
||||
var templates = new List<DocumentPartialTemplateWithValuesDto>(); |
|
||||
|
|
||||
while (documentContent.Contains(Opener)) |
|
||||
{ |
|
||||
var afterJsonOpener = documentContent.Substring( |
|
||||
documentContent.IndexOf(Opener, StringComparison.Ordinal) + Opener.Length |
|
||||
); |
|
||||
|
|
||||
var betweenJsonOpenerAndCloser = afterJsonOpener.Substring(0, |
|
||||
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) |
|
||||
); |
|
||||
|
|
||||
documentContent = afterJsonOpener.Substring( |
|
||||
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) + Closer.Length |
|
||||
); |
|
||||
|
|
||||
if (!betweenJsonOpenerAndCloser.Contains(DocsTemplates)) |
|
||||
{ |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
var json = betweenJsonOpenerAndCloser.Substring( |
|
||||
betweenJsonOpenerAndCloser.IndexOf(DocsTemplates, StringComparison.Ordinal) + |
|
||||
DocsTemplates.Length); |
|
||||
|
|
||||
if (!DocsJsonSerializerHelper.TryDeserialize<DocumentPartialTemplateWithValuesDto>(json, |
|
||||
out var template)) |
|
||||
{ |
|
||||
throw new UserFriendlyException( |
|
||||
$"ERROR-20200327: Cannot validate JSON content for `AvailableParameters`!"); |
|
||||
} |
|
||||
|
|
||||
templates.Add(template); |
|
||||
} |
|
||||
|
|
||||
return await Task.FromResult(templates); |
|
||||
} |
|
||||
|
|
||||
public string SetPartialTemplates(string document, |
|
||||
IReadOnlyCollection<DocumentPartialTemplateContent> templates) |
|
||||
{ |
|
||||
var newDocument = new StringBuilder(); |
|
||||
|
|
||||
while (document.Contains(Opener)) |
|
||||
{ |
|
||||
var beforeJson = document.Substring(0, |
|
||||
document.IndexOf(Opener, StringComparison.Ordinal) + Opener.Length |
|
||||
); |
|
||||
|
|
||||
var afterJsonOpener = document.Substring( |
|
||||
document.IndexOf(Opener, StringComparison.Ordinal) + Opener.Length |
|
||||
); |
|
||||
|
|
||||
var betweenJsonOpenerAndCloser = afterJsonOpener.Substring(0, |
|
||||
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) |
|
||||
); |
|
||||
|
|
||||
if (!betweenJsonOpenerAndCloser.Contains(DocsTemplates)) |
|
||||
{ |
|
||||
document = afterJsonOpener.Substring( |
|
||||
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) + Closer.Length |
|
||||
); |
|
||||
|
|
||||
newDocument.Append(beforeJson + betweenJsonOpenerAndCloser + Closer); |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
var json = betweenJsonOpenerAndCloser.Substring( |
|
||||
betweenJsonOpenerAndCloser.IndexOf(DocsTemplates, StringComparison.Ordinal) + |
|
||||
DocsTemplates.Length |
|
||||
); |
|
||||
|
|
||||
if (DocsJsonSerializerHelper.TryDeserialize<DocumentPartialTemplateWithValuesDto>(json, |
|
||||
out var documentPartialTemplateWithValuesDto)) |
|
||||
{ |
|
||||
var template = |
|
||||
templates.FirstOrDefault(t => t.Path == documentPartialTemplateWithValuesDto.Path); |
|
||||
|
|
||||
var beforeTemplate = document.Substring(0, |
|
||||
document.IndexOf(Opener, StringComparison.Ordinal) |
|
||||
); |
|
||||
|
|
||||
newDocument.Append(beforeTemplate + template?.Content + Closer); |
|
||||
|
|
||||
document = afterJsonOpener.Substring( |
|
||||
afterJsonOpener.IndexOf(Closer, StringComparison.Ordinal) + Closer.Length |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
newDocument.Append(document); |
|
||||
|
|
||||
return newDocument.ToString(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,38 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Text; |
||||
|
using System.Threading.Tasks; |
||||
|
using Scriban; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Abp.ObjectMapping; |
||||
|
using Volo.Docs.Documents.Rendering; |
||||
|
using Volo.Extensions; |
||||
|
|
||||
|
namespace Volo.Docs.HtmlConverting |
||||
|
{ |
||||
|
public class ScribanWebDocumentSectionRenderer : ScribanDocumentSectionRenderer, IWebDocumentSectionRenderer |
||||
|
{ |
||||
|
private IObjectMapper ObjectMapper { get; set; } |
||||
|
|
||||
|
public Task<DocumentNavigationsDto> GetDocumentNavigationsAsync(string documentContent) |
||||
|
{ |
||||
|
return GetSectionAsync<DocumentNavigationsDto>(documentContent, DocsNav); |
||||
|
} |
||||
|
|
||||
|
public async Task<List<DocumentPartialTemplateWithValues>> GetPartialTemplatesInDocumentAsync( |
||||
|
string documentContent) |
||||
|
{ |
||||
|
var templates = new List<DocumentPartialTemplateWithValues>(); |
||||
|
|
||||
|
foreach (var section in DocsJsonSections) |
||||
|
{ |
||||
|
templates.AddRange(await section.GetPartialTemplatesInDocumentAsync(documentContent)); |
||||
|
} |
||||
|
|
||||
|
return templates; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue