mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(chromium): inject paint-callback polyfill when waitForExpression or waitForSelector is set
This commit is contained in:
@@ -308,6 +308,7 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
|||||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
// Note: no error wrapping because it leaks on errors we want to display to
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
// the end user.
|
// the end user.
|
||||||
|
installPaintPolyfill := options.WaitForExpression != "" || options.WaitForSelector != ""
|
||||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||||
network.Enable(),
|
network.Enable(),
|
||||||
fetch.Enable(),
|
fetch.Enable(),
|
||||||
@@ -317,6 +318,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
|
injectPaintCallbacksPolyfillActionFunc(logger, installPaintPolyfill),
|
||||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
|
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
|
||||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||||
forceExactColorsActionFunc(logger, options.PrintBackground),
|
forceExactColorsActionFunc(logger, options.PrintBackground),
|
||||||
@@ -334,6 +336,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
// Note: no error wrapping because it leaks on errors we want to display to
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
// the end user.
|
// the end user.
|
||||||
|
installPaintPolyfill := options.WaitForExpression != "" || options.WaitForSelector != ""
|
||||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||||
network.Enable(),
|
network.Enable(),
|
||||||
fetch.Enable(),
|
fetch.Enable(),
|
||||||
@@ -343,6 +346,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
|
injectPaintCallbacksPolyfillActionFunc(logger, installPaintPolyfill),
|
||||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
|
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
|
||||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
||||||
forceExactColorsActionFunc(logger, true),
|
forceExactColorsActionFunc(logger, true),
|
||||||
|
|||||||
146
pkg/modules/chromium/paint_polyfill.go
Normal file
146
pkg/modules/chromium/paint_polyfill.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// paintCallbacksPolyfill is a JavaScript shim installed before any user
|
||||||
|
// script runs. It replaces [requestAnimationFrame], [cancelAnimationFrame],
|
||||||
|
// [ResizeObserver], and [IntersectionObserver] with timer-backed
|
||||||
|
// implementations. Headless Chromium's print-emulation pipeline does not
|
||||||
|
// tick the compositor refresh driver reliably between
|
||||||
|
// "load" and [Page.printToPDF], which leaves the native rAF /
|
||||||
|
// ResizeObserver / IntersectionObserver queues permanently stalled and
|
||||||
|
// breaks charting libraries (visx, ApexCharts, and similar) that rely on
|
||||||
|
// rAF-gated measurement. The polyfill exposes the same APIs with timer
|
||||||
|
// semantics, so user scripts that schedule work on those callbacks
|
||||||
|
// receive their measurements and Gotenberg's rendered output reflects
|
||||||
|
// the page the author intended. See
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1535.
|
||||||
|
const paintCallbacksPolyfill = `
|
||||||
|
(function () {
|
||||||
|
var nextHandle = 0;
|
||||||
|
var pending = new Map();
|
||||||
|
|
||||||
|
window.requestAnimationFrame = function (callback) {
|
||||||
|
var handle = ++nextHandle;
|
||||||
|
pending.set(handle, setTimeout(function () {
|
||||||
|
pending.delete(handle);
|
||||||
|
callback(performance.now());
|
||||||
|
}, 16));
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
window.cancelAnimationFrame = function (handle) {
|
||||||
|
clearTimeout(pending.get(handle));
|
||||||
|
pending.delete(handle);
|
||||||
|
};
|
||||||
|
|
||||||
|
function PollingResizeObserver(callback) {
|
||||||
|
this._callback = callback;
|
||||||
|
this._observed = [];
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
PollingResizeObserver.prototype.observe = function (el) {
|
||||||
|
var entry = { target: el, lastW: -1, lastH: -1 };
|
||||||
|
this._observed.push(entry);
|
||||||
|
if (!this._timer) {
|
||||||
|
var self = this;
|
||||||
|
this._timer = setInterval(function () { self._tick(); }, 100);
|
||||||
|
}
|
||||||
|
this._tick();
|
||||||
|
};
|
||||||
|
PollingResizeObserver.prototype.unobserve = function (el) {
|
||||||
|
this._observed = this._observed.filter(function (e) { return e.target !== el; });
|
||||||
|
if (this._observed.length === 0 && this._timer) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
PollingResizeObserver.prototype.disconnect = function () {
|
||||||
|
this._observed = [];
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
PollingResizeObserver.prototype._tick = function () {
|
||||||
|
var changed = [];
|
||||||
|
for (var i = 0; i < this._observed.length; i++) {
|
||||||
|
var e = this._observed[i];
|
||||||
|
var w = e.target.offsetWidth;
|
||||||
|
var h = e.target.offsetHeight;
|
||||||
|
if (w !== e.lastW || h !== e.lastH) {
|
||||||
|
e.lastW = w;
|
||||||
|
e.lastH = h;
|
||||||
|
changed.push({
|
||||||
|
target: e.target,
|
||||||
|
contentRect: { width: w, height: h, top: 0, left: 0, right: w, bottom: h, x: 0, y: 0 },
|
||||||
|
borderBoxSize: [{ inlineSize: w, blockSize: h }],
|
||||||
|
contentBoxSize: [{ inlineSize: w, blockSize: h }],
|
||||||
|
devicePixelContentBoxSize: [{ inlineSize: w, blockSize: h }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.length > 0) {
|
||||||
|
try { this._callback(changed, this); } catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.ResizeObserver = PollingResizeObserver;
|
||||||
|
|
||||||
|
function ImmediateIntersectionObserver(callback) {
|
||||||
|
this._callback = callback;
|
||||||
|
this._observed = [];
|
||||||
|
}
|
||||||
|
ImmediateIntersectionObserver.prototype.observe = function (el) {
|
||||||
|
this._observed.push(el);
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function () {
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var entry = {
|
||||||
|
target: el,
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
boundingClientRect: rect,
|
||||||
|
intersectionRect: rect,
|
||||||
|
rootBounds: null,
|
||||||
|
time: performance.now(),
|
||||||
|
};
|
||||||
|
try { self._callback([entry], self); } catch (err) { console.error(err); }
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
ImmediateIntersectionObserver.prototype.unobserve = function (el) {
|
||||||
|
this._observed = this._observed.filter(function (x) { return x !== el; });
|
||||||
|
};
|
||||||
|
ImmediateIntersectionObserver.prototype.disconnect = function () {
|
||||||
|
this._observed = [];
|
||||||
|
};
|
||||||
|
ImmediateIntersectionObserver.prototype.takeRecords = function () { return []; };
|
||||||
|
window.IntersectionObserver = ImmediateIntersectionObserver;
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
// injectPaintCallbacksPolyfillActionFunc installs the
|
||||||
|
// [paintCallbacksPolyfill] via [page.AddScriptToEvaluateOnNewDocument]
|
||||||
|
// so that it runs before any user script on the navigated page. Callers
|
||||||
|
// set install=false to skip the shim when no readiness signal gates the
|
||||||
|
// conversion, which preserves the native implementations for pages that
|
||||||
|
// do not require it.
|
||||||
|
func injectPaintCallbacksPolyfillActionFunc(logger *slog.Logger, install bool) chromedp.ActionFunc {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if !install {
|
||||||
|
logger.DebugContext(ctx, "paint-callbacks polyfill not requested")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.DebugContext(ctx, "inject paint-callbacks polyfill")
|
||||||
|
_, err := page.AddScriptToEvaluateOnNewDocument(paintCallbacksPolyfill).Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inject paint-callbacks polyfill: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,39 @@ Feature: /forms/chromium/convert/html
|
|||||||
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/html (paint-callback polyfill fires rAF / ResizeObserver / IntersectionObserver when waitForExpression is set)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/paint-callbacks-html/index.html | file |
|
||||||
|
| waitForExpression | !!document.body.getAttribute('data-pdf-ready') | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
raf-fired
|
||||||
|
"""
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
ro-fired
|
||||||
|
"""
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
io-fired
|
||||||
|
"""
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/html (paint-callback polyfill skipped without a readiness signal)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| LOG_LEVEL | debug |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page-1-html/index.html | file |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| paint-callbacks polyfill not requested |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Wait For Selector)
|
Scenario: POST /forms/chromium/convert/html (Wait For Selector)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
46
test/integration/testdata/paint-callbacks-html/index.html
vendored
Normal file
46
test/integration/testdata/paint-callbacks-html/index.html
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Paint-driven callbacks</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
#target {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p id="raf">raf-pending</p>
|
||||||
|
<p id="ro">ro-pending</p>
|
||||||
|
<p id="io">io-pending</p>
|
||||||
|
<div id="target">target</div>
|
||||||
|
<script>
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
document.getElementById("raf").textContent = "raf-fired";
|
||||||
|
});
|
||||||
|
|
||||||
|
var target = document.getElementById("target");
|
||||||
|
|
||||||
|
new ResizeObserver(function () {
|
||||||
|
document.getElementById("ro").textContent = "ro-fired";
|
||||||
|
}).observe(target);
|
||||||
|
|
||||||
|
new IntersectionObserver(function () {
|
||||||
|
document.getElementById("io").textContent = "io-fired";
|
||||||
|
}).observe(target);
|
||||||
|
|
||||||
|
// Signal Gotenberg to print after 2 s. Long enough for rAF / RO / IO
|
||||||
|
// to have fired when the polyfill is active; short enough to keep
|
||||||
|
// the test fast.
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.setAttribute("data-pdf-ready", "true");
|
||||||
|
}, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user