Comment utiliser iText pour convertir du HTML avec des images et des hyperliens en PDF?

html-agility-pack html-parsing itext pdf xmlworker

Question

J'essaie de convertir du HTML au PDF aide de iTextSharp dans une application Web ASP.NET qui utilise à la fois MVC et des formulaires Web . Les éléments <img> et <a> ont des URL absolues et relatives , et certains des éléments <img> sont en base64 . Les réponses typiques ici à SO et les résultats de recherche Google utilisent un HTML générique en PDF avec XMLWorkerHelper qui ressemble à ceci:

using (var stringReader = new StringReader(xHtml))
{
    using (Document document = new Document())
    {
        PdfWriter writer = PdfWriter.GetInstance(document, stream);
        document.Open();
        XMLWorkerHelper.GetInstance().ParseXHtml(
            writer, document, stringReader
        );
    }
}

Donc, avec un exemple HTML comme ceci:

<div>
    <h3>HTML Works, but Broken in Converted PDF</h3>
    <div>Relative local <img>: <img src='./../content/images/kuujinbo_320-30.gif' /></div>
    <div>
        Base64 <img>:
        <img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' />
    </div>
    <div><a href='/somePage.html'>Relative local hyperlink, broken in PDF</a></div>
<div>

Le fichier PDF résultant: (1) manque toutes les images et (2) tous les hyperliens avec des URL relatives sont rompus et utilisent un schéma d'URI de file///XXX... ( file///XXX... ) au lieu de pointer vers le site Web approprié .

Certaines réponses de SO et d’autres de Google Search recommandent de remplacer les URL relatives par des URL absolues, ce qui est parfaitement acceptable pour les cas uniques. Cependant, remplacer globalement tous les attributs <img src> et <a href> par une chaîne codée en dur est inacceptable pour cette question. Veuillez donc ne pas publier une réponse comme celle-ci, car elle sera donc rejetée.

Je recherche une solution qui fonctionne pour de nombreuses applications Web différentes résidant dans des environnements de test, de développement et de production.

Réponse acceptée

XMLWorker , XMLWorker ne comprend que les URI absolus . Les problèmes décrits sont donc le comportement attendu . L'analyseur ne peut pas déduire automatiquement des schémas d'URI ou des chemins sans informations supplémentaires.

L'implémentation d'un ILinkProvider corrige le problème de lien hypertexte rompu et l'implémentation d'un IImageProvider corrige le problème d'image cassée. Étant donné que les deux implémentations doivent effectuer la résolution URI , c'est la première étape. La classe d'assistance suivante fait cela et essaie également de rendre les appels de contexte Web ( ASP.NET ) (les exemples suivent) aussi simples que possible:

// resolve URIs for LinkProvider & ImageProvider
public class UriHelper
{
    /* IsLocal; when running in web context:
     * [1] give LinkProvider http[s] scheme; see CreateBase(string baseUri)
     * [2] give ImageProvider relative path starting with '/' - see:
     *     Join(string relativeUri)
     */
    public bool IsLocal { get; set; }
    public HttpContext HttpContext { get; private set; }
    public Uri BaseUri { get; private set; }

    public UriHelper(string baseUri) : this(baseUri, true) {}
    public UriHelper(string baseUri, bool isLocal)
    {
        IsLocal = isLocal;
        HttpContext = HttpContext.Current;
        BaseUri = CreateBase(baseUri);
    }

    /* get URI for IImageProvider to instantiate iTextSharp.text.Image for 
     * each <img> element in the HTML.
     */
    public string Combine(string relativeUri)
    {
        /* when running in a web context, the HTML is coming from a MVC view 
         * or web form, so convert the incoming URI to a **local** path
         */
        if (HttpContext != null && !BaseUri.IsAbsoluteUri && IsLocal)
        {
            return HttpContext.Server.MapPath(
                // Combine() checks directory traversal exploits
                VirtualPathUtility.Combine(BaseUri.ToString(), relativeUri)
            );
        }
        return BaseUri.Scheme == Uri.UriSchemeFile 
            ? Path.Combine(BaseUri.LocalPath, relativeUri)
            // for this example we're assuming URI.Scheme is http[s]
            : new Uri(BaseUri, relativeUri).AbsoluteUri;
    }

    private Uri CreateBase(string baseUri)
    {
        if (HttpContext != null)
        {   // running on a web server; need to update original value  
            var req = HttpContext.Request;
            baseUri = IsLocal
                // IImageProvider; absolute virtual path (starts with '/')
                // used to convert to local file system path. see:
                // Combine(string relativeUri)
                ? req.ApplicationPath
                // ILinkProvider; absolute http[s] URI scheme
                : req.Url.GetLeftPart(UriPartial.Authority)
                    + HttpContext.Request.ApplicationPath;
        }

        Uri uri;
        if (Uri.TryCreate(baseUri, UriKind.RelativeOrAbsolute, out uri)) return uri;

        throw new InvalidOperationException("cannot create a valid BaseUri");
    }
}

Implémenter ILinkProvider est assez simple maintenant que UriHelper donne l'URI de base. Nous avons juste besoin du schéma d'URI correct ( file ou http[s] ):

// make hyperlinks with relative URLs absolute
public class LinkProvider : ILinkProvider
{
    // rfc1738 - file URI scheme section 3.10
    public const char SEPARATOR = '/';
    public string BaseUrl { get; private set; }

    public LinkProvider(UriHelper uriHelper)
    {
        var uri = uriHelper.BaseUri;
        /* simplified implementation that only takes into account:
         * Uri.UriSchemeFile || Uri.UriSchemeHttp || Uri.UriSchemeHttps
         */
        BaseUrl = uri.Scheme == Uri.UriSchemeFile
            // need trailing separator or file paths break
            ? uri.AbsoluteUri.TrimEnd(SEPARATOR) + SEPARATOR
            // assumes Uri.UriSchemeHttp || Uri.UriSchemeHttps
            : BaseUrl = uri.AbsoluteUri;
    }

    public string GetLinkRoot()
    {
        return BaseUrl;
    }
}

IImageProvider ne nécessite que l’implémentation d’une seule méthode, Retrieve(string src) , mais Store(string src, Image img) est simple - notez les commentaires en-ligne ici et pour GetImageRootPath() :

// handle <img> elements in HTML  
public class ImageProvider : IImageProvider
{
    private UriHelper _uriHelper;
    // see Store(string src, Image img)
    private Dictionary<string, Image> _imageCache = 
        new Dictionary<string, Image>();

    public virtual float ScalePercent { get; set; }
    public virtual Regex Base64 { get; set; }

    public ImageProvider(UriHelper uriHelper) : this(uriHelper, 67f) { }
    //              hard-coded based on general past experience ^^^
    // but call the overload to supply your own
    public ImageProvider(UriHelper uriHelper, float scalePercent)
    {
        _uriHelper = uriHelper;
        ScalePercent = scalePercent;
        Base64 = new Regex( // rfc2045, section 6.8 (alphabet/padding)
            @"^data:image/[^;]+;base64,(?<data>[a-z0-9+/]+={0,2})$",
            RegexOptions.Compiled | RegexOptions.IgnoreCase
        );
    }

    public virtual Image ScaleImage(Image img)
    {
        img.ScalePercent(ScalePercent);
        return img;
    }

    public virtual Image Retrieve(string src)
    {
        if (_imageCache.ContainsKey(src)) return _imageCache[src];

        try
        {
            if (Regex.IsMatch(src, "^https?://", RegexOptions.IgnoreCase))
            {
                return ScaleImage(Image.GetInstance(src));
            }

            Match match;
            if ((match = Base64.Match(src)).Length > 0)
            {
                return ScaleImage(Image.GetInstance(
                    Convert.FromBase64String(match.Groups["data"].Value)
                ));
            }

            var imgPath = _uriHelper.Combine(src);
            return ScaleImage(Image.GetInstance(imgPath));
        }
        // not implemented to keep the SO answer (relatively) short
        catch (BadElementException ex) { return null; }
        catch (IOException ex) { return null; }
        catch (Exception ex) { return null; }
    }

    /*
     * always called after Retrieve(string src):
     * [1] cache any duplicate <img> in the HTML source so the image bytes
     *     are only written to the PDF **once**, which reduces the 
     *     resulting file size.
     * [2] the cache can also **potentially** save network IO if you're
     *     running the parser in a loop, since Image.GetInstance() creates
     *     a WebRequest when an image resides on a remote server. couldn't
     *     find a CachePolicy in the source code
     */
    public virtual void Store(string src, Image img)
    {
        if (!_imageCache.ContainsKey(src)) _imageCache.Add(src, img);
    }

