From 1c0ff24c4b60c3dad6635b2f9412bc86e8dab71f Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 24 Apr 2026 12:14:52 +0200 Subject: [PATCH] feat(chromium): inject paint-callback polyfill when waitForExpression or waitForSelector is set --- pkg/modules/chromium/browser.go | 4 + pkg/modules/chromium/paint_polyfill.go | 146 ++++++++++++++++++ .../features/chromium_convert_html.feature | 33 ++++ .../testdata/paint-callbacks-html/index.html | 46 ++++++ 4 files changed, 229 insertions(+) create mode 100644 pkg/modules/chromium/paint_polyfill.go create mode 100644 test/integration/testdata/paint-callbacks-html/index.html diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 4eb63c1..c97890a 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -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 { // Note: no error wrapping because it leaks on errors we want to display to // the end user. + installPaintPolyfill := options.WaitForExpression != "" || options.WaitForSelector != "" return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ network.Enable(), fetch.Enable(), @@ -317,6 +318,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), setCookiesActionFunc(logger, options.Cookies), userAgentOverride(logger, options.UserAgent), + injectPaintCallbacksPolyfillActionFunc(logger, installPaintPolyfill), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, 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 { // Note: no error wrapping because it leaks on errors we want to display to // the end user. + installPaintPolyfill := options.WaitForExpression != "" || options.WaitForSelector != "" return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ network.Enable(), fetch.Enable(), @@ -343,6 +346,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), setCookiesActionFunc(logger, options.Cookies), userAgentOverride(logger, options.UserAgent), + injectPaintCallbacksPolyfillActionFunc(logger, installPaintPolyfill), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), forceExactColorsActionFunc(logger, true), diff --git a/pkg/modules/chromium/paint_polyfill.go b/pkg/modules/chromium/paint_polyfill.go new file mode 100644 index 0000000..9366d20 --- /dev/null +++ b/pkg/modules/chromium/paint_polyfill.go @@ -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 + } +} diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index e8b1414..34a7daf 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -195,6 +195,39 @@ Feature: /forms/chromium/convert/html 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) 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): diff --git a/test/integration/testdata/paint-callbacks-html/index.html b/test/integration/testdata/paint-callbacks-html/index.html new file mode 100644 index 0000000..5d289b7 --- /dev/null +++ b/test/integration/testdata/paint-callbacks-html/index.html @@ -0,0 +1,46 @@ + + + + + Paint-driven callbacks + + + +

raf-pending

+

ro-pending

+

io-pending

+
target
+ + +