Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 84 additions & 1 deletion cmd/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ var (
progressive bool
renderAnnotations bool
renderForm bool
cropPixels string
cropPoints string
cropRelative string
)

func init() {
Expand All @@ -43,14 +46,17 @@ 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)
}

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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -220,6 +301,7 @@ var renderCmd = &cobra.Command{
Height: maxHeight,
RenderFlags: renderFlags,
RenderForm: renderForm,
Crop: renderCrop,
},
},
Padding: padding,
Expand All @@ -232,6 +314,7 @@ var renderCmd = &cobra.Command{
DPI: dpi,
RenderFlags: renderFlags,
RenderForm: renderForm,
Crop: renderCrop,
},
},
Padding: padding,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
107 changes: 107 additions & 0 deletions pdf/crop.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading