From 82401bdfddfdfda4366415cec52c8d83c277d1d7 Mon Sep 17 00:00:00 2001
From: Daniel Moran
Date: Tue, 17 Feb 2026 11:16:28 -0800
Subject: [PATCH] feat(chromium): add support for emulated media features in
Chromium (#1474)
Closes https://github.com/gotenberg/gotenberg/issues/1460
It can be easier to print a "clean" PDF of some pages if you emulate
media features like `prefers-reduced-motion`. Add support for that
emulation.
---
pkg/modules/chromium/browser.go | 4 +-
pkg/modules/chromium/chromium.go | 18 ++++
pkg/modules/chromium/routes.go | 24 +++++
pkg/modules/chromium/tasks.go | 34 +++++--
.../features/chromium_convert_html.feature | 85 ++++++++++++++++++
.../features/chromium_convert_url.feature | 90 +++++++++++++++++++
.../feature-rich-html-remote/index.html | 9 ++
.../testdata/feature-rich-html/index.html | 9 ++
8 files changed, 263 insertions(+), 10 deletions(-)
diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go
index b4594830..1e8f100f 100644
--- a/pkg/modules/chromium/browser.go
+++ b/pkg/modules/chromium/browser.go
@@ -288,7 +288,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground),
- emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
+ emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
@@ -314,7 +314,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true),
- emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
+ emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index 2645eefb..56197565 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -165,11 +165,28 @@ type Options struct {
// "print".
EmulatedMediaType string
+ // EmulatedMediaFeatures are the media features to emulate, e.g.,
+ // [{"name": "prefers-color-scheme", "value": "dark"}].
+ EmulatedMediaFeatures []EmulatedMediaFeature
+
// OmitBackground hides the default white background and allows generating
// PDFs with transparency.
OmitBackground bool
}
+// EmulatedMediaFeature gathers the available entries for emulating a media
+// feature.
+type EmulatedMediaFeature struct {
+ // Name is the media feature name (e.g., "prefers-color-scheme",
+ // "prefers-reduced-motion").
+ // Required.
+ Name string `json:"name"`
+
+ // Value is the media feature value (e.g., "dark", "reduce").
+ // Required.
+ Value string `json:"value"`
+}
+
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
@@ -187,6 +204,7 @@ func DefaultOptions() Options {
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
+ EmulatedMediaFeatures: nil,
OmitBackground: false,
}
}
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index 6398594b..d3ec73d4 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -39,6 +39,7 @@ var sameSiteRegexp = regexp2.MustCompile(
// - ignoreResourceHttpStatusDomains: []string
// - cookies: []Cookie
// - extraHttpHeaders: map[string]string
+// - emulatedMediaFeatures: map[string]string
//
// Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes".
@@ -60,6 +61,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
+ emulatedMediaFeatures []EmulatedMediaFeature
omitBackground bool
)
@@ -227,6 +229,27 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
+ Custom("emulatedMediaFeatures", func(value string) error {
+ if value == "" {
+ emulatedMediaFeatures = defaultOptions.EmulatedMediaFeatures
+ return nil
+ }
+
+ var features map[string]string
+ err := json.Unmarshal([]byte(value), &features)
+ if err != nil {
+ return fmt.Errorf("unmarshal emulatedMediaFeatures: %w", err)
+ }
+
+ for k, v := range features {
+ emulatedMediaFeatures = append(emulatedMediaFeatures, EmulatedMediaFeature{
+ Name: k,
+ Value: v,
+ })
+ }
+
+ return err
+ }).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
@@ -244,6 +267,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
+ EmulatedMediaFeatures: emulatedMediaFeatures,
OmitBackground: omitBackground,
}
diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go
index 1169d9fa..a6df949e 100644
--- a/pkg/modules/chromium/tasks.go
+++ b/pkg/modules/chromium/tasks.go
@@ -423,26 +423,44 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
}
}
-func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.ActionFunc {
+func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
return func(ctx context.Context) error {
- if mediaType == "" {
- logger.Debug("no emulated media type")
+ if mediaType == "" && len(mediaFeatures) == 0 {
+ logger.Debug("no emulated media type or features")
return nil
}
- if mediaType != "screen" && mediaType != "print" {
+ if mediaType != "" && mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
}
- logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
-
emulatedMedia := emulation.SetEmulatedMedia()
- err := emulatedMedia.WithMedia(mediaType).Do(ctx)
+
+ if mediaType != "" {
+ logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
+ emulatedMedia = emulatedMedia.WithMedia(mediaType)
+ }
+
+ if len(mediaFeatures) > 0 {
+ logger.Debug(fmt.Sprintf("emulate media features %+v", mediaFeatures))
+
+ features := make([]*emulation.MediaFeature, len(mediaFeatures))
+ for i, f := range mediaFeatures {
+ features[i] = &emulation.MediaFeature{
+ Name: f.Name,
+ Value: f.Value,
+ }
+ }
+
+ emulatedMedia = emulatedMedia.WithFeatures(features)
+ }
+
+ err := emulatedMedia.Do(ctx)
if err == nil {
return nil
}
- return fmt.Errorf("emulate media type '%s': %w", mediaType, err)
+ return fmt.Errorf("emulate media: %w", err)
}
}
diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature
index 876446c8..899df667 100644
--- a/test/integration/features/chromium_convert_html.feature
+++ b/test/integration/features/chromium_convert_html.feature
@@ -281,6 +281,82 @@ Feature: /forms/chromium/convert/html
Emulated media type is 'print'.
"""
+ Scenario: POST /forms/chromium/convert/html (Emulated Media Features)
+ 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/feature-rich-html/index.html | file |
+ | 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 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ 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/feature-rich-html/index.html | file |
+ | emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | 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 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ 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/feature-rich-html/index.html | file |
+ | emulatedMediaType | screen | field |
+ | emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | 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 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ 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/feature-rich-html/index.html | file |
+ | emulatedMediaType | print | field |
+ | emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | 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 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+
Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists)
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):
@@ -550,6 +626,15 @@ Feature: /forms/chromium/convert/html
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
+ 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 |
+ | emulatedMediaFeatures | foo | field |
+ Then the response status code should be 400
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Invalid form data: form field 'emulatedMediaFeatures' is invalid (got 'foo', resulting to unmarshal emulatedMediaFeatures: invalid character 'o' in literal false (expecting 'a'))
+ """
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 |
| splitMode | foo | field |
diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature
index ae6fb88e..7907d108 100644
--- a/test/integration/features/chromium_convert_url.feature
+++ b/test/integration/features/chromium_convert_url.feature
@@ -346,6 +346,86 @@ Feature: /forms/chromium/convert/url
Emulated media type is 'print'.
"""
+ Scenario: POST /forms/chromium/convert/url (Emulated Media Features)
+ Given I have a default Gotenberg container
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | 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 1 page(s)
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | 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 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | emulatedMediaType | screen | field |
+ | emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | 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 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
+ | emulatedMediaType | print | field |
+ | emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | 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 1 page(s)
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Emulated media type is 'print'.
+ """
+ Then the "foo.pdf" PDF should have the following content at page 1:
+ """
+ Prefers reduced motion.
+ """
+ Then the "foo.pdf" PDF should NOT have the following content at page 1:
+ """
+ Emulated media type is 'screen'.
+ """
+
Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists)
Given I have a default Gotenberg container
Given I have a static server
@@ -627,6 +707,16 @@ Feature: /forms/chromium/convert/url
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
Given I have a static server
+ When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
+ | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
+ | emulatedMediaFeatures | foo | field |
+ Then the response status code should be 400
+ Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
+ Then the response body should match string:
+ """
+ Invalid form data: form field 'emulatedMediaFeatures' is invalid (got 'foo', resulting to unmarshal emulatedMediaFeatures: invalid character 'o' in literal false (expecting 'a'))
+ """
+ Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | foo | field |
diff --git a/test/integration/testdata/feature-rich-html-remote/index.html b/test/integration/testdata/feature-rich-html-remote/index.html
index f7183740..af1b006a 100644
--- a/test/integration/testdata/feature-rich-html-remote/index.html
+++ b/test/integration/testdata/feature-rich-html-remote/index.html
@@ -20,6 +20,14 @@
display: none;
}
}
+ #reduced-motion {
+ display: none;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ #reduced-motion {
+ display: block;
+ }
+ }
@@ -29,6 +37,7 @@
Emulated media type is 'print'.
Emulated media type is 'screen'.
+ Prefers reduced motion.
JavaScript is enabled.
diff --git a/test/integration/testdata/feature-rich-html/index.html b/test/integration/testdata/feature-rich-html/index.html
index dc1537d4..e2aee534 100644
--- a/test/integration/testdata/feature-rich-html/index.html
+++ b/test/integration/testdata/feature-rich-html/index.html
@@ -28,6 +28,14 @@
display: none;
}
}
+ #reduced-motion {
+ display: none;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ #reduced-motion {
+ display: block;
+ }
+ }
@@ -37,6 +45,7 @@
Emulated media type is 'print'.
Emulated media type is 'screen'.
+ Prefers reduced motion.
JavaScript is enabled.