diff --git a/README.md b/README.md index 178245f..a2ead71 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ * Get information of a PDF * Merge multiple PDFs into a single PDF * Exploding PDFs into one PDF file per page -* Rendering PDFs in JPG and PNG +* Rendering PDFs in JPG and PNG, either whole pages or a region of a page * Extracting text from PDFs * Extracting images from PDFs * Extracting attachments from PDFs diff --git a/cmd/render.go b/cmd/render.go index da39846..25f4d06 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -26,6 +26,9 @@ var ( progressive bool renderAnnotations bool renderForm bool + cropPixels string + cropPoints string + cropRelative string ) func init() { @@ -43,6 +46,9 @@ func init() { renderCmd.Flags().BoolVarP(&progressive, "progressive", "", false, "Create progressive images, only used for jpeg.") renderCmd.Flags().BoolVarP(&renderAnnotations, "render-annotations", "", false, "Render annotations that are embedded in the PDF.") renderCmd.Flags().BoolVarP(&renderForm, "render-form", "", false, "Render form fields that are embedded in the PDF.") + renderCmd.Flags().StringVarP(&cropPixels, "crop-px", "", "", "Render only a region of the page instead of the whole page, given as \"x,y,width,height\" in pixels of the full page as it would be rendered in the given dpi, for example --crop-px \"1000,500,800,600\". The origin is the top-left corner of the page. Can only be used when rendering a single page, use the pages option to select the page.") + renderCmd.Flags().StringVarP(&cropPoints, "crop-points", "", "", "The same as crop-px, but in points, where one point is 1/72 inch. This is the unit that the info command reports page sizes in, for example --crop-points \"36,36,144,72\".") + renderCmd.Flags().StringVarP(&cropRelative, "crop-relative", "", "", "The same as crop-px, but as a fraction of the page size, where 1 is the full width or height of the page, for example --crop-relative \"0.25,0.1,0.5,0.2\".") rootCmd.AddCommand(renderCmd) } @@ -50,7 +56,7 @@ func init() { var renderCmd = &cobra.Command{ Use: "render [input] [output]", Short: "Render a PDF into images", - Long: "Render a PDF into images.\n[input] can either be a file path or - for stdin.\n[output] can either be a file path or - for stdout. or - for stdout. In the case of stdout, multiple files will be delimited by the value of the std-file-delimiter, with a newline before and after it. The output filename should contain a \"%d\" placeholder for the page number when rendering more than one page and when not using the combine-pages option, e.g. render invoice.pdf invoice-%d.jpg, the result for a 2-page PDF will be invoice-1.jpg and invoice-2.jpg.", + Long: "Render a PDF into images.\n[input] can either be a file path or - for stdin.\n[output] can either be a file path or - for stdout. or - for stdout. In the case of stdout, multiple files will be delimited by the value of the std-file-delimiter, with a newline before and after it. The output filename should contain a \"%d\" placeholder for the page number when rendering more than one page and when not using the combine-pages option, e.g. render invoice.pdf invoice-%d.jpg, the result for a 2-page PDF will be invoice-1.jpg and invoice-2.jpg.\nUse one of the crop options to render only a region of a page instead of the whole page, e.g. render blueprint.pdf detail.jpg --pages 2 --crop-points \"36,36,144,72\" renders a region of 144 by 72 points, 36 points from the left and the top of page 2. The region is rendered directly in the requested resolution, it is not cut out of a render of the full page. The dpi, max-width and max-height options apply to the region instead of to the full page, so --crop-px \"1000,500,800,600\" --max-width 1600 gives an image of 1600 pixels wide of that region. A region may run past the edges of the page, the part that falls outside of the page gets the background color, which makes it possible to cut a page into equally sized tiles.", Args: func(cmd *cobra.Command, args []string) error { if err := cobra.ExactArgs(2)(cmd, args); err != nil { return newExitCodeError(err, ExitCodeInvalidArguments) @@ -63,6 +69,48 @@ var renderCmd = &cobra.Command{ return nil }, Run: func(cmd *cobra.Command, args []string) { + var parsedCrop *pdf.PageCrop + cropUnit := pdf.CropUnitPixels + + givenCrops := map[string]pdf.CropUnit{} + if cropPixels != "" { + givenCrops["crop-px"] = pdf.CropUnitPixels + } + if cropPoints != "" { + givenCrops["crop-points"] = pdf.CropUnitPoints + } + if cropRelative != "" { + givenCrops["crop-relative"] = pdf.CropUnitRelative + } + + if len(givenCrops) > 1 { + handleError(cmd, fmt.Errorf("only one of the crop-px, crop-points and crop-relative options can be used at the same time\n"), ExitCodeInvalidArguments) + return + } + + for cropOption, unit := range givenCrops { + cropValue := cropPixels + if unit == pdf.CropUnitPoints { + cropValue = cropPoints + } else if unit == pdf.CropUnitRelative { + cropValue = cropRelative + } + + crop, err := pdf.ParsePageCrop(cropValue) + if err != nil { + handleError(cmd, fmt.Errorf("invalid %s '%s': %w\n", cropOption, cropValue, err), ExitCodeInvalidArguments) + return + } + + parsedCrop = crop + cropUnit = unit + } + + if parsedCrop != nil && combinePages { + handleError(cmd, fmt.Errorf("the crop options can not be used together with the combine-pages option\n"), ExitCodeInvalidArguments) + return + } + err := pdf.LoadPdfium() if err != nil { handleError(cmd, fmt.Errorf("could not load pdfium: %w\n", newPdfiumError(err)), ExitCodePdfiumError) @@ -99,6 +147,11 @@ var renderCmd = &cobra.Command{ renderPages := []requests.Page{} splitPages := strings.Split(*parsedPageRange, ",") + if parsedCrop != nil && len(splitPages) > 1 { + handleError(cmd, fmt.Errorf("the crop options can only be used when rendering a single page, the page range '%s' resolves to %d pages, use the pages option to select one page, for example --pages 1\n", pageRange, len(splitPages)), ExitCodeInvalidArguments) + return + } + if len(splitPages) > 1 && !combinePages { if args[1] != stdFilename && !strings.Contains(args[1], "%d") { handleError(cmd, fmt.Errorf("output string %s should contain page pattern %%d\n", args[1]), ExitCodeInvalidArguments) @@ -131,6 +184,34 @@ var renderCmd = &cobra.Command{ renderFlags = enums.FPDF_RENDER_FLAG_ANNOT } + // The crop options are all converted to points here, which is the unit + // that pdfium renders regions in. Converting a relative region needs the + // size of the page, and knowing the size also lets us tell the user what + // they should have used when the region misses the page completely. + var renderCrop *requests.RenderPageCrop + if parsedCrop != nil { + pageSize, err := pdf.PdfiumInstance.GetPageSize(&requests.GetPageSize{ + Page: renderPages[0], + }) + if err != nil { + handleError(cmd, fmt.Errorf("could not get page size for page %s of PDF %s: %w\n", splitPages[0], args[0], newPdfiumError(err)), ExitCodePdfiumError) + return + } + + cropInPoints := parsedCrop.ToPoints(cropUnit, pageSize.Width, pageSize.Height, dpi) + if cropInPoints.IsOutsidePage(pageSize.Width, pageSize.Height) { + handleError(cmd, fmt.Errorf("the given crop falls completely outside of page %s, which is %.2f x %.2f points, the crop is %.2f x %.2f points at %.2f,%.2f, measured from the top-left corner of the page\n", splitPages[0], pageSize.Width, pageSize.Height, cropInPoints.Width, cropInPoints.Height, cropInPoints.X, cropInPoints.Y), ExitCodeInvalidArguments) + return + } + + renderCrop = &requests.RenderPageCrop{ + X: cropInPoints.X, + Y: cropInPoints.Y, + Width: cropInPoints.Width, + Height: cropInPoints.Height, + } + } + if combinePages { renderRequest := &requests.RenderToFile{ OutputFormat: outputFormat, @@ -220,6 +301,7 @@ var renderCmd = &cobra.Command{ Height: maxHeight, RenderFlags: renderFlags, RenderForm: renderForm, + Crop: renderCrop, }, }, Padding: padding, @@ -232,6 +314,7 @@ var renderCmd = &cobra.Command{ DPI: dpi, RenderFlags: renderFlags, RenderForm: renderForm, + Crop: renderCrop, }, }, Padding: padding, diff --git a/go.mod b/go.mod index 06e899b..6d13a9e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/klippa-app/pdfium-cli go 1.26.0 require ( - github.com/klippa-app/go-pdfium v1.19.6 + github.com/klippa-app/go-pdfium v1.19.8-0.20260811081938-21e1a228c007 github.com/spf13/cobra v1.10.2 ) diff --git a/go.sum b/go.sum index 05692f4..43dfbd0 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccS github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY= github.com/klippa-app/go-pdfium v1.19.6 h1:MocMc/6ie+9eBFu+9tFMX66i3EDqnsMDoBtOy9Mx2/g= github.com/klippa-app/go-pdfium v1.19.6/go.mod h1:poSiUJYFicnfT8dazUfVnxcqPlKQxO3aHdGbOa1lG1w= +github.com/klippa-app/go-pdfium v1.19.8-0.20260811081938-21e1a228c007 h1:sZYf74PhZzn3FUYTm6RXcyfjZrKJIL7/YGgKeYTFkS0= +github.com/klippa-app/go-pdfium v1.19.8-0.20260811081938-21e1a228c007/go.mod h1:poSiUJYFicnfT8dazUfVnxcqPlKQxO3aHdGbOa1lG1w= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= diff --git a/pdf/crop.go b/pdf/crop.go new file mode 100644 index 0000000..0d63af6 --- /dev/null +++ b/pdf/crop.go @@ -0,0 +1,107 @@ +package pdf + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// CropUnit is the unit that the values of a crop region are given in. +type CropUnit int + +const ( + // CropUnitPixels means that the values are pixels of the full page as it + // would be rendered in the requested DPI. + CropUnitPixels CropUnit = iota + + // CropUnitPoints means that the values are points, one point is 1/72 inch. + // This is the same unit that the info command reports page sizes in. + CropUnitPoints + + // CropUnitRelative means that the values are a fraction of the page size, + // where 1 is the full width or height of the page. + CropUnitRelative +) + +// PageCrop is a rectangular region of a page. The origin (0,0) is the top-left +// corner of the page as it is rendered, and Y grows downwards. +type PageCrop struct { + X float64 + Y float64 + Width float64 + Height float64 +} + +// ParsePageCrop parses a crop region in the format "x,y,width,height". +// Whitespace around the values is ignored. The values are not converted to a +// unit here, use ToPoints for that. +// +// The X and Y are allowed to be negative, a region that partly falls outside of +// the page is valid, so that a page can be cut into equally sized tiles. +func ParsePageCrop(crop string) (*PageCrop, error) { + cropParts := strings.Split(strings.TrimSpace(crop), ",") + if len(cropParts) != 4 { + return nil, fmt.Errorf("a crop must have 4 comma separated values (x,y,width,height), got %d", len(cropParts)) + } + + cropNames := [4]string{"x", "y", "width", "height"} + cropValues := [4]float64{} + for i := range cropParts { + cropPart := strings.TrimSpace(cropParts[i]) + + parsedValue, err := strconv.ParseFloat(cropPart, 64) + if err != nil || math.IsNaN(parsedValue) || math.IsInf(parsedValue, 0) { + return nil, fmt.Errorf("crop %s '%s' is not a valid number", cropNames[i], cropPart) + } + + cropValues[i] = parsedValue + } + + if cropValues[2] <= 0 { + return nil, fmt.Errorf("crop width must be larger than 0") + } + + if cropValues[3] <= 0 { + return nil, fmt.Errorf("crop height must be larger than 0") + } + + return &PageCrop{ + X: cropValues[0], + Y: cropValues[1], + Width: cropValues[2], + Height: cropValues[3], + }, nil +} + +// ToPoints converts a crop region into points, which is the unit that pdfium +// renders regions in. The page size has to be given in points, and the DPI is +// the DPI that pixel values are relative to. +func (c PageCrop) ToPoints(unit CropUnit, pageWidth, pageHeight float64, dpi int) PageCrop { + switch unit { + case CropUnitPixels: + scale := float64(dpi) / 72.0 + return PageCrop{ + X: c.X / scale, + Y: c.Y / scale, + Width: c.Width / scale, + Height: c.Height / scale, + } + case CropUnitRelative: + return PageCrop{ + X: c.X * pageWidth, + Y: c.Y * pageHeight, + Width: c.Width * pageWidth, + Height: c.Height * pageHeight, + } + default: + return c + } +} + +// IsOutsidePage returns whether the region does not overlap the page at all. +// A region that only partly falls outside of the page is fine, the part that +// falls outside of the page is filled with the background color. +func (c PageCrop) IsOutsidePage(pageWidth, pageHeight float64) bool { + return c.X >= pageWidth || c.Y >= pageHeight || c.X+c.Width <= 0 || c.Y+c.Height <= 0 +} diff --git a/pdf/crop_test.go b/pdf/crop_test.go new file mode 100644 index 0000000..65e2008 --- /dev/null +++ b/pdf/crop_test.go @@ -0,0 +1,261 @@ +package pdf + +import ( + "testing" +) + +func TestParsePageCrop(t *testing.T) { + tests := []struct { + name string + crop string + want PageCrop + wantErr string + }{ + { + "test a simple crop", + "0,0,100,200", + PageCrop{X: 0, Y: 0, Width: 100, Height: 200}, + "", + }, + { + "test a crop with an offset", + "36,72,144.5,72.25", + PageCrop{X: 36, Y: 72, Width: 144.5, Height: 72.25}, + "", + }, + { + "test a crop with whitespace", + " 36 , 72 , 144 , 72 ", + PageCrop{X: 36, Y: 72, Width: 144, Height: 72}, + "", + }, + { + "test a crop in scientific notation", + "1e1,2e1,1.5e2,1e2", + PageCrop{X: 10, Y: 20, Width: 150, Height: 100}, + "", + }, + { + "test a crop with an explicit plus", + "+10,+20,+30,+40", + PageCrop{X: 10, Y: 20, Width: 30, Height: 40}, + "", + }, + { + "test a relative crop", + "0,0,0.5,0.5", + PageCrop{X: 0, Y: 0, Width: 0.5, Height: 0.5}, + "", + }, + { + "test a crop that starts before the page", + "-50,-25,100,100", + PageCrop{X: -50, Y: -25, Width: 100, Height: 100}, + "", + }, + { + "test a crop with too few values", + "1,2,3", + PageCrop{}, + "a crop must have 4 comma separated values (x,y,width,height), got 3", + }, + { + "test a crop with too many values", + "1,2,3,4,5", + PageCrop{}, + "a crop must have 4 comma separated values (x,y,width,height), got 5", + }, + { + "test an empty crop", + "", + PageCrop{}, + "a crop must have 4 comma separated values (x,y,width,height), got 1", + }, + { + "test a crop with only whitespace", + " ", + PageCrop{}, + "a crop must have 4 comma separated values (x,y,width,height), got 1", + }, + { + "test a crop with the wrong separator", + "0;0;100;100", + PageCrop{}, + "a crop must have 4 comma separated values (x,y,width,height), got 1", + }, + { + "test a crop with a trailing separator", + "0,0,100,100,", + PageCrop{}, + "a crop must have 4 comma separated values (x,y,width,height), got 5", + }, + { + "test a crop with a value that is not a number", + "1,2,abc,4", + PageCrop{}, + "crop width 'abc' is not a valid number", + }, + { + "test a crop with an empty value", + "1,,3,4", + PageCrop{}, + "crop y '' is not a valid number", + }, + { + "test a crop with a unit suffix", + "10pt,0,100,100", + PageCrop{}, + "crop x '10pt' is not a valid number", + }, + { + "test a crop with a percentage", + "0,0,50%,100", + PageCrop{}, + "crop width '50%' is not a valid number", + }, + { + "test a crop that is not a number", + "NaN,0,100,100", + PageCrop{}, + "crop x 'NaN' is not a valid number", + }, + { + "test a crop that is infinite", + "0,Inf,100,100", + PageCrop{}, + "crop y 'Inf' is not a valid number", + }, + { + "test a crop that is out of range", + "1e999,0,100,100", + PageCrop{}, + "crop x '1e999' is not a valid number", + }, + { + "test a crop with a negative width", + "0,0,-100,100", + PageCrop{}, + "crop width must be larger than 0", + }, + { + "test a crop without a width", + "0,0,0,100", + PageCrop{}, + "crop width must be larger than 0", + }, + { + "test a crop with a negative height", + "0,0,100,-100", + PageCrop{}, + "crop height must be larger than 0", + }, + { + "test a crop without a height", + "0,0,100,0", + PageCrop{}, + "crop height must be larger than 0", + }, + } + + for i := range tests { + t.Run(tests[i].name, func(t *testing.T) { + parsedCrop, err := ParsePageCrop(tests[i].crop) + if tests[i].wantErr == "" && err != nil { + t.Errorf("expected no error but got error %s", err.Error()) + } else if tests[i].wantErr != "" && err == nil { + t.Errorf("expected error %s but got no error", tests[i].wantErr) + } else if tests[i].wantErr != "" && err != nil && err.Error() != tests[i].wantErr { + t.Errorf("expected error %s but got error %s", tests[i].wantErr, err.Error()) + } else if err == nil && tests[i].want != *parsedCrop { + t.Errorf("expected %+v but got %+v", tests[i].want, *parsedCrop) + } + }) + } +} + +func TestPageCropToPoints(t *testing.T) { + // A4 in points. + const pageWidth = 595.2755737304688 + const pageHeight = 841.8897094726562 + + tests := []struct { + name string + crop PageCrop + unit CropUnit + dpi int + want PageCrop + }{ + { + "test that points are used as they are", + PageCrop{X: 100, Y: 200, Width: 150, Height: 120}, + CropUnitPoints, + 200, + PageCrop{X: 100, Y: 200, Width: 150, Height: 120}, + }, + { + "test that pixels in 72 dpi are the same as points", + PageCrop{X: 100, Y: 200, Width: 150, Height: 120}, + CropUnitPixels, + 72, + PageCrop{X: 100, Y: 200, Width: 150, Height: 120}, + }, + { + "test that pixels are converted with the dpi", + PageCrop{X: 100, Y: 200, Width: 150, Height: 300}, + CropUnitPixels, + 144, + PageCrop{X: 50, Y: 100, Width: 75, Height: 150}, + }, + { + "test that a relative crop is converted with the page size", + PageCrop{X: 0, Y: 0, Width: 1, Height: 1}, + CropUnitRelative, + 200, + PageCrop{X: 0, Y: 0, Width: pageWidth, Height: pageHeight}, + }, + { + "test that half a relative crop is half the page", + PageCrop{X: 0.5, Y: 0.5, Width: 0.5, Height: 0.5}, + CropUnitRelative, + 200, + PageCrop{X: pageWidth / 2, Y: pageHeight / 2, Width: pageWidth / 2, Height: pageHeight / 2}, + }, + } + + for i := range tests { + t.Run(tests[i].name, func(t *testing.T) { + converted := tests[i].crop.ToPoints(tests[i].unit, pageWidth, pageHeight, tests[i].dpi) + if converted != tests[i].want { + t.Errorf("expected %+v but got %+v", tests[i].want, converted) + } + }) + } +} + +func TestPageCropIsOutsidePage(t *testing.T) { + const pageWidth = 595.2755737304688 + const pageHeight = 841.8897094726562 + + tests := []struct { + name string + crop PageCrop + want bool + }{ + {"test a crop inside the page", PageCrop{X: 100, Y: 100, Width: 100, Height: 100}, false}, + {"test a crop that covers the page", PageCrop{X: 0, Y: 0, Width: pageWidth, Height: pageHeight}, false}, + {"test a crop that runs past the right of the page", PageCrop{X: 500, Y: 100, Width: 200, Height: 100}, false}, + {"test a crop that starts before the page", PageCrop{X: -50, Y: -50, Width: 100, Height: 100}, false}, + {"test a crop fully to the right of the page", PageCrop{X: 600, Y: 100, Width: 100, Height: 100}, true}, + {"test a crop fully below the page", PageCrop{X: 100, Y: 900, Width: 100, Height: 100}, true}, + {"test a crop fully to the left of the page", PageCrop{X: -200, Y: 100, Width: 100, Height: 100}, true}, + {"test a crop fully above the page", PageCrop{X: 100, Y: -200, Width: 100, Height: 100}, true}, + } + + for i := range tests { + t.Run(tests[i].name, func(t *testing.T) { + if got := tests[i].crop.IsOutsidePage(pageWidth, pageHeight); got != tests[i].want { + t.Errorf("expected %v but got %v", tests[i].want, got) + } + }) + } +}