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.
This commit is contained in:
Daniel Moran
2026-02-17 11:16:28 -08:00
committed by GitHub
parent 12c25a2d21
commit 82401bdfdd
8 changed files with 263 additions and 10 deletions

View File

@@ -288,7 +288,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground), forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), 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), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true), forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),

View File

@@ -165,11 +165,28 @@ type Options struct {
// "print". // "print".
EmulatedMediaType string 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 // OmitBackground hides the default white background and allows generating
// PDFs with transparency. // PDFs with transparency.
OmitBackground bool 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. // DefaultOptions returns the default values for Options.
func DefaultOptions() Options { func DefaultOptions() Options {
return Options{ return Options{
@@ -187,6 +204,7 @@ func DefaultOptions() Options {
UserAgent: "", UserAgent: "",
ExtraHttpHeaders: nil, ExtraHttpHeaders: nil,
EmulatedMediaType: "", EmulatedMediaType: "",
EmulatedMediaFeatures: nil,
OmitBackground: false, OmitBackground: false,
} }
} }

View File

@@ -39,6 +39,7 @@ var sameSiteRegexp = regexp2.MustCompile(
// - ignoreResourceHttpStatusDomains: []string // - ignoreResourceHttpStatusDomains: []string
// - cookies: []Cookie // - cookies: []Cookie
// - extraHttpHeaders: map[string]string // - extraHttpHeaders: map[string]string
// - emulatedMediaFeatures: map[string]string
// //
// Domain filtering only applies to resource checks triggered by // Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes". // "failOnResourceHttpStatusCodes".
@@ -60,6 +61,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
userAgent string userAgent string
extraHttpHeaders []ExtraHttpHeader extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string emulatedMediaType string
emulatedMediaFeatures []EmulatedMediaFeature
omitBackground bool omitBackground bool
) )
@@ -227,6 +229,27 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil 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) Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{ options := Options{
@@ -244,6 +267,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
UserAgent: userAgent, UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders, ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType, EmulatedMediaType: emulatedMediaType,
EmulatedMediaFeatures: emulatedMediaFeatures,
OmitBackground: omitBackground, OmitBackground: omitBackground,
} }

View File

@@ -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 { return func(ctx context.Context) error {
if mediaType == "" { if mediaType == "" && len(mediaFeatures) == 0 {
logger.Debug("no emulated media type") logger.Debug("no emulated media type or features")
return nil return nil
} }
if mediaType != "screen" && mediaType != "print" { if mediaType != "" && mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType) return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
} }
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia := emulation.SetEmulatedMedia() 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 { if err == nil {
return nil return nil
} }
return fmt.Errorf("emulate media type '%s': %w", mediaType, err) return fmt.Errorf("emulate media: %w", err)
} }
} }

View File

@@ -281,6 +281,82 @@ Feature: /forms/chromium/convert/html
Emulated media type is 'print'. 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) Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists)
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):
@@ -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 `*.`) 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): 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 | | files | testdata/page-1-html/index.html | file |
| splitMode | foo | field | | splitMode | foo | field |

View File

@@ -346,6 +346,86 @@ Feature: /forms/chromium/convert/url
Emulated media type is 'print'. 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) Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists)
Given I have a default Gotenberg container Given I have a default Gotenberg container
Given I have a static server 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 `*.`) 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 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): 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 | | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | foo | field | | splitMode | foo | field |

View File

@@ -20,6 +20,14 @@
display: none; display: none;
} }
} }
#reduced-motion {
display: none;
}
@media (prefers-reduced-motion: reduce) {
#reduced-motion {
display: block;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -29,6 +37,7 @@
</p> </p>
<p id="print">Emulated media type is 'print'.</p> <p id="print">Emulated media type is 'print'.</p>
<p id="screen">Emulated media type is 'screen'.</p> <p id="screen">Emulated media type is 'screen'.</p>
<p id="reduced-motion">Prefers reduced motion.</p>
<p id="javascript" style="display: none">JavaScript is enabled.</p> <p id="javascript" style="display: none">JavaScript is enabled.</p>
<iframe src="file:///etc/passwd"></iframe> <iframe src="file:///etc/passwd"></iframe>

View File

@@ -28,6 +28,14 @@
display: none; display: none;
} }
} }
#reduced-motion {
display: none;
}
@media (prefers-reduced-motion: reduce) {
#reduced-motion {
display: block;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -37,6 +45,7 @@
</p> </p>
<p id="print">Emulated media type is 'print'.</p> <p id="print">Emulated media type is 'print'.</p>
<p id="screen">Emulated media type is 'screen'.</p> <p id="screen">Emulated media type is 'screen'.</p>
<p id="reduced-motion">Prefers reduced motion.</p>
<p id="javascript" style="display: none">JavaScript is enabled.</p> <p id="javascript" style="display: none">JavaScript is enabled.</p>
<iframe src="/etc/passwd"></iframe> <iframe src="/etc/passwd"></iframe>
<iframe src="\\localhost/etc/passwd"></iframe> <iframe src="\\localhost/etc/passwd"></iframe>