Repository: gotenberg/gotenberg (go)

Issue:
Feature: Ability to print one large full-page PDF from chromium
We have our own chromium wrapper that we use to take single-page PDF using the site content size.

We use:
- Emulation.setDeviceMetricsOverride(width=<requested pdf width>, height=<requested pdf height>)
- Page.getLayoutMetrics -> content_width=contentSize.width;  content_height=contentSize.height
- Page.printToPdf(paperWidth=content_width / 96, paperHeight=content_height / 96)

This way the whole website is "printed" inside one page which has the requested dimensions, rather than multiple pages.

I can't use dynamically computed values for paperWidth/Height in the current version of gotenberg. Would this be something that could be implemented ?

Pull request description:
feat(chromium): single page PDF
Closes #421.

New form field `singlePage` (bool).

Chromium also outputs tagged PDFs by default now.

Patch:
diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go
index 9a3594e..0ab4ad3 100644
--- a/pkg/modules/chromium/chromium.go
+++ b/pkg/modules/chromium/chromium.go
@@ -150,6 +150,11 @@ type PdfOptions struct {
 	// Optional.
 	Scale float64
 
+	// SinglePage defines whether to print the entire content in one single
+	// page.
+	// Optional.
+	SinglePage bool
+
 	// PaperWidth is the paper width, in inches.
 	// Optional.
 	PaperWidth float64
@@ -209,6 +214,7 @@ func DefaultPdfOptions() PdfOptions {
 		Landscape:         false,
 		PrintBackground:   false,
 		Scale:             1.0,
+		SinglePage:        false,
 		PaperWidth:        8.5,
 		PaperHeight:       11,
 		MarginTop:         0.39,
diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go
index e43400a..f490963 100644
--- a/pkg/modules/chromium/routes.go
+++ b/pkg/modules/chromium/routes.go
@@ -109,7 +109,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
 	defaultPdfOptions := DefaultPdfOptions()
 
 	var (
-		landscape, printBackground                       bool
+		landscape, printBackground, singlePage           bool
 		scale, paperWidth, paperHeight                   float64
 		marginTop, marginBottom, marginLeft, marginRight float64
 		pageRanges                                       string
@@ -121,6 +121,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
 		Bool("landscape", &landscape, defaultPdfOptions.Landscape).
 		Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
 		Float64("scale", &scale, defaultPdfOptions.Scale).
+		Bool("singlePage", &singlePage, defaultPdfOptions.SinglePage).
 		Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
 		Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
 		Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop).
@@ -137,6 +138,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
 		Landscape:         landscape,
 		PrintBackground:   printBackground,
 		Scale:             scale,
+		SinglePage:        singlePage,
 		PaperWidth:        paperWidth,
 		PaperHeight:       paperHeight,
 		MarginTop:         marginTop,
diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go
index d77688b..76068dd 100644
--- a/pkg/modules/chromium/tasks.go
+++ b/pkg/modules/chromium/tasks.go
@@ -17,19 +17,37 @@ import (
 
 func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
 	return func(ctx context.Context) error {
+		paperHeight := options.PaperHeight
+		pageRanges := options.PageRanges
+
+		if options.SinglePage {
+			logger.Debug("single page PDF")
+
+			_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
+			if err != nil {
+				return fmt.Errorf("get layout metrics: %w", err)
+			}
+
+			// There are 96 CSS pixels per inch.
+			// See https://issues.chromium.org/issues/40267771#comment14.
+			paperHeight = cssContentSize.Height / 96
+			pageRanges = "1" // little dirty hack to avoid leftovers.
+		}
+
 		printToPdf := page.PrintToPDF().
 			WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
 			WithLandscape(options.Landscape).
 			WithPrintBackground(options.PrintBackground).
 			WithScale(options.Scale).
 			WithPaperWidth(options.PaperWidth).
-			WithPaperHeight(options.PaperHeight).
+			WithPaperHeight(paperHeight).
 			WithMarginTop(options.MarginTop).
 			WithMarginBottom(options.MarginBottom).
 			WithMarginLeft(options.MarginLeft).
 			WithMarginRight(options.MarginRight).
-			WithPageRanges(options.PageRanges).
-			WithPreferCSSPageSize(options.PreferCssPageSize)
+			WithPageRanges(pageRanges).
+			WithPreferCSSPageSize(options.PreferCssPageSize).
+			WithGenerateTaggedPDF(true)
 
 		hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
 			options.FooterTemplate != DefaultPdfOptions().FooterTemplate


Test patch:
diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go
index 4a4bc54..b33b5ae 100644
--- a/pkg/modules/chromium/browser_test.go
+++ b/pkg/modules/chromium/browser_test.go
@@ -950,6 +950,41 @@ func TestChromiumBrowser_pdf(t *testing.T) {
 				"wait until 'window.globalVar === 'ready'' is true before print",
 			},
 		},
+		{
+			scenario: "single page",
+			browser: newChromiumBrowser(
+				browserArguments{
+					binPath:          os.Getenv("CHROMIUM_BIN_PATH"),
+					wsUrlReadTimeout: 5 * time.Second,
+					allowList:        regexp2.MustCompile("", 0),
+					denyList:         regexp2.MustCompile("", 0),
+				},
+			),
+			fs: func() *gotenberg.FileSystem {
+				fs := gotenberg.NewFileSystem()
+
+				err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
+				if err != nil {
+					t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
+				}
+
+				err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>Custom header and footer</h1>"), 0o755)
+				if err != nil {
+					t.Fatalf("expected no error but got: %v", err)
+				}
+
+				return fs
+			}(),
+			options: PdfOptions{
+				SinglePage: true,
+			},
+			noDeadline:  false,
+			start:       true,
+			expectError: false,
+			expectedLogEntries: []string{
+				"single page PDF",
+			},
+		},
 		{
 			scenario: "custom header and footer",
 			browser: newChromiumBrowser(