    /* XMLWorker documentation for ImageProvider recommends implementing
     * GetImageRootPath():
     * 
     * http://demo.itextsupport.com/xmlworker/itextdoc/flatsite.html#itextdoc-menu-10
     * 
     * but a quick run through the debugger never hits the breakpoint, so 
     * not sure if I'm missing something, or something has changed internally 
     * with XMLWorker....
     */
    public virtual string GetImageRootPath() { return null; }
    public virtual void Reset() { }
}

Basé sur la documentation de XML Worker, il est assez simple de ILinkProvider les implémentations de ILinkProvider et IImageProvider ci-dessus à une simple classe d'analyseur:

/* a simple parser that uses XMLWorker and XMLParser to handle converting 
 * (most) images and hyperlinks internally
 */
public class SimpleParser
{
    public virtual ILinkProvider LinkProvider { get; set; }
    public virtual IImageProvider ImageProvider { get; set; }

    public virtual HtmlPipelineContext HtmlPipelineContext { get; set; }
    public virtual ITagProcessorFactory TagProcessorFactory { get; set; }
    public virtual ICSSResolver CssResolver { get; set; }

    /* overloads simplfied to keep SO answer (relatively) short. if needed
     * set LinkProvider/ImageProvider after instantiating SimpleParser()
     * to override the defaults (e.g. ImageProvider.ScalePercent)
     */
    public SimpleParser() : this(null) { }
    public SimpleParser(string baseUri)
    {
        LinkProvider = new LinkProvider(new UriHelper(baseUri, false));
        ImageProvider = new ImageProvider(new UriHelper(baseUri, true));

        HtmlPipelineContext = new HtmlPipelineContext(null);

        // another story altogether, and not implemented for simplicity 
        TagProcessorFactory = Tags.GetHtmlTagProcessorFactory();
        CssResolver = XMLWorkerHelper.GetInstance().GetDefaultCssResolver(true);
    }

    /*
     * when sending XHR via any of the popular JavaScript frameworks,
     * <img> tags are **NOT** always closed, which results in the 
     * infamous iTextSharp.tool.xml.exceptions.RuntimeWorkerException:
     * 'Invalid nested tag a found, expected closing tag img.' a simple
     * workaround.
     */
    public virtual string SimpleAjaxImgFix(string xHtml)
    {
        return Regex.Replace(
            xHtml,
            "(?<image><img[^>]+)(?<=[^/])>",
            new MatchEvaluator(match => match.Groups["image"].Value + " />"),
            RegexOptions.IgnoreCase | RegexOptions.Multiline
        );
    }

    public virtual void Parse(Stream stream, string xHtml)
    {
        xHtml = SimpleAjaxImgFix(xHtml);

        using (var stringReader = new StringReader(xHtml))
        {
            using (Document document = new Document())
            {
                PdfWriter writer = PdfWriter.GetInstance(document, stream);
                document.Open();

                HtmlPipelineContext
                    .SetTagFactory(Tags.GetHtmlTagProcessorFactory())
                    .SetLinkProvider(LinkProvider)
                    .SetImageProvider(ImageProvider)
                ;
                var pdfWriterPipeline = new PdfWriterPipeline(document, writer);
                var htmlPipeline = new HtmlPipeline(HtmlPipelineContext, pdfWriterPipeline);
                var cssResolverPipeline = new CssResolverPipeline(CssResolver, htmlPipeline);

                XMLWorker worker = new XMLWorker(cssResolverPipeline, true);
                XMLParser parser = new XMLParser(worker);
                parser.Parse(stringReader);
            }
        }
    }
}

En ligne a commenté, SimpleAjaxImgFix(string xHtml) traite spécifiquement XHR qui peuvent envoyer unclosed <img> balises , ce qui est valide HTML , mais invalide XML qui brisera XMLWorker . Une explication simple et la mise en œuvre de la façon de recevoir un PDF ou d' autres données binaires avec XHR et iTextSharp peuvent être trouvés ici .

Un Regex a été utilisé dans SimpleAjaxImgFix(string xHtml) afin que toute personne utilisant le code n'a pas besoin d'ajouter une autre (copier / coller?) nuget package, mais un HTML analyseur comme HtmlAgilityPack doit être utilisé, car il est tourne ceci:

<div><img src='a.gif'><br><hr></div>

dans ceci:

