feat(chromium): add print_to_pdf sub-span with bounded option attrs

This commit is contained in:
Julien Neuhart
2026-06-02 19:36:50 +02:00
parent 11ab93aef6
commit e7c8a6a50c
3 changed files with 160 additions and 84 deletions

View File

@@ -4,11 +4,43 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"go.opentelemetry.io/otel/attribute"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
) )
func TestPrintToPdfAttrs(t *testing.T) {
options := DefaultPdfOptions()
options.Landscape = true
options.PageRanges = "1-5"
options.HeaderTemplate = "<div>secret header</div>"
// FooterTemplate left at default, so has_footer must be false.
got := map[string]attribute.Value{}
for _, kv := range printToPdfAttrs(options) {
got[string(kv.Key)] = kv.Value
if s := kv.Value.AsString(); strings.Contains(s, "secret") || s == "1-5" {
t.Errorf("attribute %s leaked a raw value: %q", kv.Key, s)
}
}
if !got["gotenberg.chromium.print.landscape"].AsBool() {
t.Error("expected landscape=true")
}
if !got["gotenberg.chromium.print.has_page_ranges"].AsBool() {
t.Error("expected has_page_ranges=true")
}
if !got["gotenberg.chromium.print.has_header"].AsBool() {
t.Error("expected has_header=true")
}
if got["gotenberg.chromium.print.has_footer"].AsBool() {
t.Error("expected has_footer=false")
}
}
func TestConversionInputAttrs(t *testing.T) { func TestConversionInputAttrs(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "index.html") tmp := filepath.Join(t.TempDir(), "index.html")
content := []byte("<html></html>") content := []byte("<html></html>")

View File

@@ -334,7 +334,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// PDF specific. // PDF specific.
printToPdfActionFunc(logger, outputPath, options), printToPdfActionFunc(ctx, logger, outputPath, options),
// Teardown. // Teardown.
page.Close(), page.Close(),
}) })

View File

@@ -14,104 +14,148 @@ import (
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc { func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error { return func(ctx context.Context) error {
paperHeight := options.PaperHeight // ctx is the chromedp task context, derived from context.Background(),
pageRanges := options.PageRanges // so the span is started under reqCtx to keep print_to_pdf in the
// conversion trace instead of orphaning it into a new one.
_, span := gotenberg.Tracer().Start(reqCtx, "chromium.print_to_pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(printToPdfAttrs(options)...),
)
defer span.End()
if options.SinglePage { err := func() error {
logger.DebugContext(ctx, "single page PDF") paperHeight := options.PaperHeight
pageRanges := options.PageRanges
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx) if options.SinglePage {
if err != nil { logger.DebugContext(ctx, "single page PDF")
return fmt.Errorf("get layout metrics: %w", err)
_, _, _, _, _, 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.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
pageRanges = "1" // little dirty hack to avoid leftovers.
} }
// There are 96 CSS pixels per inch. printToPdf := page.PrintToPDF().
// See https://issues.chromium.org/issues/40267771#comment14. WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
// We add top and bottom margins so that the content area WithLandscape(options.Landscape).
// is large enough to fit the entire content. WithPrintBackground(options.PrintBackground).
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom WithScale(options.Scale).
pageRanges = "1" // little dirty hack to avoid leftovers. WithPaperWidth(options.PaperWidth).
} WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// See https://github.com/gotenberg/gotenberg/issues/1210.
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
printToPdf := page.PrintToPDF(). hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream). options.FooterTemplate != DefaultPdfOptions().FooterTemplate
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// See https://github.com/gotenberg/gotenberg/issues/1210.
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate || if !hasCustomHeaderFooter {
options.FooterTemplate != DefaultPdfOptions().FooterTemplate logger.DebugContext(ctx, "no custom header nor footer")
if !hasCustomHeaderFooter { printToPdf = printToPdf.WithDisplayHeaderFooter(false)
logger.DebugContext(ctx, "no custom header nor footer") } else {
logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false) printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
}
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else { } else {
logger.DebugContext(ctx, "with custom header and/or footer") span.SetStatus(codes.Ok, "")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
} }
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf)) return err
}
}
_, stream, err := printToPdf.Do(ctx) // printToPdfAttrs derives bounded, low-cardinality attributes from the print
if err != nil { // options. Raw header/footer templates and page ranges are reduced to booleans
return fmt.Errorf("print to PDF: %w", err) // to avoid leaking document content and exploding cardinality.
} func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
return []attribute.KeyValue{
reader := &streamReader{ attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
ctx: ctx, attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
handle: stream, attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
r: nil, attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
pos: 0, attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
eof: false, attribute.Bool("gotenberg.chromium.print.single_page", options.SinglePage),
} attribute.Bool("gotenberg.chromium.print.prefer_css_page_size", options.PreferCssPageSize),
attribute.Bool("gotenberg.chromium.print.generate_tagged_pdf", options.GenerateTaggedPdf),
defer func() { attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
err = reader.Close() attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
if err != nil { attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
} }
} }