mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(pdfengines): add embed feature
This commit is contained in:
2
Makefile
2
Makefile
@@ -63,6 +63,7 @@ PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PDFENGINES_EMBED_ENGINES=pdfcpu
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
|
||||
@@ -137,6 +138,7 @@ run: ## Start a Gotenberg container
|
||||
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
|
||||
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
|
||||
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
|
||||
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
|
||||
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
|
||||
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
|
||||
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
|
||||
|
||||
@@ -53,6 +53,7 @@ type PdfEngineMock struct {
|
||||
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
@@ -83,6 +84,10 @@ func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, in
|
||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
|
||||
}
|
||||
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
type PdfEngineProviderMock struct {
|
||||
PdfEngineMock func() (PdfEngine, error)
|
||||
|
||||
@@ -143,6 +143,10 @@ type PdfEngine interface {
|
||||
// The ownerPassword provides full access to the document.
|
||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
||||
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
@@ -38,11 +38,12 @@ var (
|
||||
|
||||
// Context is the request context for a "multipart/form-data" requests.
|
||||
type Context struct {
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
filesByField map[string][]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
@@ -79,6 +80,9 @@ type downloadFrom struct {
|
||||
|
||||
// ExtraHttpHeaders are the HTTP headers to send alongside.
|
||||
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
|
||||
|
||||
// Download as embed file
|
||||
Embedded bool `json:"embedded"`
|
||||
}
|
||||
|
||||
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||
@@ -184,6 +188,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
ctx.dirPath = dirPath
|
||||
ctx.values = form.Value
|
||||
ctx.files = make(map[string]string)
|
||||
ctx.filesByField = make(map[string][]string)
|
||||
|
||||
// First, try to download files listed in the "downloadFrom" form field, if
|
||||
// any.
|
||||
@@ -318,6 +323,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
if dl.Embedded {
|
||||
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -373,17 +381,22 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
}
|
||||
|
||||
// Then, copy the form files, if any.
|
||||
for _, files := range form.File {
|
||||
for fieldName, files := range form.File {
|
||||
for _, fh := range files {
|
||||
err = copyToDisk(fh)
|
||||
if err != nil {
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
// Track files by field name
|
||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||
filePath := ctx.files[filename]
|
||||
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
|
||||
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
|
||||
ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
||||
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
||||
|
||||
return ctx, cancel, err
|
||||
@@ -397,9 +410,10 @@ func (ctx *Context) Request() *http.Request {
|
||||
// FormData return a [FormData].
|
||||
func (ctx *Context) FormData() *FormData {
|
||||
return &FormData{
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
errors: nil,
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
filesByField: ctx.filesByField,
|
||||
errors: nil,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,14 +17,20 @@ import (
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// EmbedsFormField represents the form field name for embedding files.
|
||||
const (
|
||||
EmbedsFormField string = "embeds"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
// "multipart/form-data" request.
|
||||
//
|
||||
// form := ctx.FormData()
|
||||
type FormData struct {
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
errors error
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
filesByField map[string][]string
|
||||
errors error
|
||||
}
|
||||
|
||||
// Validate returns nil or an error related to the [FormData] values, with a
|
||||
@@ -358,6 +365,26 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
|
||||
return form.paths(extensions, target)
|
||||
}
|
||||
|
||||
// Embeds binds the absolute paths of form data files that should be
|
||||
// embedded in the PDF. Only files uploaded with the "embeds" field name
|
||||
// will be included.
|
||||
//
|
||||
// var embeds []string
|
||||
//
|
||||
// ctx.FormData().Embeds(&embeds)
|
||||
func (form *FormData) Embeds(target *[]string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
// Get files from the "embeds" field
|
||||
if paths, ok := form.filesByField[EmbedsFormField]; ok {
|
||||
*target = append(*target, paths...)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryPaths binds the absolute paths of form data files, according to a
|
||||
// list of file extensions, to a string slice variable. It populates an error
|
||||
// if there is no file for given file extensions.
|
||||
@@ -381,8 +408,15 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
|
||||
|
||||
// paths bind the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
// embeds are excluded.
|
||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
embeds, ok := form.filesByField[EmbedsFormField]
|
||||
|
||||
for filename, path := range form.files {
|
||||
if ok && slices.Contains(embeds, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ext := range extensions {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||
|
||||
@@ -1612,6 +1612,24 @@ func TestFormData_Paths(t *testing.T) {
|
||||
},
|
||||
expectCount: 2,
|
||||
},
|
||||
{
|
||||
scenario: "files except embeds",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo.pdf": "/foo.pdf",
|
||||
"embed_1.pdf": "/embed_1.pdf",
|
||||
"embed_2.xml": "/embed_2.xml",
|
||||
},
|
||||
filesByField: map[string][]string{
|
||||
"embeds": {"/embed_1.pdf", "/embed_2.xml"},
|
||||
},
|
||||
},
|
||||
extensions: []string{".pdf"},
|
||||
expect: []string{
|
||||
"/foo.pdf",
|
||||
},
|
||||
expectCount: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual []string
|
||||
@@ -1740,3 +1758,28 @@ func TestFormData_mustAssign(t *testing.T) {
|
||||
var target []string
|
||||
form.mustAssign("foo", "foo", &target)
|
||||
}
|
||||
|
||||
func TestFormData_Embeds(t *testing.T) {
|
||||
expected := []string{"/bar.xml", "/baz.xml"}
|
||||
|
||||
var actual []string
|
||||
form := &FormData{
|
||||
files: map[string]string{
|
||||
"foo.pdf": "/foo.pdf",
|
||||
"bar.xml": "/bar.xml",
|
||||
"baz.xml": "/baz.xml",
|
||||
},
|
||||
filesByField: map[string][]string{
|
||||
"embeds": {"/bar.xml", "/baz.xml"},
|
||||
},
|
||||
}
|
||||
form.Embeds(&actual)
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf("expected %d embeds but got %d", len(expected), len(actual))
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Errorf("expected %v but got %v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -370,7 +371,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -423,6 +424,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -433,7 +435,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -487,6 +489,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -506,7 +509,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -630,7 +633,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string, embedPaths []string) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
@@ -687,6 +690,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
|
||||
@@ -181,6 +181,11 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPa
|
||||
return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
|
||||
}
|
||||
|
||||
// EmbedFiles is not available in this implementation.
|
||||
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
|
||||
@@ -96,6 +96,11 @@ func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Log
|
||||
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// EmbedFiles is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||
|
||||
@@ -31,6 +31,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
|
||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||
|
||||
@@ -264,6 +265,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
}
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
|
||||
@@ -171,6 +171,34 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
|
||||
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
if len(filePaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
|
||||
|
||||
args := []string{
|
||||
"attachments", "add",
|
||||
inputPath,
|
||||
}
|
||||
args = append(args, filePaths...)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create command for attaching files: %w", err)
|
||||
}
|
||||
|
||||
_, err = cmd.Exec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("attach files with pdfcpu: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Encrypt adds password protection to a PDF file using pdfcpu.
|
||||
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
if userPassword == "" {
|
||||
|
||||
@@ -19,6 +19,7 @@ type multiPdfEngines struct {
|
||||
readMetadataEngines []gotenberg.PdfEngine
|
||||
writeMetadataEngines []gotenberg.PdfEngine
|
||||
passwordEngines []gotenberg.PdfEngine
|
||||
embedEngines []gotenberg.PdfEngine
|
||||
}
|
||||
|
||||
func newMultiPdfEngines(
|
||||
@@ -28,7 +29,8 @@ func newMultiPdfEngines(
|
||||
convertEngines,
|
||||
readMetadataEngines,
|
||||
writeMetadataEngines,
|
||||
passwordEngines []gotenberg.PdfEngine,
|
||||
passwordEngines,
|
||||
embedEngines []gotenberg.PdfEngine,
|
||||
) *multiPdfEngines {
|
||||
return &multiPdfEngines{
|
||||
mergeEngines: mergeEngines,
|
||||
@@ -38,6 +40,7 @@ func newMultiPdfEngines(
|
||||
readMetadataEngines: readMetadataEngines,
|
||||
writeMetadataEngines: writeMetadataEngines,
|
||||
passwordEngines: passwordEngines,
|
||||
embedEngines: embedEngines,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +241,31 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
|
||||
return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
|
||||
}
|
||||
|
||||
// EmbedFiles embeds files into a PDF using the first available
|
||||
// engine that supports file embedding.
|
||||
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.embedEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case embedErr := <-errChan:
|
||||
errored := multierr.AppendInto(&err, embedErr)
|
||||
if !errored {
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
||||
|
||||
@@ -34,6 +34,7 @@ type PdfEngines struct {
|
||||
readMetadataNames []string
|
||||
writeMetadataNames []string
|
||||
encryptNames []string
|
||||
embedNames []string
|
||||
engines []gotenberg.PdfEngine
|
||||
disableRoutes bool
|
||||
}
|
||||
@@ -51,6 +52,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
|
||||
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
|
||||
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
|
||||
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
|
||||
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||
|
||||
// Deprecated flags.
|
||||
@@ -77,6 +79,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
||||
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
||||
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
||||
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
|
||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||
|
||||
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
||||
@@ -138,6 +141,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
mod.encryptNames = encryptNames
|
||||
}
|
||||
|
||||
mod.embedNames = defaultNames
|
||||
if len(embedNames) > 0 {
|
||||
mod.embedNames = embedNames
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -192,6 +200,7 @@ func (mod *PdfEngines) Validate() error {
|
||||
findNonExistingEngines(mod.readMetadataNames)
|
||||
findNonExistingEngines(mod.writeMetadataNames)
|
||||
findNonExistingEngines(mod.encryptNames)
|
||||
findNonExistingEngines(mod.embedNames)
|
||||
|
||||
if len(nonExistingEngines) == 0 {
|
||||
return nil
|
||||
@@ -238,6 +247,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
||||
engines(mod.readMetadataNames),
|
||||
engines(mod.writeMetadataNames),
|
||||
engines(mod.encryptNames),
|
||||
engines(mod.embedNames),
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -262,6 +272,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
||||
readMetadataRoute(engine),
|
||||
writeMetadataRoute(engine),
|
||||
encryptRoute(engine),
|
||||
embedRoute(engine),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,14 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormDataPdfEmbeds extracts embedded file paths from form data.
|
||||
// Only files uploaded with the "embeds" field name are included.
|
||||
func FormDataPdfEmbeds(form *api.FormData) []string {
|
||||
var embedPaths []string
|
||||
form.Embeds(&embedPaths)
|
||||
return embedPaths
|
||||
}
|
||||
|
||||
// FormDataPdfEncrypt extracts encryption parameters from form data.
|
||||
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
||||
form.String("userPassword", &userPassword, "")
|
||||
@@ -277,6 +285,22 @@ func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword,
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedFilesStub embeds files into PDF files.
|
||||
func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error {
|
||||
if len(embedPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, inputPath := range inputPaths {
|
||||
err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeRoute returns an [api.Route] which can merge PDFs.
|
||||
func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
@@ -290,6 +314,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := FormDataPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -324,6 +349,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
@@ -353,6 +383,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := FormDataPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -386,6 +417,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
@@ -620,3 +656,38 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// embedRoute returns an [api.Route] which can add embedded files to PDFs.
|
||||
func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/embed",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
|
||||
var inputPaths []string
|
||||
err := form.
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(inputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,11 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedFiles is not available in this implementation.
|
||||
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*PdfTk)(nil)
|
||||
|
||||
@@ -201,6 +201,11 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedFiles is not available in this implementation.
|
||||
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
var (
|
||||
_ gotenberg.Module = (*QPdf)(nil)
|
||||
_ gotenberg.Provisioner = (*QPdf)(nil)
|
||||
|
||||
@@ -942,3 +942,19 @@ Feature: /forms/chromium/convert/html
|
||||
| files | testdata/page-1-html/index.html | file |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
|
||||
@embed
|
||||
Scenario: POST /forms/chromium/convert/html (Embeds)
|
||||
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/page-1-html/index.html | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
|
||||
@@ -1075,3 +1075,20 @@ Feature: /forms/chromium/convert/markdown
|
||||
| files | testdata/page-1-markdown/page_1.md | file |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
|
||||
@embed
|
||||
Scenario: POST /forms/chromium/convert/markdown (Embeds)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-1-markdown/index.html | file |
|
||||
| files | testdata/page-1-markdown/page_1.md | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
|
||||
@@ -1039,3 +1039,20 @@ Feature: /forms/chromium/convert/url
|
||||
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
|
||||
@embed
|
||||
Scenario: POST /foo/forms/chromium/convert/url (Embeds)
|
||||
Given I have a default Gotenberg container
|
||||
And 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):
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And there should be the following file(s) in the webhook request:
|
||||
| foo.pdf |
|
||||
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
|
||||
@@ -652,3 +652,19 @@ Feature: /forms/libreoffice/convert
|
||||
| files | testdata/page_1.docx | file |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
|
||||
@embed
|
||||
Scenario: POST /forms/libreoffice/convert (Embeds)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
|
||||
32
test/integration/features/pdfengines_embeds.feature
Normal file
32
test/integration/features/pdfengines_embeds.feature
Normal file
@@ -0,0 +1,32 @@
|
||||
@embed
|
||||
Feature: /forms/pdfengines/embed
|
||||
|
||||
Scenario: POST /forms/pdfengines/embed
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| embeds | testdata/page_2.pdf | file |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And there should be the following file(s) in the response:
|
||||
| page_1.pdf |
|
||||
And the "page_1.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "page_1.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
And the "page_1.pdf" PDF should have the "page_2.pdf" file embedded in it
|
||||
|
||||
Scenario: POST /forms/pdfengines/embed with (Download From)
|
||||
Given I have a default Gotenberg container
|
||||
And I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/embed_1.xml", "embedded": true},{"url":"http://host.docker.internal:%d/static/testdata/embed_2.xml", "embedded": false}] | field |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And there should be the following file(s) in the response:
|
||||
| page_1.pdf |
|
||||
And the "page_1.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "page_1.pdf" PDF should NOT have the "embed_2.xml" file embedded in it
|
||||
@@ -368,3 +368,16 @@ Feature: /forms/pdfengines/merge
|
||||
| files | testdata/page_2.pdf | file |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
|
||||
@embed
|
||||
Scenario: POST /foo/forms/pdfengines/merge (Embeds)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| files | testdata/page_2.pdf | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
And the "foo.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "foo.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
|
||||
@@ -650,3 +650,23 @@ Feature: /forms/pdfengines/split
|
||||
| splitSpan | 2 | field |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/zip"
|
||||
|
||||
@embed
|
||||
Scenario: POST /foo/forms/pdfengines/split (Embeds)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
|
||||
| files | testdata/pages_3.pdf | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| splitMode | intervals | field |
|
||||
| splitSpan | 2 | field |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/zip"
|
||||
And there should be 2 PDF(s) in the response
|
||||
And there should be the following file(s) in the response:
|
||||
| pages_3_0.pdf |
|
||||
| pages_3_1.pdf |
|
||||
And the "pages_3_0.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "pages_3_0.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
And the "pages_3_1.pdf" PDF should have the "embed_1.xml" file embedded in it
|
||||
And the "pages_3_1.pdf" PDF should have the "embed_2.xml" file embedded in it
|
||||
|
||||
@@ -735,6 +735,41 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) thePdfShouldHaveTheFollowingEmbedsInIt(ctx context.Context, name, should string, embed string) error {
|
||||
path, err := s.getPath(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get path %q: %w", name, err)
|
||||
}
|
||||
invert := should == "should NOT"
|
||||
|
||||
cmd := []string{
|
||||
"verapdf",
|
||||
"--off",
|
||||
"--loglevel",
|
||||
"0",
|
||||
"--extract",
|
||||
"embeddedFile",
|
||||
name,
|
||||
}
|
||||
|
||||
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
found := strings.Contains(output, fmt.Sprintf("<fileName>%s</fileName>", embed))
|
||||
|
||||
if invert && found {
|
||||
return fmt.Errorf("embed %q found", embed)
|
||||
}
|
||||
|
||||
if !invert && !found {
|
||||
return fmt.Errorf("embed %q not found", embed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
|
||||
var path string
|
||||
if !strings.HasPrefix(name, "*_") {
|
||||
@@ -927,6 +962,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the "([^"]*)" file embedded in it$`, s.thePdfShouldHaveTheFollowingEmbedsInIt)
|
||||
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||
if s.gotenbergContainer != nil {
|
||||
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
||||
@@ -950,3 +986,14 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
return ctx, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scenario) getPath(name string) (string, error) {
|
||||
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("PDF %q does not exist", path)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
5
test/integration/testdata/embed_1.xml
vendored
Normal file
5
test/integration/testdata/embed_1.xml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<xml>
|
||||
<test>test 1.1</test>
|
||||
<test>test 1.2</test>
|
||||
<test>test 1.3</test>
|
||||
</xml>
|
||||
5
test/integration/testdata/embed_2.xml
vendored
Normal file
5
test/integration/testdata/embed_2.xml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<xml>
|
||||
<test>test 2.1</test>
|
||||
<test>test 2.2</test>
|
||||
<test>test 2.3</test>
|
||||
</xml>
|
||||
Reference in New Issue
Block a user