feat(chromium): inject paint-callback polyfill when waitForExpression or waitForSelector is set

This commit is contained in:
Julien Neuhart
2026-04-24 12:14:52 +02:00
parent 8f711b0f99
commit 1c0ff24c4b
4 changed files with 229 additions and 0 deletions

View File

@@ -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),

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

View File

@@ -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):

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