6

Goal

  • I'm trying to generate a HTML string on the backend as I want to convert it to a PDF using a HtmlToPDF library.
  • I also want to be able to easily see the generated HTML in the browser, for debugging/tweaking purposes. The page will only be public when IsDevelopment().
  • I want it to be as simple as possible.

I'm using ASP.NET Core 3.1

Approach

Razor Page

I figured I'd try the new Razor Pages as they're advertised as super easy.

@page
@using MyProject.Pages.Pdf
@model IndexModel

<h2>Test</h2>
<p>
    @Model.Message
</p>
namespace MyProject.Pages.Pdf
{
    public class IndexModel : PageModel
    {
        private readonly MyDbContext _context;

        public IndexModel(MyDbContext context)
        {
            _context = context;
        }

        public string Message { get; private set; } = "PageModel in C#";

        public async Task<IActionResult> OnGetAsync()
        {
            var count = await _context.Foos.CountAsync();

            Message += $" Server time is { DateTime.Now } and the Foo count is { count }";

            return Page();
        }
    }
}

This works in the browser - yay!

Render and get HTML string

I found Render a Razor Page to string which appears to do what I want.

But this is where the trouble start :(

Problems

First off, I find it very odd that when you find the page via _razorViewEngine.FindPage it doesn't know how to populate the ViewContext or the Model. I'd think the job of IndexModel was to populate these. I was hoping it was possible to ask ASP.NET for the IndexModel Page and that would be that.

Anyway... the next problem. In order to render the Page I have to manually create a ViewContext and I have to supply it with a Model. But the Page is the Model, and since it's a Page it isn't a simple ViewModel. It rely on DI and it expects OnGetAsync() to be executed in order to populate the Model. It's pretty much a catch-22.

I also tried fetching the View instead of the Page via _razorViewEngine.FindView but that also requires a Model, so we're back to catch-22.

Another issue. The purpose of the debug/tweaking page was to easily see what was generated. But if I have to create a Model outside IndexModel then it's no longer representative of what is actually being generated in a service somewhere.

All this have me wondering if I'm even on the right path. Or am I missing something?

Snæbjørn
  • 10,322
  • 14
  • 65
  • 124
  • check this out;https://github.com/Tyrrrz/MiniRazor it should be interesting to use to build email templates, but also any html page. I need to create a html to pdf tool too, so i will start to have your problem soon and need to find a good solution. – Johan Herstad Aug 07 '20 at 12:41

2 Answers2

10

Please refer to the following steps to render A Partial View To A String:

  1. Add an interface to the Services folder named IRazorPartialToStringRenderer.cs.

     public interface IRazorPartialToStringRenderer
     {
         Task<string> RenderPartialToStringAsync<TModel>(string partialName, TModel model);
     }
    
  2. Add a C# class file to the Services folder named RazorPartialToStringRenderer.cs with the following code:

     using System;
     using System.IO;
     using System.Linq;
     using System.Threading.Tasks;
     using Microsoft.AspNetCore.Http;
     using Microsoft.AspNetCore.Mvc;
     using Microsoft.AspNetCore.Mvc.Abstractions;
     using Microsoft.AspNetCore.Mvc.ModelBinding;
     using Microsoft.AspNetCore.Mvc.Razor;
     using Microsoft.AspNetCore.Mvc.Rendering;
     using Microsoft.AspNetCore.Mvc.ViewEngines;
     using Microsoft.AspNetCore.Mvc.ViewFeatures;
     using Microsoft.AspNetCore.Routing;
    
     namespace RazorPageSample.Services
     {
         public class RazorPartialToStringRenderer : IRazorPartialToStringRenderer
         {
             private IRazorViewEngine _viewEngine;
             private ITempDataProvider _tempDataProvider;
             private IServiceProvider _serviceProvider;
             public RazorPartialToStringRenderer(
                 IRazorViewEngine viewEngine,
                 ITempDataProvider tempDataProvider,
                 IServiceProvider serviceProvider)
             {
                 _viewEngine = viewEngine;
                 _tempDataProvider = tempDataProvider;
                 _serviceProvider = serviceProvider;
             }
             public async Task<string> RenderPartialToStringAsync<TModel>(string partialName, TModel model)
             {
                 var actionContext = GetActionContext();
                 var partial = FindView(actionContext, partialName);
                 using (var output = new StringWriter())
                 {
                     var viewContext = new ViewContext(
                         actionContext,
                         partial,
                         new ViewDataDictionary<TModel>(
                             metadataProvider: new EmptyModelMetadataProvider(),
                             modelState: new ModelStateDictionary())
                         {
                             Model = model
                         },
                         new TempDataDictionary(
                             actionContext.HttpContext,
                             _tempDataProvider),
                         output,
                         new HtmlHelperOptions()
                     );
                     await partial.RenderAsync(viewContext);
                     return output.ToString();
                 }
             }
             private IView FindView(ActionContext actionContext, string partialName)
             {
                 var getPartialResult = _viewEngine.GetView(null, partialName, false);
                 if (getPartialResult.Success)
                 {
                     return getPartialResult.View;
                 }
                 var findPartialResult = _viewEngine.FindView(actionContext, partialName, false);
                 if (findPartialResult.Success)
                 {
                     return findPartialResult.View;
                 }
                 var searchedLocations = getPartialResult.SearchedLocations.Concat(findPartialResult.SearchedLocations);
                 var errorMessage = string.Join(
                     Environment.NewLine,
                     new[] { $"Unable to find partial '{partialName}'. The following locations were searched:" }.Concat(searchedLocations)); ;
                 throw new InvalidOperationException(errorMessage);
             }
             private ActionContext GetActionContext()
             {
                 var httpContext = new DefaultHttpContext
                 {
                     RequestServices = _serviceProvider
                 };
                 return new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
             }
         }
     }
    
  3. Register the services in the ConfigureServices method in the Startup class:

     public void ConfigureServices(IServiceCollection services)
     {
         services.AddRazorPages(); 
         services.AddTransient<IRazorPartialToStringRenderer, RazorPartialToStringRenderer>();
     }
    
  4. Using the RenderPartialToStringAsync() method to render Razor Page as HTML string:

     public class ContactModel : PageModel
     {
         private readonly IRazorPartialToStringRenderer _renderer;
         public ContactModel(IRazorPartialToStringRenderer renderer)
         {
             _renderer = renderer; 
         }
         public void OnGet()
         { 
         }
         [BindProperty]
         public ContactForm ContactForm { get; set; }
         [TempData]
         public string PostResult { get; set; }
    
         public async Task<IActionResult> OnPostAsync()
         {
             var body = await _renderer.RenderPartialToStringAsync("_ContactEmailPartial", ContactForm);  //transfer model to the partial view, and then render the Partial view to string.
             PostResult = "Check your specified pickup directory";
             return RedirectToPage();
         }
     }
     public class ContactForm
     {
         public string Email { get; set; }
         public string Message { get; set; }
         public string Name { get; set; }
         public string Subject { get; set; }
         public Priority Priority { get; set; }
     }
     public enum Priority
     {
         Low, Medium, High
     }
    

The debug screenshot as below:

enter image description here

More detail steps, please check this blog Rendering A Partial View To A String.

Zhi Lv
  • 18,845
  • 1
  • 19
  • 30
  • How do you include the layout so that you get the full page html and not just the partial? I'm doing something similar to this but it only returns the html in the page without the layout code. – Bradly Bennison Aug 20 '21 at 19:40
5

I managed to crack it! I was on the wrong path after all... the solution is to use a ViewComponent. But it's still funky!

Thanks to

The Solution

Converted PageModel into ViewComponent

namespace MyProject.ViewComponents
{
    public class MyViewComponent : ViewComponent
    {
        private readonly MyDbContext _context;

        public MyViewComponent(MyDbContext context)
        {
            _context = context;
        }

        public async Task<IViewComponentResult> InvokeAsync()
        {
            var count = await _context.Foos.CountAsync();

            var message = $"Server time is { DateTime.Now } and the Foo count is { count }";

            return View<string>(message);
        }
    }
}

and the view is placed in Pages/Shared/Components/My/Default.cshtml

@model string

<h2>Test</h2>
<p>
    @Model
</p>

The Service

using System;
using System.IO;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;

public class RenderViewComponentService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ITempDataProvider _tempDataProvider;
    private readonly IViewComponentHelper _viewComponentHelper;

    public RenderViewComponentService(
        IServiceProvider serviceProvider,
        ITempDataProvider tempDataProvider,
        IViewComponentHelper viewComponentHelper
    )
    {
        _serviceProvider = serviceProvider;
        _tempDataProvider = tempDataProvider;
        _viewComponentHelper = viewComponentHelper;
    }

    public async Task<string> RenderViewComponentToStringAsync<TViewComponent>(object args)
        where TViewComponent : ViewComponent
    {
        var viewContext = GetFakeViewContext();
        (_viewComponentHelper as IViewContextAware).Contextualize(viewContext);

        var htmlContent = await _viewComponentHelper.InvokeAsync<TViewComponent>(args);
        using var stringWriter = new StringWriter();
        htmlContent.WriteTo(stringWriter, HtmlEncoder.Default);
        var html = stringWriter.ToString();

        return html;
    }

    private ViewContext GetFakeViewContext(ActionContext actionContext = null, TextWriter writer = null)
    {
        actionContext ??= GetFakeActionContext();
        var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary());
        var tempData = new TempDataDictionary(actionContext.HttpContext, _tempDataProvider);

        var viewContext = new ViewContext(
            actionContext,
            NullView.Instance,
            viewData,
            tempData,
            writer ?? TextWriter.Null,
            new HtmlHelperOptions());

        return viewContext;
    }

    private ActionContext GetFakeActionContext()
    {
        var httpContext = new DefaultHttpContext
        {
            RequestServices = _serviceProvider,
        };

        var routeData = new RouteData();
        var actionDescriptor = new ActionDescriptor();

        return new ActionContext(httpContext, routeData, actionDescriptor);
    }

    private class NullView : IView
    {
        public static readonly NullView Instance = new NullView();
        public string Path => string.Empty;
        public Task RenderAsync(ViewContext context)
        {
            if (context == null) { throw new ArgumentNullException(nameof(context)); }
            return Task.CompletedTask;
        }
    }
}

