From 45b9f3235167764e670b8c804e23e9b29bd3634c Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Thu, 30 Apr 2026 14:05:03 +0200 Subject: [PATCH] refactor(chromium): drop paint-callback polyfill now that chromedp is pinned --- pkg/modules/chromium/browser.go | 2 - pkg/modules/chromium/paint_polyfill.go | 141 ------------------ .../features/chromium_convert_html.feature | 4 +- 3 files changed, 2 insertions(+), 145 deletions(-) delete mode 100644 pkg/modules/chromium/paint_polyfill.go diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 5005da98..4eb63c13 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -317,7 +317,6 @@ 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), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), forceExactColorsActionFunc(logger, options.PrintBackground), @@ -344,7 +343,6 @@ 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), 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 deleted file mode 100644 index 081c189f..00000000 --- a/pkg/modules/chromium/paint_polyfill.go +++ /dev/null @@ -1,141 +0,0 @@ -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. Replaces requestAnimationFrame, cancelAnimationFrame, -// ResizeObserver, and IntersectionObserver with timer-backed -// implementations. Headless Chromium's print-emulation pipeline does -// not tick the compositor refresh driver between the load event and -// the Page.printToPDF call, which leaves the native callback queues -// stalled and breaks charting libraries (visx, ApexCharts) that rely -// on rAF-gated measurement. The polyfill exposes the same APIs with -// timer semantics so user-scheduled callbacks fire in print mode and -// the rendered output matches a live browser. 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] -// before any user script runs. Runs on every conversion: Chromium's -// headless-print pipeline does not fire the native rAF, -// ResizeObserver, and IntersectionObserver, so the timer-backed -// replacements fire the user's callbacks when the page relies on them -// and have no effect otherwise. -func injectPaintCallbacksPolyfillActionFunc(logger *slog.Logger) chromedp.ActionFunc { - return func(ctx context.Context) error { - 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 8b86610e..03066c00 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -195,7 +195,7 @@ 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 with waitForExpression) + Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression) 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 | @@ -219,7 +219,7 @@ Feature: /forms/chromium/convert/html io-fired """ - Scenario: POST /forms/chromium/convert/html (paint-callback polyfill fires rAF / ResizeObserver / IntersectionObserver with waitDelay and emulatedMediaType=print) + Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitDelay and emulatedMediaType=print) 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 |