2016-02-24 3 views
4

Я пытаюсь преобразовать HTML в PDF с использованием iTextSharp в ASP.NET веб-приложение, которое использует как MVC, иweb forms. Элементы <img> и <a> имеют абсолютные и ссылки URL-адреса, а некоторые из элементов <img>: base64. Типичные ответы здесь на SO и результатах поиска Google используют общий HTML для PDF код с XMLWorkerHelper, который выглядит примерно так:Как использовать iText для преобразования HTML с изображениями и гиперссылками в PDF?

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 
     ); 
    } 
} 

Так с образцом HTML так:

<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> 

В результате PDF: (1) отсутствует все изображения, и (2) все гиперссылки с относительными URL-адресами разбиты и используют file URI scheme (file///XXX...) вместо того, чтобы указывать на соответствующий ct веб-сайт.

Некоторые ответы здесь на SO и другие из поиска Google рекомендуют заменять относительные URL-адреса абсолютными URL-адресами, что является совершенно приемлемым для разовых случаев. Тем не менее, глобально заменяя все атрибуты и <a href> с жестко запрограммированной строкой, неприемлемо для этого вопроса, поэтому, пожалуйста, не отправляйте ответ таким образом, потому что он будет соответственно опущен.

Ищем решение, которое работает для . Много различных веб-приложений, находящихся в условиях тестирования, разработки и производства.

+0

Ах, я не сразу понял, что это действительно ваш собственный вопрос. Хорошо, в следующий раз я лучше посмотрю. – mkl

ответ

8

Из коробки XMLWorkeronly understands absolute URIs, поэтому описанные вопросы ожидается поведение. Парсер не может автоматически выводить URI schemes или пути без дополнительной информации.

Внедрение ILinkProvider устраняет проблему с разбивкой гиперссылок, а реализация IImageProvider устраняет проблему с поврежденным изображением. Поскольку обе реализации должны выполнять URI resolution, это первый шаг. Следующий вспомогательный класс делает это, а также пытается сделать веб (ASP.NET) контекста вызовов (примеры приведены) как можно более простой:

// 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"); 
    } 
} 

Реализация ILinkProvider довольно просто теперь, когда UriHelper дает основание URI.Нам просто нужно правильно схема URI (file или 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 только требует реализации одного метода, Retrieve(string src), но Store(string src, Image img) легко - обратите внимание, рядные комментарии там и 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() { } 
} 

Основываясь на XML Worker documentation, довольно просто подключить реализации ILinkProvider и IImageProvider выше в простой класс парсера:

/* 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); 
      } 
     } 
    } 
} 

Как прокомментировал инлайн, SimpleAjaxImgFix(string xHtml) специально обрабатывает XHR that may send unclosed <img> tags, который действуетHTML, но недействителенXML что будет перерыв XMLWorker. Простое объяснение: & Реализация, как получить PDF или другие двоичные данные с XHR и iTextSharp can be found here.

Regex был использован в SimpleAjaxImgFix(string xHtml) так, что любое использование (копировать/вставить?) Код не нужно добавить еще один nuget пакет, но должен быть использован HTML анализатор как HtmlAgilityPack, так как это превращает этот :

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

в этом:

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

лишь несколько строк кода:

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

Также стоит отметить - использовать SimpleParser.Parse() выше как общий план дополнительно реализовать пользовательские ICSSResolver или ITagProcessorFactory, который explained in the documentation.

Теперь вопросы, описанные в вопросе, должны быть учтены.Вызывается из 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(); 
} 

или из Web Form, который получает HTML из server control:

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(); 

или простой HTML файл с гиперссылками и изображений в файловой системе:

<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> 

или HTML с удаленного сайта:

<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> 

Над двумя HTML фрагментах запустить из консольного приложения:

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); 
} 

довольно длинный ответ, но взглянуть на вопросы здесь SO помечено html, pdf, and itextsharp, на момент написания этого (2016-02-23) есть являются 776 результатов против 4,063 всего помечены itextsharp - это 19%.

+0

Неплохо, такой длинный ответ за такое короткое время ...;) – mkl

+0

@mkl - Ха-ха ... шел туда и обратно о публикации вопроса, а затем сам отвечал на прошлой неделе или около того. '' Быть _erernal optimist_ '' Я надеюсь, что это поможет битку _tiny_ - даже я устаю видеть версии 1.XX.XXX.XXXX, которые отделяют эту или очень похожую концепцию. ;) – kuujinbo

+0

Nice @kuujinbo, это будет отличная ссылка «close as duplicate», чтобы иметь! –

Смежные вопросы