<div><img src='a.gif' /><br /><hr /></div>

avec seulement quelques lignes de code:

var hDocument = new HtmlDocument()
{
    OptionWriteEmptyNodes = true,
    OptionAutoCloseOnEnd = true
};
hDocument.LoadHtml("<div><img src='a.gif'><br><hr></div>");
var closedTags  = hDocument.DocumentNode.WriteTo();

A noter également - utiliser SimpleParser.Parse() ci - dessus comme un plan général à mettre en œuvre en outre une coutume ICSSResolver ou ITagProcessorFactory , ce qui est expliqué dans la documentation .

Maintenant, les problèmes décrits dans la question devraient être pris en charge. Appelé à partir d'une MVC Action Method :

[HttpPost]  // some browsers have URL length limits
[ValidateInput(false)] // or throws HttpRequestValidationException
public ActionResult Index(string xHtml)
{
    Response.ContentType = "application/pdf";
    Response.AppendHeader(
        "Content-Disposition", "attachment; filename=test.pdf"
    );
    var simpleParser = new SimpleParser();
    simpleParser.Parse(Response.OutputStream, xHtml);

    return new EmptyResult();
}

ou à partir d'un Web Form qui obtient le HTML d'un contrôle serveur :

Response.ContentType = "application/pdf";
Response.AppendHeader("Content-Disposition", "attachment; filename=test.pdf");
using (var stringWriter = new StringWriter())
{
    using (var htmlWriter = new HtmlTextWriter(stringWriter))
    {
        ConvertControlToPdf.RenderControl(htmlWriter);
    }
    var simpleParser = new SimpleParser();
    simpleParser.Parse(Response.OutputStream, stringWriter.ToString());
}
Response.End();

ou un simple fichier HTML avec des hyperliens et des images sur le système de fichiers:

<h1>HTML Page 00 on Local File System</h1>
<div>
    <div>
        Relative &lt;img&gt;: <img src='Images/alt-gravatar.png' />
    </div>
    <div>
        Hyperlink to file system HTML page: 
        <a href='file-system-html-01.html'>Page 01</a>
    </div>
</div>

ou HTML depuis un site Web distant:

<div>
    <div>
        <img width="200" alt="Wikipedia Logo"
             src="portal/wikipedia.org/assets/img/Wikipedia-logo-v2.png">
    </div>
    <div lang="en">
        <a href="https://en.wikipedia.org/">English</a>
    </div>
    <div lang="en">
        <a href="wiki/IText">iText</a>
    </div>
</div>

Au-dessus de deux extraits HTML exécutés à partir d'une application de console:

var filePaths = Path.Combine(basePath, "file-system-html-00.html");
var htmlFile = File.ReadAllText(filePaths);
var remoteUrl = Path.Combine(basePath, "wikipedia.html");
var htmlRemote = File.ReadAllText(remoteUrl);
var outputFile = Path.Combine(basePath, "filePaths.pdf");
var outputRemote = Path.Combine(basePath, "remoteUrl.pdf");

using (var stream = new FileStream(outputFile, FileMode.Create))
{
    var simpleParser = new SimpleParser(basePath);
    simpleParser.Parse(stream, htmlFile);
}
using (var stream = new FileStream(outputRemote, FileMode.Create))
{
    var simpleParser = new SimpleParser("https://wikipedia.org");
    simpleParser.Parse(stream, htmlRemote);
}

Réponse assez longue, mais en itextsharp un coup d'œil aux questions de SO étiquetées html , pdf et itextsharp , à la date de rédaction de ce document (2016-02-23), il y a 776 résultats, sur un total de 4 063 itextsharp marqués - soit 19% .


Réponse populaire

Post très utile,

J'avais un problème pour rendre les images dans mon rapport HTML en PDF avec ton post je pourrais le faire.

Je travaille avec asp.mvc 5.

Je n'ai qu'à changer cette méthode de ImageProviderClass

public virtual string GetImageRootPath() { return null; }

à

public virtual string GetImageRootPath() { HostingEnvironment.MapPath("~/Content/Images/") }

Merci!



Sous licence: CC-BY-SA with attribution
Non affilié à Stack Overflow
Est-ce KB légal? Oui, apprenez pourquoi
Sous licence: CC-BY-SA with attribution
Non affilié à Stack Overflow
Est-ce KB légal? Oui, apprenez pourquoi