package libreoffice import ( "errors" "fmt" "net/http" "slices" "strconv" "github.com/labstack/echo/v4" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/api" libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines" ) // convertRoute returns an [api.Route] which can convert LibreOffice documents // to PDF. func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route { return api.Route{ Method: http.MethodPost, Path: "/forms/libreoffice/convert", IsMultipart: true, Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) defaultOptions := libreofficeapi.DefaultOptions() form := ctx.FormData() splitMode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) watermark := pdfengines.FormDataPdfWatermark(form, false) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) stamp := pdfengines.FormDataPdfStamp(form, false) stampFile := pdfengines.FormDataPdfStampFile(form) angle, rotatePages := pdfengines.FormDataPdfRotate(form, false) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form) zeroValuedSplitMode := gotenberg.SplitMode{} var ( inputPaths []string password string landscape bool nativePageRanges string updateIndexes bool exportFormFields bool allowDuplicateFieldNames bool exportBookmarks bool exportBookmarksToPdfDestination bool exportPlaceholders bool exportNotes bool exportNotesPages bool exportOnlyNotesPages bool exportNotesInMargin bool convertOooTargetToPdfTarget bool exportLinksRelativeFsys bool exportHiddenSlides bool skipEmptyPages bool addOriginalDocumentAsStream bool singlePageSheets bool losslessImageCompression bool quality int reduceImageResolution bool maxImageResolution int nativeWatermarkText string nativeWatermarkColor int initialView int initialPage int magnification int zoom int pageLayout int firstPageOnLeft bool resizeWindowToInitialPage bool centerWindow bool openInFullScreenMode bool displayPDFDocumentTitle bool hideViewerMenubar bool hideViewerToolbar bool hideViewerWindowControls bool useTransitionEffects bool openBookmarkLevels int nativeWatermarkFontHeight int nativeWatermarkRotateAngle int nativeWatermarkFontName string nativeTiledWatermarkText string nativePdfFormats bool merge bool flatten bool ) err := form. MandatoryPaths(libreOffice.Extensions(), &inputPaths). String("password", &password, defaultOptions.Password). Bool("landscape", &landscape, defaultOptions.Landscape). String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges). Bool("updateIndexes", &updateIndexes, defaultOptions.UpdateIndexes). Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields). Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames). Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks). Bool("exportBookmarksToPdfDestination", &exportBookmarksToPdfDestination, defaultOptions.ExportBookmarksToPdfDestination). Bool("exportPlaceholders", &exportPlaceholders, defaultOptions.ExportPlaceholders). Bool("exportNotes", &exportNotes, defaultOptions.ExportNotes). Bool("exportNotesPages", &exportNotesPages, defaultOptions.ExportNotesPages). Bool("exportOnlyNotesPages", &exportOnlyNotesPages, defaultOptions.ExportOnlyNotesPages). Bool("exportNotesInMargin", &exportNotesInMargin, defaultOptions.ExportNotesInMargin). Bool("convertOooTargetToPdfTarget", &convertOooTargetToPdfTarget, defaultOptions.ConvertOooTargetToPdfTarget). Bool("exportLinksRelativeFsys", &exportLinksRelativeFsys, defaultOptions.ExportLinksRelativeFsys). Bool("exportHiddenSlides", &exportHiddenSlides, defaultOptions.ExportHiddenSlides). Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages). Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream). Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets). Custom("initialView", func(value string) error { if value == "" { initialView = defaultOptions.InitialView return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if !slices.Contains([]int{0, 1, 2}, intValue) { return errors.New("value is not 0, 1 or 2") } initialView = intValue return nil }). Custom("initialPage", func(value string) error { if value == "" { initialPage = defaultOptions.InitialPage return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if intValue < 1 { return errors.New("value is inferior to 1") } initialPage = intValue return nil }). Custom("magnification", func(value string) error { if value == "" { magnification = defaultOptions.Magnification return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if !slices.Contains([]int{0, 1, 2, 3, 4}, intValue) { return errors.New("value is not 0, 1, 2, 3 or 4") } magnification = intValue return nil }). Custom("zoom", func(value string) error { if value == "" { zoom = defaultOptions.Zoom return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if intValue < 1 { return errors.New("value is inferior to 1") } zoom = intValue return nil }). Custom("pageLayout", func(value string) error { if value == "" { pageLayout = defaultOptions.PageLayout return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if !slices.Contains([]int{0, 1, 2, 3}, intValue) { return errors.New("value is not 0, 1, 2 or 3") } pageLayout = intValue return nil }). Bool("firstPageOnLeft", &firstPageOnLeft, defaultOptions.FirstPageOnLeft). Bool("resizeWindowToInitialPage", &resizeWindowToInitialPage, defaultOptions.ResizeWindowToInitialPage). Bool("centerWindow", ¢erWindow, defaultOptions.CenterWindow). Bool("openInFullScreenMode", &openInFullScreenMode, defaultOptions.OpenInFullScreenMode). Bool("displayPDFDocumentTitle", &displayPDFDocumentTitle, defaultOptions.DisplayPDFDocumentTitle). Bool("hideViewerMenubar", &hideViewerMenubar, defaultOptions.HideViewerMenubar). Bool("hideViewerToolbar", &hideViewerToolbar, defaultOptions.HideViewerToolbar). Bool("hideViewerWindowControls", &hideViewerWindowControls, defaultOptions.HideViewerWindowControls). Bool("useTransitionEffects", &useTransitionEffects, defaultOptions.UseTransitionEffects). Custom("openBookmarkLevels", func(value string) error { if value == "" { openBookmarkLevels = defaultOptions.OpenBookmarkLevels return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if intValue != -1 && (intValue < 1 || intValue > 10) { return errors.New("value is not -1 or between 1 and 10") } openBookmarkLevels = intValue return nil }). Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression). Custom("quality", func(value string) error { if value == "" { quality = defaultOptions.Quality return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if intValue < 1 { return errors.New("value is inferior to 1") } if intValue > 100 { return errors.New("value is superior to 100") } quality = intValue return nil }). Bool("reduceImageResolution", &reduceImageResolution, defaultOptions.ReduceImageResolution). Custom("maxImageResolution", func(value string) error { if value == "" { maxImageResolution = defaultOptions.MaxImageResolution return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if !slices.Contains([]int{75, 150, 300, 600, 1200}, intValue) { return errors.New("value is not 75, 150, 300, 600 or 1200") } maxImageResolution = intValue return nil }). String("nativeWatermarkText", &nativeWatermarkText, defaultOptions.NativeWatermarkText). Custom("nativeWatermarkColor", func(value string) error { if value == "" { nativeWatermarkColor = defaultOptions.NativeWatermarkColor return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } nativeWatermarkColor = intValue return nil }). Custom("nativeWatermarkFontHeight", func(value string) error { if value == "" { nativeWatermarkFontHeight = defaultOptions.NativeWatermarkFontHeight return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } if intValue < 0 { return errors.New("value is inferior to 0") } nativeWatermarkFontHeight = intValue return nil }). Custom("nativeWatermarkRotateAngle", func(value string) error { if value == "" { nativeWatermarkRotateAngle = defaultOptions.NativeWatermarkRotateAngle return nil } intValue, err := strconv.Atoi(value) if err != nil { return err } nativeWatermarkRotateAngle = intValue return nil }). String("nativeWatermarkFontName", &nativeWatermarkFontName, defaultOptions.NativeWatermarkFontName). String("nativeTiledWatermarkText", &nativeTiledWatermarkText, defaultOptions.NativeTiledWatermarkText). Bool("nativePdfFormats", &nativePdfFormats, true). Bool("merge", &merge, false). Bool("flatten", &flatten, false). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) } err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) if err != nil { return fmt.Errorf("validate watermark: %w", err) } err = pdfengines.EnsureStampFile(&stamp, stampFile) if err != nil { return fmt.Errorf("validate stamp: %w", err) } err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) if err != nil { return err } err = pdfengines.ValidatePdfEncryptCompat(encrypt) if err != nil { return err } err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats) if err != nil { return err } // Factur-X requires PDF/A-3; default to PDF/A-3b when no format was // requested. The conversion runs as a post-processing step below. pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil) hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 || len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != "" outputPaths := make([]string, len(inputPaths)) for i, inputPath := range inputPaths { outputPaths[i] = ctx.GeneratePath(".pdf") options := libreofficeapi.Options{ Password: password, Landscape: landscape, PageRanges: nativePageRanges, UpdateIndexes: updateIndexes, ExportFormFields: exportFormFields, AllowDuplicateFieldNames: allowDuplicateFieldNames, ExportBookmarks: exportBookmarks, ExportBookmarksToPdfDestination: exportBookmarksToPdfDestination, ExportPlaceholders: exportPlaceholders, ExportNotes: exportNotes, ExportNotesPages: exportNotesPages, ExportOnlyNotesPages: exportOnlyNotesPages, ExportNotesInMargin: exportNotesInMargin, ConvertOooTargetToPdfTarget: convertOooTargetToPdfTarget, ExportLinksRelativeFsys: exportLinksRelativeFsys, ExportHiddenSlides: exportHiddenSlides, SkipEmptyPages: skipEmptyPages, AddOriginalDocumentAsStream: addOriginalDocumentAsStream, SinglePageSheets: singlePageSheets, InitialView: initialView, InitialPage: initialPage, Magnification: magnification, Zoom: zoom, PageLayout: pageLayout, FirstPageOnLeft: firstPageOnLeft, ResizeWindowToInitialPage: resizeWindowToInitialPage, CenterWindow: centerWindow, OpenInFullScreenMode: openInFullScreenMode, DisplayPDFDocumentTitle: displayPDFDocumentTitle, HideViewerMenubar: hideViewerMenubar, HideViewerToolbar: hideViewerToolbar, HideViewerWindowControls: hideViewerWindowControls, UseTransitionEffects: useTransitionEffects, OpenBookmarkLevels: openBookmarkLevels, LosslessImageCompression: losslessImageCompression, Quality: quality, ReduceImageResolution: reduceImageResolution, MaxImageResolution: maxImageResolution, NativeWatermarkText: nativeWatermarkText, NativeWatermarkColor: nativeWatermarkColor, NativeWatermarkFontHeight: nativeWatermarkFontHeight, NativeWatermarkRotateAngle: nativeWatermarkRotateAngle, NativeWatermarkFontName: nativeWatermarkFontName, NativeTiledWatermarkText: nativeTiledWatermarkText, } if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing { // Only natively apply given PDF formats if we're not // splitting the PDF later and no post-processing features // are enabled (as they would degrade compliance). options.PdfFormats = pdfFormats } err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options) if err != nil { if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), api.NewSentinelHttpError( http.StatusBadRequest, fmt.Sprintf("The PDF format '%s' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.", pdfFormats.PdfA), ), ) } if errors.Is(err, libreofficeapi.ErrUnoException) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)), ) } if errors.Is(err, libreofficeapi.ErrRuntimeException) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."), ) } return fmt.Errorf("convert to PDF: %w", err) } } if merge { outputPath, err := pdfengines.MergeStub(ctx, engine, outputPaths) if err != nil { return fmt.Errorf("merge PDFs: %w", err) } // Only one output path. outputPaths = []string{outputPath} } if splitMode != zeroValuedSplitMode { if !merge { // document.docx -> document.docx.pdf, so that split naming // document.docx_0.pdf, etc. for i, inputPath := range inputPaths { originalName := ctx.OriginalFilename(inputPath) outputPath := ctx.GeneratePathFromFilename(originalName + ".pdf") err = ctx.Rename(outputPaths[i], outputPath) if err != nil { return fmt.Errorf("rename output path: %w", err) } outputPaths[i] = outputPath } } outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths) if err != nil { return fmt.Errorf("split PDFs: %w", err) } } err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths) if err != nil { return fmt.Errorf("watermark PDFs: %w", err) } err = pdfengines.StampStub(ctx, engine, stamp, outputPaths) if err != nil { return fmt.Errorf("stamp PDFs: %w", err) } err = pdfengines.RotateStub(ctx, engine, angle, rotatePages, outputPaths) if err != nil { return fmt.Errorf("rotate PDFs: %w", err) } if flatten { err = pdfengines.FlattenStub(ctx, engine, outputPaths) if err != nil { return fmt.Errorf("flatten PDFs: %w", err) } } needsConvertStub := !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) || (nativePdfFormats && hasPostProcessing) if needsConvertStub { convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) if err != nil { return fmt.Errorf("convert PDFs: %w", err) } if splitMode != zeroValuedSplitMode { // The PDF has been split and split parts have been converted to // specific formats. We want to keep the split naming. for i, convertOutputPath := range convertOutputPaths { err = ctx.Rename(convertOutputPath, outputPaths[i]) if err != nil { return fmt.Errorf("rename output path: %w", err) } } } else { outputPaths = convertOutputPaths } } // Metadata, embeds are written after Convert, as LibreOffice // strips them during PDF/A conversion. err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) if err != nil { return fmt.Errorf("write metadata: %w", err) } err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths) if err != nil { return fmt.Errorf("embed files into PDFs: %w", err) } err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths) if err != nil { return fmt.Errorf("set embeds metadata: %w", err) } err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths) if err != nil { return fmt.Errorf("apply Factur-X: %w", err) } err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) } if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode { // If .zip archive, document.docx -> document.docx.pdf. for i, inputPath := range inputPaths { originalName := ctx.OriginalFilename(inputPath) outputPath := ctx.GeneratePathFromFilename(originalName + ".pdf") err = ctx.Rename(outputPaths[i], outputPath) if err != nil { return fmt.Errorf("rename output path: %w", err) } outputPaths[i] = outputPath } } err = ctx.AddOutputPaths(outputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) } return nil }, } }