Usage

From Razor Page (the debug/tweaking page)

Note there is no code behind file

@page
@using MyProject.ViewComponents

@await Component.InvokeAsync(typeof(MyViewComponent))

With RouteData

@page "{id}"
@using MyProject.ViewComponents

@await Component.InvokeAsync(typeof(MyViewComponent), RouteData.Values["id"])

From Controller

[HttpGet]
public async Task<IActionResult> Get()
{
    var html = await _renderViewComponentService
        .RenderViewComponentToStringAsync<MyViewComponent>();

    // do something with the html

    return Ok(new { html });
}

With FromRoute

[HttpGet("{id}")]
public async Task<IActionResult> Get([FromRoute] int id)
{
    var html = await _renderViewComponentService
        .RenderViewComponentToStringAsync<MyViewComponent>(id);

    // do something with the html

    return Ok(new { html });
}

The strange

It's very unfortunate that the injected IViewComponentHelper doesn't work out of the box.

So we have do this very unintuitive thing to make it work.

(_viewComponentHelper as IViewContextAware).Contextualize(viewContext);

which causes a cascade of odd things like the fake ActionContext and ViewContext which require a TextWriter but it isn't used for ANYTHING! In fact the hole ViewContext isn't used at all. It just needs to exist :(

Also the NullView... for some reason Microsoft.AspNetCore.Mvc.ViewFeatures.NullView is Internal so we basically have to copy/paste it into our own code.

Perhaps it'll be improved in the future.

Anyway: IMO this is simpler then using IRazorViewEngine which turns up in pretty much every web search :)

halfer
  • 19,824
  • 17
  • 99
  • 186
Snæbjørn
  • 10,322
  • 14
  • 65
  • 124
  • 2
    This method is more tedious. The previous answer gives you endless opportunities for a wide range of usage. Assuming you want to pass a model right from where you called the engine in your get method how would you do that? Because for sure you will need to show values related to the current request not just some data stored in database – Tavershima Aug 07 '20 at 14:44
  • @Tavershima `InvokeAsync()` can take any number of args. So you just call `RenderViewComponentToStringAsync(int arg1, string arg2, etc...);` it's pretty simple – Snæbjørn Aug 07 '20 at 15:08
  • Here's an example of calling InvokeAsync with args https://learn.microsoft.com/en-us/aspnet/core/mvc/views/view-components?view=aspnetcore-3.1#invoking-a-view-component – Snæbjørn Aug 07 '20 at 15:11
  • I added examples of passing route params from a RazorPage and Controller – Snæbjørn Aug 07 '20 at 16:25
  • 1
    You are invoking inside a page which was called your string renderer. The page you are invoking in will only work with pre-existing data stored in database. Assuming a case where there is a route parameter which you got from the request and you need to work on it, how would that data be relayed to that page in order for the invoke to use it? What I'm trying to tell you here is that that's too much engineering for this simple case – Tavershima Aug 07 '20 at 17:57
  • "which causes a cascade of odd things like the fake ActionContext and ViewContext which require a TextWriter but it isn't used for ANYTHING" This is not true. I get exception "InvalidOperationException: Could not find an IRouter associated with the ActionContext. If your application is using endpoint routing then you can get a IUrlHelperFactory with dependency injection and use it to create a UrlHelper, or use Microsoft.AspNetCore.Routing.LinkGenerator." while trying to reference another endpoint with @Url.PageLink in the view. I use this endpoint to dynamically render an image on the Pdf. – Creative Jul 16 '21 at 12:01