diff --git a/cmd/pdfcpu/process.go b/cmd/pdfcpu/process.go index 6c4e3f5f..b793f287 100644 --- a/cmd/pdfcpu/process.go +++ b/cmd/pdfcpu/process.go @@ -39,8 +39,6 @@ import ( "github.com/pkg/errors" ) -var errInvalidBookletID = errors.New("pdfcpu: booklet: n: one of 2, 4") - func hasPDFExtension(filename string) bool { return strings.HasSuffix(strings.ToLower(filename), ".pdf") } @@ -1211,34 +1209,51 @@ func processRotateCommand(conf *model.Configuration) { process(cli.RotateCommand(inFile, outFile, rotation, selectedPages, conf)) } -func parseAfterNUpDetails(nup *model.NUp, argInd int, filenameOut string) []string { - if nup.PageGrid { - cols, err := strconv.Atoi(flag.Arg(argInd)) - if err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(1) - } - rows, err := strconv.Atoi(flag.Arg(argInd + 1)) - if err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(1) - } - if err = pdfcpu.ParseNUpGridDefinition(cols, rows, nup); err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(1) +func parseForGrid(nup *model.NUp, argInd *int) { + cols, err := strconv.Atoi(flag.Arg(*argInd)) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + rows, err := strconv.Atoi(flag.Arg(*argInd + 1)) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + if err = pdfcpu.ParseNUpGridDefinition(cols, rows, nup); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + *argInd += 2 +} + +func parseForNUp(nup *model.NUp, argInd *int, nUpValues []int) { + n, err := strconv.Atoi(flag.Arg(*argInd)) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + if !types.IntMemberOf(n, nUpValues) { + ss := make([]string, len(nUpValues)) + for i, v := range nUpValues { + ss[i] = strconv.Itoa(v) } - argInd += 2 + err := errors.Errorf("pdfcpu: n must be one of %s", strings.Join(ss, ", ")) + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + if err = pdfcpu.ParseNUpValue(n, nup); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + *argInd++ +} + +func parseAfterNUpDetails(nup *model.NUp, argInd int, nUpValues []int, filenameOut string) []string { + if nup.PageGrid { + parseForGrid(nup, &argInd) } else { - n, err := strconv.Atoi(flag.Arg(argInd)) - if err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(1) - } - if err = pdfcpu.ParseNUpValue(n, nup); err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(1) - } - argInd++ + parseForNUp(nup, &argInd, nUpValues) } filenameIn := flag.Arg(argInd) @@ -1307,7 +1322,7 @@ func processNUpCommand(conf *model.Configuration) { // pdfcpu nup outFile n inFile|imageFiles... // If no optional 'description' argument provided use default nup configuration. - inFiles := parseAfterNUpDetails(nup, argInd, outFile) + inFiles := parseAfterNUpDetails(nup, argInd, pdfcpu.NUpValues, outFile) process(cli.NUpCommand(inFiles, outFile, pages, nup, conf)) } @@ -1345,7 +1360,7 @@ func processGridCommand(conf *model.Configuration) { // pdfcpu grid outFile m n inFile|imageFiles... // If no optional 'description' argument provided use default nup configuration. - inFiles := parseAfterNUpDetails(nup, argInd, outFile) + inFiles := parseAfterNUpDetails(nup, argInd, nil, outFile) process(cli.NUpCommand(inFiles, outFile, pages, nup, conf)) } @@ -1383,12 +1398,7 @@ func processBookletCommand(conf *model.Configuration) { // pdfcpu booklet outFile n inFile|imageFiles... // If no optional 'description' argument provided use default nup configuration. - inFiles := parseAfterNUpDetails(nup, argInd, outFile) - n := nup.Grid.Width * nup.Grid.Height - if n != 2 && n != 4 { - fmt.Fprintf(os.Stderr, "%s\n", errInvalidBookletID) - os.Exit(1) - } + inFiles := parseAfterNUpDetails(nup, argInd, pdfcpu.NUpValuesForBooklets, outFile) process(cli.BookletCommand(inFiles, outFile, pages, nup, conf)) } diff --git a/cmd/pdfcpu/usage.go b/cmd/pdfcpu/usage.go index fc62f399..fc585329 100644 --- a/cmd/pdfcpu/usage.go +++ b/cmd/pdfcpu/usage.go @@ -664,34 +664,62 @@ Examples: pdfcpu nup out.pdf 4 in.pdf pages ... for inFile only, please refer to "pdfcpu selectedpages" description ... dimensions, formsize, border, margin outFile ... output PDF file - n ... booklet style (2 or 4) + n ... booklet style (2, 4, 6, 8) inFile ... input PDF file imageFiles ... input image file(s) -There are two styles of booklet, depending on your page/input and sheet/output size: +There are several styles of booklet, depending on your page/input and sheet/output size, +the edge along which your booklet will be bound, +and your preferred method for creating the booklet. -n=2: Two of your pages fit on one side of a sheet (eg statement on letter, A5 on A4) +For assembly instructions for each type, see: https://pdfcpu.io/generate/booklet + +n=2: This is the simplest case and the most common for those printing at home. +Two of your pages fit on one side of a sheet (eg statement on letter, A5 on A4) Assemble by printing on both sides (odd pages on the front and even pages on the back) and folding down the middle. -A variant of n=2 is a technique to bind your own hardback book. -It works best when the source PDF holding your book content has at least 128 pages. -You bind your paper in eight sheet folios each making up 32 pages of your book. -Each sheet is going to make four pages of your book, gets printed on both sides and folded in half. +A variant of n=2 is multifolio, a technique to bind your own hardback book. +This technique makes the most sense when your book has at least 128 pages. +For example, you can bind your paper in eight sheet folios (also known as signatures), with each folio containing 32 pages of your book. For such a multi folio booklet set 'multifolio:on' and play around with 'foliosize' which defaults to 8. -n=4: Four of your pages fit on one side of a sheet (eg statement on ledger, A5 on A3, A6 on A4) -Assemble by printing on both sides, then cutting the sheets horizontally. -The sets of pages on the bottom of the sheet are rotated so that the cut side of the -paper is on the bottom of the booklet for every page. After cutting, place the bottom -set of pages after the top set of pages in the booklet. Then fold the half sheets. +n=4: Four of your pages fit on one side of a sheet (eg statement on ledger, A5 on A3, A6 on A4). + +When printing 4-up, your booklet can be bound either along the long-edge (for portrait this is the left side of the paper, for landscape the top) +or the short-edge (for portrait this is the top of the paper, for landscape the left side). +Using a different binding will change the ordering of the pages on the sheet. +You can set long or short-edge with the 'binding' option. + +In 4-up printing, the sets of pages on the bottom of the sheet are rotated so that the cut side of the +paper is on the bottom of the booklet for every page (for the default portrait, long-edge binding case. +Similar rotation logic applies for the other three orientations). +Having the cut edge always on bottom makes for more uniform pages within the book and less work in trimming. + +The btype=advanced is a special method for assembling, only for 4-up booklets. +Printers that are used to collating first and then cutting may prefer this method. + +n=6: Six of your pages fit on one side of a sheet. This produces an unusual sized booklet. + +Only available for portrait, long-edge orientation. + +n=8: Eight of your pages fit on one side of a sheet (eg A6 on A3). + +Only available for portrait, long-edge orientation. + +Perfect binding is a special type of booklet. The main difference is that the binding is glued into a spine, +meaning that the pages are cut along the binding and not folded as in the other forms of booklet. +This results in a different page ordering on the sheet than the other methods. If you intend to perfect bind your booklet, +use btype=perfectbound. portrait landscape - Possible values for n: 2 ... 1x2 2x1 + Possible values for n: 2 ... 1x2 -- 4 ... 2x2 2x2 + 6 ... 2x3 -- + 8 ... 2x4 -- is a comma separated configuration string containing these optional entries: - (defaults: "dim:595 842, formsize:A4, border:off, guides:off, margin:0") + (defaults: "dim:595 842, formsize:A4, btype: booklet, binding: long, multifolio: false, border:off, guides:off, margin:0") dimensions: (width,height) of the output sheet in given display unit eg. '400 200' formsize: The output sheet size, eg. A4, Letter, Legal... @@ -700,6 +728,8 @@ set of pages after the top set of pages in the booklet. Then fold the half sheet Only one of dimensions or format is allowed. Please refer to "pdfcpu paper" for a comprehensive list of defined paper sizes. "papersize" is also accepted. + btype: The method for arranging pages into a booklet. (booklet, bookletadvanced, perfectbound) + binding: The edge of the paper which has the binding. (long, short) multifolio: Generate multi folio booklet (on/off, true/false, t/f) for n=2 and PDF input only. foliosize: folio size for multi folio booklets only (default:8) border: Print border (on/off, true/false, t/f) @@ -710,18 +740,32 @@ set of pages after the top set of pages in the booklet. Then fold the half sheet All configuration string parameters support completion. -Examples: pdfcpu booklet -- "formsize:Letter" out.pdf 2 in.pdf - Arrange pages of in.pdf 2 per sheet side (4 per sheet, back and front) onto out.pdf +Examples: - pdfcpu booklet -- "formsize:Ledger" out.pdf 4 in.pdf" - Arrange pages of in.pdf 4 per sheet side (8 per sheet, back and front) onto out.pdf + pdfcpu booklet -- "formsize:Letter" out.pdf 2 in.pdf + Arrange pages of in.pdf 2 per sheet side (4 per sheet, back and front) onto out.pdf - pdfcpu booklet -- "formsize:A4" out.pdf 2 in.pdf - Arrange pages of in.pdf 2 per sheet side (4 per sheet, back and front) onto out.pdf + pdfcpu booklet -- "formsize:Ledger" out.pdf 4 in.pdf + Arrange pages of in.pdf 4 per sheet side (8 per sheet, back and front) onto out.pdf + + pdfcpu booklet -- "formsize:Ledger" out.pdf 6 in.pdf + Arrange pages of in.pdf 6 per sheet side (12 per sheet, back and front) onto out.pdf + + pdfcpu booklet -- "formsize:A3" out.pdf 8 in.pdf + Arrange pages of in.pdf 8 per sheet side (16 per sheet, back and front) onto out.pdf - pdfcpu booklet -- "formsize:A4, multifolio:on" hardbackbook.pdf 2 in.pdf - Arrange pages of in.pdf 2 per sheetside as sequence of folios covering 4*foliosize pages each. - See also: https://www.instructables.com/How-to-bind-your-own-Hardback-Book/ + pdfcpu booklet -- "formsize:A3, binding:short" out.pdf 4 in.pdf + Arrange pages of in.pdf 4 per sheet side, with short-edge binding onto out.pdf + + pdfcpu booklet -- "formsize:A4, multifolio:on" hardbackbook.pdf 2 in.pdf + Arrange pages of in.pdf 2 per sheetside as sequence of folios covering 4*foliosize pages each. + See also: https://www.instructables.com/How-to-bind-your-own-Hardback-Book/ + + pdfcpu booklet -- "formsize:A4, btype:perfectbound" out.pdf 2 in.pdf + Arrange pages of in.pdf 2 per sheet side, arranged for perfect binding, onto out.pdf + + pdfcpu booklet -- "formsize:A3, btype:bookletadvanced" out.pdf 4 in.pdf + Arrange pages of in.pdf 4 per sheet side, arranged for advanced binding, onto out.pdf ` usageGrid = "usage: pdfcpu grid [-p(ages) selectedPages] -- [description] outFile m n inFile|imageFiles..." + generalFlags diff --git a/pkg/api/test/booklet_test.go b/pkg/api/test/booklet_test.go index 0a80fa4a..04e3f47e 100644 --- a/pkg/api/test/booklet_test.go +++ b/pkg/api/test/booklet_test.go @@ -151,6 +151,91 @@ func TestBooklet(t *testing.T) { false, }, + // more nup + {"TestBookletFromPDF_2up_perfectbound", + []string{filepath.Join(inDir, "bookletTest.pdf")}, + filepath.Join(outDir, "BookletFromPDFLetter_2Up_perfectbound.pdf"), + []string{"1-24"}, + "p:LetterP, g:on, btype:perfectbound", + "points", + 2, + false, + }, + {"TestBookletFromPDF_6up", + []string{filepath.Join(inDir, "bookletTest.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_6Up.pdf"), + []string{"1-24"}, + "p:LedgerP, g:on", + "points", + 6, + false, + }, + {"TestBookletFromPDF_8up", + []string{filepath.Join(inDir, "bookletTest.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_8Up.pdf"), + []string{"1-32"}, + "p:LedgerP, g:on", + "points", + 8, + false, + }, + + // misc orientations and booklet types on 4-up + {"TestBookletFromPDF_4up_portrait_short", + []string{filepath.Join(inDir, "bookletTest.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_4Up_portrait_short.pdf"), + []string{"1-24"}, + "p:LedgerP, g:on, binding:short", + "points", + 4, + false, + }, + {"TestBookletFromPDF_4up_landscape_long", + []string{filepath.Join(inDir, "bookletTestLandscape.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_4Up_landscape_long.pdf"), + []string{"1-24"}, + "p:LedgerL, g:on", + "points", + 4, + false, + }, + {"TestBookletFromPDF_4up_landscape_short", + []string{filepath.Join(inDir, "bookletTestLandscape.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_4Up_landscape_short.pdf"), + []string{"1-24"}, + "p:LedgerL, g:on, binding:short", + "points", + 4, + false, + }, + {"TestBookletFromPDF_4up-portrait_long_advanced", + []string{filepath.Join(inDir, "bookletTest.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_4Up_portrait_long_advanced.pdf"), + []string{"1-24"}, + "p:LedgerP, g:on, btype:bookletadvanced", + "points", + 4, + false, + }, + {"TestBookletFromPDF_4up_landscape_short_advanced", + []string{filepath.Join(inDir, "bookletTestLandscape.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_4Up_landscape_short_advanced.pdf"), + []string{"1-24"}, + "p:LedgerL, g:on, binding:short, btype:bookletadvanced", + "points", + 4, + false, + }, + {"TestBookletFromPDF_4up_perfectbound", + []string{filepath.Join(inDir, "bookletTest.pdf")}, + filepath.Join(outDir, "BookletFromPDFLedger_4Up_perfectbound.pdf"), + []string{"1-24"}, + "p:LedgerP, g:on, btype:perfectbound", + "points", + 4, + false, + }, + // 2-up multi folio booklet from PDF on A4 using 8 sheets per folio // using the default foliosize:8 // Here we print 2 complete folios (2 x 8 sheets) + 1 partial folio diff --git a/pkg/pdfcpu/booklet.go b/pkg/pdfcpu/booklet.go index 5e1eb807..817a1328 100644 --- a/pkg/pdfcpu/booklet.go +++ b/pkg/pdfcpu/booklet.go @@ -20,12 +20,19 @@ import ( "bytes" "fmt" "os" + "strconv" + "strings" "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/draw" "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" + "github.com/pkg/errors" ) +var errInvalidBookletAdvanced = errors.New("pdfcpu booklet advanced cannot have binding along the top (portrait short-edge, landscape long-edge). use plain booklet instead.") + +var NUpValuesForBooklets = []int{2, 4, 6, 8} + // DefaultBookletConfig returns the default configuration for a booklet func DefaultBookletConfig() *model.NUp { nup := model.DefaultNUpConfig() @@ -34,6 +41,8 @@ func DefaultBookletConfig() *model.NUp { nup.BookletGuides = false nup.MultiFolio = false nup.FolioSize = 8 + nup.BookletType = model.Booklet + nup.BookletBinding = model.LongEdge return nup } @@ -49,7 +58,27 @@ func PDFBookletConfig(val int, desc string, conf *model.Configuration) (*model.N return nil, err } } - return nup, ParseNUpValue(val, nup) + if !types.IntMemberOf(val, NUpValuesForBooklets) { + ss := make([]string, len(NUpValuesForBooklets)) + for i, v := range NUpValuesForBooklets { + ss[i] = strconv.Itoa(v) + } + return nil, errors.Errorf("pdfcpu: n must be one of %s", strings.Join(ss, ", ")) + } + if err := ParseNUpValue(val, nup); err != nil { + return nil, err + } + // 6up and 8up special cases + if nup.IsBooklet() && val > 4 && nup.IsTopFoldBinding() { + // You can't top fold a 6up with 3 rows. + // TODO: support this for 8up + return nup, fmt.Errorf("pdfcpu booklet: n>4 must have binding on side (portrait long-edge or landscape short-edge)") + } + // bookletadvanced + if nup.BookletType == model.BookletAdvanced && val == 4 && nup.IsTopFoldBinding() { + return nup, errInvalidBookletAdvanced + } + return nup, nil } // ImageBookletConfig returns an NUp configuration for booklet-ing image files. @@ -87,7 +116,122 @@ func nup2OutputPageNr(inputPageNr, inputPageCount int, pageNumbers []int) (int, return pageNr, rotate } -func nup4OutputPageNr(inputPageNr int, inputPageCount int, pageNumbers []int) (int, bool) { +func get4upPos(pos int, isLandscape bool) (out int) { + if isLandscape { + switch pos % 4 { + // landscape short-edge binding page ordering is rotated 90 degrees anti-clockwise from the portrait ordering on the back sides of the pages to make duplexing work + // from portrait to lanscape map {0 => 3, 1 => 2, 2 => 1, 3 => 0} + case 0: + return 3 + case 1: + return 2 + case 2: + return 1 + case 3: + return 0 + } + } + return pos % 4 +} + +func nup4OutputPageNr(inputPageNr int, pageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + switch nup.BookletType { + case model.Booklet: + // simple booklets are collated by collecting the top of the sheet, then the bottom, then the top of the next sheet, and so on. + // this is conceptually easier for collation without specialized tools. + if nup.IsTopFoldBinding() { + return nup4BasicTopFoldOutputPageNr(inputPageNr, pageCount, pageNumbers, nup) + } else { + return nup4BasicSideFoldOutputPageNr(inputPageNr, pageCount, pageNumbers, nup) + } + case model.BookletAdvanced: + // advanced booklets have a different collation pattern: collect the top of each sheet and then the bottom of each sheet. + // this allows printers to fold the sheets twice and then cut along one of the folds. + return nup4AdvancedSideFoldOutputPageNr(inputPageNr, pageCount, pageNumbers, nup) + } + return 0, false +} + +func nup4BasicSideFoldOutputPageNr(positionNumber int, inputPageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + var p int + bookletSheetSideNumber := positionNumber / 4 + bookletPageNumber := positionNumber / 8 + if bookletSheetSideNumber%2 == 0 { + // front side + n := bookletPageNumber * 4 + switch positionNumber % 4 { + case 0: + p = inputPageCount - n + case 1: + p = 1 + n + case 2: + p = 3 + n + case 3: + p = inputPageCount - 2 - n + } + } else { + // back side + n := bookletPageNumber * 4 + switch get4upPos(positionNumber, nup.PageDim.Landscape()) { + case 0: + p = 2 + n + case 1: + p = inputPageCount - 1 - n + case 2: + p = inputPageCount - 3 - n + case 3: + p = 4 + n + } + } + pageNr := getPageNumber(pageNumbers, p-1) // p is one-indexed and we want zero-indexed + // Rotate bottom row of each output sheet by 180 degrees. + var rotate bool + if positionNumber%4 >= 2 { + rotate = true + } + return pageNr, rotate +} + +func nup4BasicTopFoldOutputPageNr(positionNumber int, inputPageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + var p int + bookletSheetSideNumber := positionNumber / 4 + bookletSheetNumber := positionNumber / 8 + if bookletSheetSideNumber%2 == 0 { + // front side + switch positionNumber % 4 { + case 0: + p = inputPageCount - 4*bookletSheetNumber + case 1: + p = 3 + 4*bookletSheetNumber + case 2: + p = 1 + 4*bookletSheetNumber + case 3: + p = inputPageCount - 2 - 4*bookletSheetNumber + } + } else { + // back side + switch get4upPos(positionNumber, nup.PageDim.Landscape()) { + case 0: + p = 4 + 4*bookletSheetNumber + case 1: + p = inputPageCount - 1 - 4*bookletSheetNumber + case 2: + p = inputPageCount - 3 - 4*bookletSheetNumber + case 3: + p = 2 + 4*bookletSheetNumber + } + } + pageNr := getPageNumber(pageNumbers, p-1) // p is one-indexed and we want zero-indexed + // Rotate right side of output page by 180 degrees. + var rotate bool + if positionNumber%2 == 1 { + rotate = true + } + return pageNr, rotate +} + +func nup4AdvancedSideFoldOutputPageNr(inputPageNr int, inputPageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + // (output page, input page) = [(1,n), (2,1), (3, n/2+1), (4, n/2-0), (5, 2), (6, n-1), (7, n/2-1), (8, n/2+2) ...] bookletPageNumber := inputPageNr / 4 var p int if bookletPageNumber%2 == 0 { @@ -103,8 +247,8 @@ func nup4OutputPageNr(inputPageNr int, inputPageCount int, pageNumbers []int) (i p = inputPageCount/2 - 1 - bookletPageNumber } } else { - // back side - switch inputPageNr % 4 { + // back side (portrait) + switch get4upPos(inputPageNr, nup.PageDim.Landscape()) { case 0: p = bookletPageNumber case 1: @@ -125,6 +269,120 @@ func nup4OutputPageNr(inputPageNr int, inputPageCount int, pageNumbers []int) (i return pageNr, rotate } +func nupLRTBOutputPageNr(positionNumber int, inputPageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + // move from left to right and then from top to bottom with no rotation + var p int + N := nup.N() + bookletSheetSideNumber := positionNumber / N + bookletSheetNumber := positionNumber / (2 * N) + if bookletSheetSideNumber%2 == 0 { + // front side + if positionNumber%2 == 0 { + // left side - count down from end + p = inputPageCount - N*bookletSheetNumber - positionNumber%N + } else { + // right side - count up from start + p = N*bookletSheetNumber + positionNumber%N + } + } else { + // back side + if positionNumber%2 == 0 { + // left side - count up from start + p = 2 + N*bookletSheetNumber + positionNumber%N + } else { + // right side - count down from end + p = inputPageCount - N*bookletSheetNumber - positionNumber%N + } + } + pageNr := getPageNumber(pageNumbers, p-1) // p is one-indexed and we want zero-indexed + return pageNr, false +} + +func nup8OutputPageNr(portraitPositionNumber int, inputPageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + // 8up sheet has four rows and two columns + // but the spreads are NOT across the two columns - instead the spreads are rotated 90deg to fit in a portrait orientation on the sheet + // rather than coding up an entire new imposition, we're going to use the left-down-top-bottom imposition as a base + // and the rotate the spreads (ie reorder) to fit on the sheet + + bookletSheetSideNumber := portraitPositionNumber / 8 + var landscapePositionNumber int + switch bookletSheetSideNumber % 2 { + case 0: // front side + // rotate the block of four pages 90deg clockwise to go from portrait to landscape. sequence=[1,3,0,2] + // then because we are rotating the right side by 180deg - so need to change to those positions. sequence=[1,2,0,3] + switch portraitPositionNumber % 4 { + case 0: + landscapePositionNumber = 1 + case 1: + landscapePositionNumber = 2 + case 2: + landscapePositionNumber = 0 + case 3: + landscapePositionNumber = 3 + } + case 1: // back side + // rotate the block of four pages 90deg anti-clockwise to go from portrait to landscape. sequence=[2,0,3,1] + // then because we are rotating the *left* side by 180deg - so need to change to those positions. sequence=[3,0,2,1] + // this is different from the front side because of the non-duplex sheet handling flip along the short edge + + switch portraitPositionNumber % 4 { + case 0: + landscapePositionNumber = 3 + case 1: + landscapePositionNumber = 0 + case 2: + landscapePositionNumber = 2 + case 3: + landscapePositionNumber = 1 + } + + } + positionNumber := landscapePositionNumber + portraitPositionNumber/4*4 + pageNumber, _ := nupLRTBOutputPageNr(positionNumber, inputPageCount, pageNumbers, nup) + // rotate right side so that bottom edge of pages is on the center cut + rotate := portraitPositionNumber%2 == 1 + return pageNumber, rotate +} + +func nupPerfectBound(positionNumber int, inputPageCount int, pageNumbers []int, nup *model.NUp) (int, bool) { + // input: positionNumber + // output: original page number and rotation + var p int + var rotate bool + N := nup.N() + twoN := N * 2 + + bookletSheetSideNumber := positionNumber / N + bookletSheetNumber := positionNumber / twoN + if bookletSheetSideNumber%2 == 0 { + // front side + p = bookletSheetNumber*twoN + 2*(positionNumber%twoN) + 1 + } else { + // back side + p = bookletSheetNumber*twoN + 2*((positionNumber-N)%twoN) + 2 + if N == 4 || N == 6 || N == 8 { + if N == 4 && nup.PageDim.Landscape() { // landscape pages on portrait sheets + // flip top and bottom rows to account for landscape rotation and the page handling flip (short edge flip, no duplex) + if positionNumber%N < 2 { // top side + p += 4 + } else { // bottom side + p -= 4 + } + } else { // portrait pages on portrait sheets + // flip left and right columns to account for the page handling flip (short edge flip, no duplex) + if positionNumber%2 == 0 { // left side + p += 2 + } else { // right side + p -= 2 + } + } + } + // account for page handling flip (short edge flip, no duplex) + rotate = N == 2 || nup.PageDim.Landscape() + } + return getPageNumber(pageNumbers, p-1), rotate // p is one-indexed and we want zero-indexed +} + type bookletPage struct { number int rotate bool @@ -145,19 +403,39 @@ func sortSelectedPagesForBooklet(pages types.IntSet, nup *model.NUp) []bookletPa bookletPages := make([]bookletPage, pageCount) - switch nup.N() { - case 2: - // (output page, input page) = [(1,n), (2,1), (3, n-1), (4, 2), (5, n-2), (6, 3), ...] - for i := 0; i < pageCount; i++ { - pageNr, rotate := nup2OutputPageNr(i, pageCount, pageNumbers) - bookletPages[i].number = pageNr - bookletPages[i].rotate = rotate - } + switch nup.BookletType { + case model.Booklet, model.BookletAdvanced: + switch nup.N() { + case 2: + // (output page, input page) = [(1,n), (2,1), (3, n-1), (4, 2), (5, n-2), (6, 3), ...] + for i := 0; i < pageCount; i++ { + pageNr, rotate := nup2OutputPageNr(i, pageCount, pageNumbers) + bookletPages[i].number = pageNr + bookletPages[i].rotate = rotate + } - case 4: - // (output page, input page) = [(1,n), (2,1), (3, n/2+1), (4, n/2-0), (5, 2), (6, n-1), (7, n/2-1), (8, n/2+2) ...] + case 4: + for i := 0; i < pageCount; i++ { + pageNr, rotate := nup4OutputPageNr(i, pageCount, pageNumbers, nup) + bookletPages[i].number = pageNr + bookletPages[i].rotate = rotate + } + case 6: + for i := 0; i < pageCount; i++ { + pageNr, rotate := nupLRTBOutputPageNr(i, pageCount, pageNumbers, nup) + bookletPages[i].number = pageNr + bookletPages[i].rotate = rotate + } + case 8: + for i := 0; i < pageCount; i++ { + pageNr, rotate := nup8OutputPageNr(i, pageCount, pageNumbers, nup) + bookletPages[i].number = pageNr + bookletPages[i].rotate = rotate + } + } + case model.BookletPerfectBound: for i := 0; i < pageCount; i++ { - pageNr, rotate := nup4OutputPageNr(i, pageCount, pageNumbers) + pageNr, rotate := nupPerfectBound(i, pageCount, pageNumbers, nup) bookletPages[i].number = pageNr bookletPages[i].rotate = rotate } @@ -282,8 +560,8 @@ func BookletFromImages(ctx *model.Context, fileNames []string, nup *model.NUp, p // BookletFromPDF creates a booklet version of the PDF represented by xRefTable. func BookletFromPDF(ctx *model.Context, selectedPages types.IntSet, nup *model.NUp) error { n := int(nup.Grid.Width * nup.Grid.Height) - if !(n == 2 || n == 4) { - return fmt.Errorf("pdfcpu: booklet must have n={2,4} pages per sheet, got %d", n) + if !(n == 2 || n == 4 || n == 6 || n == 8) { + return fmt.Errorf("pdfcpu: booklet must have n={2,4,6,8} pages per sheet, got %d", n) } var mb *types.Rectangle diff --git a/pkg/pdfcpu/booklet_test.go b/pkg/pdfcpu/booklet_test.go new file mode 100644 index 00000000..942d0fab --- /dev/null +++ b/pkg/pdfcpu/booklet_test.go @@ -0,0 +1,243 @@ +/* +Copyright 2024 The pdfcpu Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package pdfcpu + +import ( + "fmt" + "strings" + "testing" +) + +type pageOrderResults struct { + id string + nup int + pageCount int + expectedPageOrder []int + papersize string + bookletType string + binding string +} + +var bookletTestCases = []pageOrderResults{ + // basic booklet sidefold test cases + { + id: "booklet portrait long edge", + nup: 4, + pageCount: 16, + expectedPageOrder: []int{ + 16, 1, 3, 14, + 2, 15, 13, 4, + 12, 5, 7, 10, + 6, 11, 9, 8, + }, + papersize: "A5", // portrait, long-edge binding + bookletType: "booklet", + binding: "long", + }, + { + id: "booklet landscape short edge", + nup: 4, + pageCount: 8, + expectedPageOrder: []int{ + 8, 1, 3, 6, + 4, 5, 7, 2, // this is ordered differently from the portrait layout (because of differences in how duplexing works) + }, + papersize: "A5L", // landscape, short-edge binding + bookletType: "booklet", + binding: "short", + }, + // basic booklet topfold test cases + { + id: "booklet topfold portrait", + nup: 4, + pageCount: 16, + expectedPageOrder: []int{ + 16, 3, 1, 14, + 4, 15, 13, 2, + 12, 7, 5, 10, + 8, 11, 9, 6, + }, + papersize: "A5", // portrait, short-edge binding + bookletType: "booklet", + binding: "short", + }, + { + id: "booklet topfold landscape", + nup: 4, + pageCount: 8, + expectedPageOrder: []int{ + 8, 3, 1, 6, + 2, 5, 7, 4, // this is 180degrees flipped from the portrait layout (because of differences in how duplexing works) + }, + papersize: "A5L", // landscape, long-edge binding + bookletType: "booklet", + binding: "long", + }, + // advanced booklet sidefold test cases + { + id: "advanced portrait long edge", + nup: 4, + pageCount: 8, + expectedPageOrder: []int{ + 8, 1, 5, 4, + 2, 7, 3, 6, + }, + papersize: "A5", // portrait, long-edge binding + bookletType: "bookletadvanced", + binding: "long", + }, + { + id: "advanced landscape short edge", + nup: 4, + pageCount: 8, + expectedPageOrder: []int{ + 8, 1, 5, 4, + 6, 3, 7, 2, // this is ordered differently from the portrait layout (because of differences in how duplexing works) + }, + papersize: "A5L", // landscape, short-edge binding + bookletType: "bookletadvanced", + binding: "short", + }, + // 6up test + { + id: "6up", + nup: 6, + pageCount: 12, + expectedPageOrder: []int{ + 12, 1, 10, 3, 8, 5, + 2, 11, 4, 9, 6, 7, + }, + papersize: "A6", // portrait, long-edge binding + bookletType: "booklet", + binding: "long", + }, + { + id: "6up multisheet", + nup: 6, + pageCount: 24, + expectedPageOrder: []int{ + 24, 1, 22, 3, 20, 5, + 2, 23, 4, 21, 6, 19, + 18, 7, 16, 9, 14, 11, + 8, 17, 10, 15, 12, 13, + }, + papersize: "A6", // portrait, long-edge binding + bookletType: "booklet", + binding: "long", + }, + // 8up test + { + id: "8up", + nup: 8, + pageCount: 32, + expectedPageOrder: []int{ + 1, 30, 32, 3, 5, 26, 28, 7, + 29, 2, 4, 31, 25, 6, 8, 27, + 9, 22, 24, 11, 13, 18, 20, 15, + 21, 10, 12, 23, 17, 14, 16, 19, + }, + papersize: "A6", // portrait, long-edge binding + bookletType: "booklet", + binding: "long", + }, + // perfect bound + { + id: "perfect bound 2up", + nup: 2, + pageCount: 8, + expectedPageOrder: []int{ + 1, 3, + 2, 4, + 5, 7, + 6, 8, + }, + papersize: "A6", // portrait, long-edge binding + bookletType: "perfectbound", + binding: "long", + }, + { + id: "perfect bound 4up", + nup: 4, + pageCount: 16, + expectedPageOrder: []int{ + 1, 3, 5, 7, + 4, 2, 8, 6, + 9, 11, 13, 15, + 12, 10, 16, 14, + }, + papersize: "A6", // portrait, long-edge binding + bookletType: "perfectbound", + binding: "long", + }, + { + id: "perfect bound 4up landscape short-edge", + nup: 4, + pageCount: 16, + expectedPageOrder: []int{ + 1, 3, 5, 7, + 6, 8, 2, 4, + 9, 11, 13, 15, + 14, 16, 10, 12, + }, + papersize: "A6L", // landscape, short-edge binding + bookletType: "perfectbound", + binding: "short", + }, + { + id: "perfect bound 8up", + nup: 8, + pageCount: 16, + expectedPageOrder: []int{ + 1, 3, 5, 7, 9, 11, 13, 15, + 4, 2, 8, 6, 12, 10, 16, 14, + }, + papersize: "A6", // portrait, long-edge binding + bookletType: "perfectbound", + binding: "long", + }, +} + +func TestBookletPageOrder(t *testing.T) { + for _, test := range bookletTestCases { + t.Run(test.id, func(t *testing.T) { + nup, err := PDFBookletConfig(test.nup, fmt.Sprintf("papersize:%s, btype:%s, binding: %s", test.papersize, test.bookletType, test.binding), nil) + if err != nil { + t.Fatal(err) + } + pageNumbers := make(map[int]bool) + for i := 0; i < test.pageCount; i++ { + pageNumbers[i+1] = true + } + pageOrder := make([]int, test.pageCount) + for i, p := range sortSelectedPagesForBooklet(pageNumbers, nup) { + pageOrder[i] = p.number + } + for i, expected := range test.expectedPageOrder { + if pageOrder[i] != expected { + t.Fatal("incorrect page order\nexpected:", arrayToString(test.expectedPageOrder), "\n got:", arrayToString(pageOrder)) + } + } + }) + } +} + +func arrayToString(arr []int) string { + out := make([]string, len(arr)) + for i, n := range arr { + out[i] = fmt.Sprintf("%02d", n) + } + return fmt.Sprintf("[%s]", strings.Join(out, " ")) +} diff --git a/pkg/pdfcpu/model/booklet.go b/pkg/pdfcpu/model/booklet.go index 1a4cd34f..19e79b48 100644 --- a/pkg/pdfcpu/model/booklet.go +++ b/pkg/pdfcpu/model/booklet.go @@ -26,6 +26,44 @@ import ( "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" ) +type BookletType int + +// These are the types of booklet layouts. +const ( + Booklet BookletType = iota + BookletAdvanced + BookletPerfectBound +) + +func (b BookletType) String() string { + switch b { + case Booklet: + return "booklet" + case BookletAdvanced: + return "booklet advanced" + case BookletPerfectBound: + return "booklet perfect bound" + } + return "" +} + +type BookletBinding int + +const ( + LongEdge BookletBinding = iota + ShortEdge +) + +func (b BookletBinding) String() string { + switch b { + case ShortEdge: + return "short-edge" + case LongEdge: + return "long-edge" + } + return "" +} + func drawGuideLineLabel(w io.Writer, x, y float64, s string, mb *types.Rectangle, fm FontMap, rot int) { fontName := "Helvetica" td := TextDescriptor{ @@ -44,7 +82,16 @@ func drawGuideLineLabel(w io.Writer, x, y float64, s string, mb *types.Rectangle WriteMultiLine(nil, w, mb, nil, td) } -func drawScissors(w io.Writer, mb *types.Rectangle, fm FontMap) { +func drawScissors(w io.Writer, isVerticalCut bool, horzCutYpos float64, mb *types.Rectangle, fm FontMap) { + x := 0. + y := horzCutYpos - 4 + rot := 0. + if isVerticalCut { + // TODO: if we ever have multiple vertical cuts, would need to change this. + x = mb.Width()/2 - 12 + y = 12 + rot = 90 + } fontName := "ZapfDingbats" td := TextDescriptor{ FontName: fontName, @@ -54,43 +101,119 @@ func drawScissors(w io.Writer, mb *types.Rectangle, fm FontMap) { ScaleAbs: true, StrokeCol: color.Black, FillCol: color.Black, - X: 0, - Y: mb.Height()/2 - 4, + X: x, + Y: y, + Rotation: rot, Text: string([]byte{byte(34)}), } WriteMultiLine(nil, w, mb, nil, td) } +type cutOrFold int + +const ( + none cutOrFold = iota + cut + fold +) + +func (c cutOrFold) String(nup *NUp) string { + if c == cut { + if nup.BookletType == BookletAdvanced { + return "Fold & Cut here" + } + return "Cut here" + } + if c == fold { + return "Fold here" + } + return "" +} + +func getCutFolds(nup *NUp) (horizontal cutOrFold, vertical cutOrFold) { + var getCutOrFold = func(nup *NUp) (cutOrFold, cutOrFold) { + switch nup.N() { + case 2: + return fold, none + case 4: + if nup.BookletBinding == LongEdge { + return cut, fold + } else { + return fold, cut + } + case 6: + // Really, it has two horizontal cuts. + return cut, fold + case 8: + // Also has a horizontal cut in the center. + return fold, cut + } + return none, none + } + horizontal, vertical = getCutOrFold(nup) + if nup.BookletType == BookletPerfectBound { + // All folds turn into cuts for perfect binding. + if horizontal == fold { + horizontal = cut + } + if vertical == fold { + vertical = cut + } + } + if nup.N() == 4 && nup.PageDim.Landscape() { + // The logic above is for a portrait sheet, so swap the outputs. + return vertical, horizontal + } + return horizontal, vertical +} + +func drawGuideHorizontal(w io.Writer, y, width float64, cutOrFold cutOrFold, nup *NUp, mb *types.Rectangle, fm FontMap) { + fmt.Fprint(w, "[3] 0 d ") + draw.SetLineWidth(w, 0) + draw.SetStrokeColor(w, color.Gray) + draw.DrawLineSimple(w, 0, y, width, y) + drawGuideLineLabel(w, width-46, y+2, cutOrFold.String(nup), mb, fm, 0) + if cutOrFold == cut { + drawScissors(w, false, y, mb, fm) + } +} + +func drawGuideVertical(w io.Writer, x, height float64, cutOrFold cutOrFold, nup *NUp, mb *types.Rectangle, fm FontMap) { + fmt.Fprint(w, "[3] 0 d ") + draw.SetLineWidth(w, 0) + draw.SetStrokeColor(w, color.Gray) + draw.DrawLineSimple(w, x, 0, x, height) + drawGuideLineLabel(w, x-23, height-32, cutOrFold.String(nup), mb, fm, 90) + if cutOrFold == cut { + drawScissors(w, true, height/2, mb, fm) + } +} + // DrawBookletGuides draws guides according to corresponding nup value. func DrawBookletGuides(nup *NUp, w io.Writer) FontMap { width := nup.PageDim.Width height := nup.PageDim.Height var fm FontMap = FontMap{} - mb := types.RectForDim(nup.PageDim.Width, nup.PageDim.Height) + mb := types.RectForDim(width, height) - draw.SetLineWidth(w, 0) - draw.SetStrokeColor(w, color.Gray) - - switch nup.N() { - case 2: - // Draw horizontal folding line. - fmt.Fprint(w, "[3] 0 d ") - draw.DrawLineSimple(w, 0, height/2, width, height/2) - drawGuideLineLabel(w, 1, height/2+2, "Fold here", mb, fm, 0) - case 4: - // Draw vertical folding line. - fmt.Fprint(w, "[3] 0 d ") - draw.DrawLineSimple(w, width/2, 0, width/2, height) - drawGuideLineLabel(w, width/2-23, 20, "Fold here", mb, fm, 90) - - // Draw horizontal cutting line. - fmt.Fprint(w, "[3] 0 d ") - draw.DrawLineSimple(w, 0, height/2, width, height/2) - drawGuideLineLabel(w, width, height/2+2, "Fold & Cut here", mb, fm, 0) - - // Draw scissors over cutting line. - drawScissors(w, mb, fm) + horz, vert := getCutFolds(nup) + if horz != none { + switch nup.N() { + case 2, 4: + drawGuideHorizontal(w, height/2, width, horz, nup, mb, fm) + case 6: + // 6up: two cuts + drawGuideHorizontal(w, height*1/3, width, horz, nup, mb, fm) + drawGuideHorizontal(w, height*2/3, width, horz, nup, mb, fm) + case 8: + // 8up: middle cut and 1/4,3/4 folds + drawGuideHorizontal(w, height/2, width, cut, nup, mb, fm) + drawGuideHorizontal(w, height*1/4, width, fold, nup, mb, fm) + drawGuideHorizontal(w, height*3/4, width, fold, nup, mb, fm) + } + } + if vert != none { + drawGuideVertical(w, width/2, height, vert, nup, mb, fm) } - return fm } diff --git a/pkg/pdfcpu/model/nup.go b/pkg/pdfcpu/model/nup.go index 274a813a..0645248e 100644 --- a/pkg/pdfcpu/model/nup.go +++ b/pkg/pdfcpu/model/nup.go @@ -61,22 +61,31 @@ const ( DownLeft ) +type BorderStyling struct { + Color *color.SimpleColor + LineStyle *types.LineJoinStyle + Width float64 +} + // NUp represents the command details for the command "NUp". type NUp struct { - PageDim *types.Dim // Page dimensions in display unit. - PageSize string // Paper size eg. A4L, A4P, A4(=default=A4P), see paperSize.go - UserDim bool // true if one of dimensions or paperSize provided overriding the default. - Orient orientation // One of rd(=default),dr,ld,dl - Grid *types.Dim // Intra page grid dimensions eg (2,2) - PageGrid bool // Create a m x n grid of pages for PDF inputfiles only (think "extra page n-Up"). - ImgInputFile bool // Process image or PDF input files. - Margin float64 // Cropbox for n-Up content. - Border bool // Draw bounding box. - BookletGuides bool // Draw folding and cutting lines. - MultiFolio bool // Render booklet as sequence of folios. - FolioSize int // Booklet multifolio folio size: default: 8 - InpUnit types.DisplayUnit // input display unit. - BgColor *color.SimpleColor // background color + PageDim *types.Dim // Page dimensions in display unit. + PageSize string // Paper size eg. A4L, A4P, A4(=default=A4P), see paperSize.go + UserDim bool // true if one of dimensions or paperSize provided overriding the default. + Orient orientation // One of rd(=default),dr,ld,dl + Grid *types.Dim // Intra page grid dimensions eg (2,2) + PageGrid bool // Create a m x n grid of pages for PDF inputfiles only (think "extra page n-Up"). + ImgInputFile bool // Process image or PDF input files. + Margin float64 // Cropbox for n-Up content. + Border bool // Draw bounding box. + BorderOnCropbox *BorderStyling // Draw bounding box around crop box. + BookletGuides bool // Draw folding and cutting lines. + MultiFolio bool // Render booklet as sequence of folios. + FolioSize int // Booklet multifolio folio size: default: 8 + BookletType BookletType // Is this a booklet or booklet cover layout + BookletBinding BookletBinding // Does the booklet have short or long-edge binding + InpUnit types.DisplayUnit // input display unit. + BgColor *color.SimpleColor // background color } // DefaultNUpConfig returns the default NUp configuration. @@ -99,6 +108,14 @@ func (nup NUp) N() int { return int(nup.Grid.Height * nup.Grid.Width) } +func (nup NUp) IsTopFoldBinding() bool { + return (nup.PageDim.Portrait() && nup.BookletBinding == ShortEdge) || (nup.PageDim.Landscape() && nup.BookletBinding == LongEdge) +} + +func (nup NUp) IsBooklet() bool { + return nup.BookletType == Booklet || nup.BookletType == BookletAdvanced +} + // RectsForGrid calculates dest rectangles for given grid. func (nup NUp) RectsForGrid() []*types.Rectangle { cols := int(nup.Grid.Width) diff --git a/pkg/pdfcpu/nup.go b/pkg/pdfcpu/nup.go index d01561a0..1d4421bd 100644 --- a/pkg/pdfcpu/nup.go +++ b/pkg/pdfcpu/nup.go @@ -35,13 +35,12 @@ import ( ) var ( - errInvalidNUpVal = errors.New("pdfcpu nup: n must be one of 2, 3, 4, 6, 8, 9, 12, 16") errInvalidGridDims = errors.New("pdfcpu grid: dimensions must be: m > 0, n > 0") errInvalidNUpConfig = errors.New("pdfcpu: invalid configuration string") ) var ( - nUpValues = []int{2, 3, 4, 6, 8, 9, 12, 16} + NUpValues = []int{2, 3, 4, 6, 8, 9, 12, 16} nUpDims = map[int]types.Dim{ 2: {Width: 2, Height: 1}, 3: {Width: 3, Height: 1}, @@ -62,12 +61,15 @@ var nupParamMap = nUpParamMap{ "papersize": parsePageFormatNUp, "orientation": parseOrientation, "border": parseElementBorder, + "cropboxborder": parseElementBorderOnCropbox, "margin": parseElementMargin, "backgroundcolor": parseSheetBackgroundColor, "bgcolor": parseSheetBackgroundColor, "guides": parseBookletGuides, "multifolio": parseBookletMultifolio, "foliosize": parseBookletFolioSize, + "btype": parseBookletType, + "binding": parseBookletBinding, } // Handle applies parameter completion and if successful @@ -142,6 +144,58 @@ func parseElementBorder(s string, nup *model.NUp) error { return nil } +func parseElementBorderOnCropbox(s string, nup *model.NUp) error { + // w + // w r g b + // w #c + // w round + // w round r g b + // w round #c + + var err error + + b := strings.Split(s, " ") + if len(b) == 0 || len(b) > 5 { + return errors.Errorf("pdfcpu: borders: need 1,2,3,4 or 5 int values, %s\n", s) + } + + switch b[0] { + case "off", "false", "f": + return nil + case "on", "true", "t": + nup.BorderOnCropbox = &model.BorderStyling{Width: 1} + return nil + } + + nup.BorderOnCropbox = &model.BorderStyling{} + width, err := strconv.ParseFloat(b[0], 64) + if err != nil { + return err + } + if width == 0 { + return errors.New("pdfcpu: borders: need width > 0") + } + nup.BorderOnCropbox.Width = width + + if len(b) == 1 { + return nil + } + if strings.HasPrefix("round", b[1]) { + style := types.LJRound + nup.BorderOnCropbox.LineStyle = &style + if len(b) == 2 { + return nil + } + c, err := color.ParseColor(strings.Join(b[2:], " ")) + nup.BorderOnCropbox.Color = &c + return err + } + + c, err := color.ParseColor(strings.Join(b[1:], " ")) + nup.BorderOnCropbox.Color = &c + return err +} + func parseBookletGuides(s string, nup *model.NUp) error { switch strings.ToLower(s) { case "on", "true", "t": @@ -178,6 +232,32 @@ func parseBookletFolioSize(s string, nup *model.NUp) error { return nil } +func parseBookletType(s string, nup *model.NUp) error { + switch strings.ToLower(s) { + case "booklet": + nup.BookletType = model.Booklet + case "bookletadvanced": + nup.BookletType = model.BookletAdvanced + case "perfectbound": + nup.BookletType = model.BookletPerfectBound + default: + return errors.New("pdfcpu: booklet type, please provide one of: booklet perfectbound") + } + return nil +} + +func parseBookletBinding(s string, nup *model.NUp) error { + switch strings.ToLower(s) { + case "short": + nup.BookletBinding = model.ShortEdge + case "long": + nup.BookletBinding = model.LongEdge + default: + return errors.New("pdfcpu: booklet binding, please provide one of: short long") + } + return nil +} + func parseElementMargin(s string, nup *model.NUp) error { f, err := strconv.ParseFloat(s, 64) if err != nil { @@ -240,6 +320,13 @@ func PDFNUpConfig(val int, desc string, conf *model.Configuration) (*model.NUp, return nil, err } } + if !types.IntMemberOf(val, NUpValues) { + ss := make([]string, len(NUpValues)) + for i, v := range NUpValues { + ss[i] = strconv.Itoa(v) + } + return nil, errors.Errorf("pdfcpu: n must be one of %s", strings.Join(ss, ", ")) + } return nup, ParseNUpValue(val, nup) } @@ -281,10 +368,6 @@ func ImageGridConfig(rows, cols int, desc string, conf *model.Configuration) (*m // ParseNUpValue parses the NUp value into an internal structure. func ParseNUpValue(n int, nUp *model.NUp) error { - if !types.IntMemberOf(n, nUpValues) { - return errInvalidNUpVal - } - // The n-Up layout depends on the orientation of the chosen output paper size. // This optional paper size may also be specified by dimensions in user unit. // The default paper size is A4 or A4P (A4 in portrait mode) respectively. diff --git a/pkg/pdfcpu/types/layout_test.go b/pkg/pdfcpu/types/layout_test.go new file mode 100644 index 00000000..f50a8cfe --- /dev/null +++ b/pkg/pdfcpu/types/layout_test.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 The pdfcpu Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package types + +import "testing" + +func TestParsePageFormat(t *testing.T) { + dim, _, err := ParsePageFormat("A3L") + if err != nil { + t.Error(err) + } + if (dim.Width != 1191) || (dim.Height != 842) { + t.Errorf("expected 1191x842. got %s", dim) + } + // the original dim should be unmodified + dimOrig := PaperSize["A3"] + if (dimOrig.Width != 842) || (dimOrig.Height != 1191) { + t.Errorf("expected origDim=842x1191x842. got %s", dimOrig) + } +} diff --git a/pkg/samples/booklet/BookletFromPDFA4_2Up.pdf b/pkg/samples/booklet/BookletFromPDFA4_2Up.pdf index f5819000..a97ed494 100644 Binary files a/pkg/samples/booklet/BookletFromPDFA4_2Up.pdf and b/pkg/samples/booklet/BookletFromPDFA4_2Up.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFA4_4Up.pdf b/pkg/samples/booklet/BookletFromPDFA4_4Up.pdf index f4fd5e23..86d71fa6 100644 Binary files a/pkg/samples/booklet/BookletFromPDFA4_4Up.pdf and b/pkg/samples/booklet/BookletFromPDFA4_4Up.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up.pdf index bd882c29..4b38ca67 100644 Binary files a/pkg/samples/booklet/BookletFromPDFLedger_4Up.pdf and b/pkg/samples/booklet/BookletFromPDFLedger_4Up.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4UpWithTrailingBlankPages.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4UpWithTrailingBlankPages.pdf index 9811917e..7c193776 100644 Binary files a/pkg/samples/booklet/BookletFromPDFLedger_4UpWithTrailingBlankPages.pdf and b/pkg/samples/booklet/BookletFromPDFLedger_4UpWithTrailingBlankPages.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_long.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_long.pdf new file mode 100644 index 00000000..a50b1a9f Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_long.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_short.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_short.pdf new file mode 100644 index 00000000..3e9c495d Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_short.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_short_advanced.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_short_advanced.pdf new file mode 100644 index 00000000..a59528ba Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_4Up_landscape_short_advanced.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up_perfectbound.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up_perfectbound.pdf new file mode 100644 index 00000000..f1dd14cc Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_4Up_perfectbound.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up_portrait_long_advanced.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up_portrait_long_advanced.pdf new file mode 100644 index 00000000..e812c6f5 Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_4Up_portrait_long_advanced.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_4Up_portrait_short.pdf b/pkg/samples/booklet/BookletFromPDFLedger_4Up_portrait_short.pdf new file mode 100644 index 00000000..1ae57184 Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_4Up_portrait_short.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_6Up.pdf b/pkg/samples/booklet/BookletFromPDFLedger_6Up.pdf new file mode 100644 index 00000000..13e83697 Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_6Up.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLedger_8Up.pdf b/pkg/samples/booklet/BookletFromPDFLedger_8Up.pdf new file mode 100644 index 00000000..f4e4e636 Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLedger_8Up.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLetter_2Up.pdf b/pkg/samples/booklet/BookletFromPDFLetter_2Up.pdf index 410be1a7..2907cef1 100644 Binary files a/pkg/samples/booklet/BookletFromPDFLetter_2Up.pdf and b/pkg/samples/booklet/BookletFromPDFLetter_2Up.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLetter_2UpWithTrailingBlankPages.pdf b/pkg/samples/booklet/BookletFromPDFLetter_2UpWithTrailingBlankPages.pdf index c3ea0800..4b16998f 100644 Binary files a/pkg/samples/booklet/BookletFromPDFLetter_2UpWithTrailingBlankPages.pdf and b/pkg/samples/booklet/BookletFromPDFLetter_2UpWithTrailingBlankPages.pdf differ diff --git a/pkg/samples/booklet/BookletFromPDFLetter_2Up_perfectbound.pdf b/pkg/samples/booklet/BookletFromPDFLetter_2Up_perfectbound.pdf new file mode 100644 index 00000000..a5bac195 Binary files /dev/null and b/pkg/samples/booklet/BookletFromPDFLetter_2Up_perfectbound.pdf differ diff --git a/pkg/samples/booklet/HardbackBookFromPDF.pdf b/pkg/samples/booklet/HardbackBookFromPDF.pdf index f426c005..6a34ea0f 100644 Binary files a/pkg/samples/booklet/HardbackBookFromPDF.pdf and b/pkg/samples/booklet/HardbackBookFromPDF.pdf differ diff --git a/pkg/testdata/bookletTestLandscape.pdf b/pkg/testdata/bookletTestLandscape.pdf new file mode 100644 index 00000000..d832e0eb Binary files /dev/null and b/pkg/testdata/bookletTestLandscape.pdf differ