home · contact · privacy
Bookmaker: Refactor cut drawing code.
[misc] / bookmaker.py
1 #!/usr/bin/env python3
2 """
3 bookmaker.py is a helper for optimizing PDFs of books for the production of small self-printed, self-bound physical books.  Towards this goal it offers various PDF manipulation options that may also be used indepéndently and for other purposes.
4 """
5 help_epilogue = """
6 EXAMPLES:
7
8 Concatenate two PDFs A.pdf and B.pdf to COMBINED.pdf:
9     bookmaker.py --input_file A.pdf --input_file B.pdf --output_file COMBINED.pdf
10
11 Produce OUTPUT.pdf containing all pages of (inclusive) page number range 3-7 from INPUT.pdf:
12     bookmaker.py -i INPUT.pdf --page_range 3-7 -o OUTPUT.pdf
13
14 Produce COMBINED.pdf from A.pdf's first 7 pages, B.pdf's pages except its first two, and all pages of C.pdf:
15     bookmaker.py -i A.pdf -p start-7 -i B.pdf -p 3-end -i C.pdf -o COMBINED.pdf
16
17 Crop each page 5cm from the left, 10cm from the bottom, 2cm from the right, and 0cm from the top:
18     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --crops "5,10,2,0"
19
20 Include all pages from INPUT.pdf, but crop pages 10-20 by 5cm each from bottom and top:
21     bookmaker.py -i INPUT.pdf -c "10-20:0,5,0,5" -o OUTPUT.pdf
22
23 Same crops for pages 10-20, but also crop all pages 30 and later by 3cm each from left and right:
24     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -c "10-20:0,5,0,5" -c "30-end:3,0,3,0"
25
26 Rotate by 90° pages 3, 5, 7; rotate page 7 once more by 90% (i.e. 180° in total):
27     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --rotate 3 -r 5 -r 7 -r 7
28
29 Initially declare 5cm crop from the left and 1cm crop from right, but alternate direction between even and odd pages:
30     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -c "5,0,1,0" -s
31
32 Quarter each OUTPUT.pdf page to carry 4 pages from INPUT.pdf, draw stencils into inner margins for cuts to carry binding strings:
33     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --nup4
34
35 Same --nup4, but define a printable-region margin of 1.3cm to limit the space for the INPUT.pdf pages in OUTPUT.pdf page quarters:
36     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --print_margin 1.3
37
38 Same --nup4, but draw lines marking printable-region margins, page quarts, spine margins:
39     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --analyze
40
41 NOTES:
42
43 For arguments like -p, page numbers are assumed to start with 1 (not 0, which is treated as an invalid page number value).
44
45 The target page shape so far is assumed to be A4 in portrait orientation; bookmaker.py normalizes all pages to this format before applying crops, and removes any source PDF /Rotate commands (for their production of landscape orientations).
46
47 For --nup4, the -c cropping instructions do not so much erase content outside the cropped area, but rather zoom into the page in a way that maximes the cropped area as much as possible into the available per-page area between printable-area margins and the borders to the other quartered pages.  If the zoomed cropped area does not fit in neatly into its per-page area, this will preserve additional page content.
48
49 The --nup4 quartering puts pages into a specific order optimized for no-tumble duplex print-outs that can easily be folded and cut into pages of a small A6 book.  Each unit of 8 pages from the source PDF is mapped thus onto two subsequent pages (i.e. front and back of a printed A4 paper):
50
51  (front)      (back)
52 +-------+   +-------+
53 | 4 | 1 |   | 2 | 3 |
54 |-------|   |-------|
55 | 8 | 5 |   | 6 | 7 |
56 +-------+   +-------+
57
58 To facilitate this layout, --nup4 also pads the input PDF pages to a total number that is a multiple of 8, by adding empty pages if necessary.
59
60 (To turn above double-sided example page into a tiny 8-page book:  Cut the paper in two on its horizontal middle line.  Fold the two halves by their vertical middle lines, with pages 3-2 and 7-6 on the folds' insides.  This creates two 4-page books of pages 1-4 and pages 5-8.  Fold them both closed and (counter-intuitively) put the book of pages 5-8 on top of the other one (creating a temporary page order of 5,6,7,8,1,2,3,4).  A binding cut stencil should be visible on the top left of this stack – cut it out (with all pages folded together) to add the same inner-margin upper cut to each page.  Turn around your 8-pages stack to find the mirror image of aforementioned stencil on the stack's back's bottom, and cut that out too.  Each page now has binding cuts on top and bottom of its inner margins.  Swap the order of both books (back to the final page order of 1,2,3,4,5,6,7,8), and you now have an 8-pages book that can be "bound" in its binding cuts through a rubber band or the like.  Repeat with the next 8-pages double-page, et cetera.  (Actually, with just 8 pages, the paper may curl under the pressure of a rubber band – but go up to 32 pages or so, and the result will become quite stable.)
61 """
62 import argparse
63 import io
64 import os
65 import sys
66
67 def handled_error_exit(msg):
68     print(f"ERROR: {msg}")
69     sys.exit(1)
70
71 try:
72     import pypdf
73 except ImportError:
74     handled_error_exit("Can't run at all without pypdf installed.")
75
76 # some constants
77 POINTS_PER_CM = 10 * 72 / 25.4
78 A4_WIDTH = 21 * POINTS_PER_CM
79 A4_HEIGHT = 29.7 * POINTS_PER_CM
80 A4 = (A4_WIDTH, A4_HEIGHT)
81 CUT_DEPTH = 1.95 * POINTS_PER_CM
82 CUT_WIDTH = 1.05 * POINTS_PER_CM
83 MIDDLE_POINT_DEPTH = 0.4 * POINTS_PER_CM
84 SPINE_LIMIT = 1 * POINTS_PER_CM
85
86 # some helpers
87 class HandledException(Exception):
88     pass
89
90 def validate_page_range(p_string, err_msg_prefix):
91     prefix = f"{err_msg_prefix}: page range string"
92     if '-' not in p_string:
93         raise HandledException(f"{prefix} lacks '-': {p_string}")
94     tokens = p_string.split("-")
95     if len(tokens) > 2:
96         raise HandledException(f"{prefix} has too many '-': {p_string}")
97     for i, token in enumerate(tokens):
98         if token == "":
99             continue
100         if i == 0 and token == "start":
101             continue
102         if i == 1 and token == "end":
103             continue
104         try:
105             int(token)
106         except ValueError:
107             raise HandledException(f"{prefix} carries value neither integer, nor 'start', nor 'end': {p_string}")
108         if int(token) < 1:
109             raise HandledException(f"{prefix} carries page number <1: {p_string}")
110     start = -1
111     end = -1
112     try:
113         start = int(tokens[0])
114         end = int(tokens[1])
115     except ValueError:
116         pass
117     if start > 0 and end > 0 and start > end:
118         raise HandledException(f"{prefix} has higher start than end value: {p_string}")
119
120 def split_crops_string(c_string):
121     initial_split = c_string.split(':')
122     if len(initial_split) > 1:
123         page_range = initial_split[0]
124         crops = initial_split[1]
125     else:
126         page_range = None
127         crops = initial_split[0]
128     return page_range, crops
129
130 def parse_page_range(range_string, pages):
131     start_page = 0
132     end_page = len(pages)
133     if range_string:
134         start, end = range_string.split('-')
135         if not (len(start) == 0 or start == "start"):
136             start_page = int(start) - 1
137         if not (len(end) == 0 or end == "end"):
138             end_page = int(end)
139     return start_page, end_page
140
141 def draw_cut(canvas, x_spine_limit, direction, half_height):
142     outer_start_x = x_spine_limit - 0.5 * CUT_WIDTH * direction
143     inner_start_x = x_spine_limit + 0.5 * CUT_WIDTH * direction
144     middle_point_y = half_height + MIDDLE_POINT_DEPTH * direction
145     end_point_y = half_height + CUT_DEPTH * direction
146     canvas.line(inner_start_x, half_height, x_spine_limit, end_point_y)
147     canvas.line(x_spine_limit, end_point_y, x_spine_limit, middle_point_y)
148     canvas.line(x_spine_limit, middle_point_y, outer_start_x, half_height)
149
150 def parse_args():
151     parser = argparse.ArgumentParser(description=__doc__, epilog=help_epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
152     parser.add_argument("-i", "--input_file", action="append", required=True, help="input PDF file")
153     parser.add_argument("-o", "--output_file", required=True, help="output PDF file")
154     parser.add_argument("-p", "--page_range", action="append", help="page range, e.g., '2-9' or '3-end' or 'start-14'")
155     parser.add_argument("-c", "--crops", action="append", help="cm crops left, bottom, right, top – e.g., '10,10,10,10'; prefix with ':'-delimited page range to limit effect")
156     parser.add_argument("-r", "--rotate_page", type=int, action="append", help="rotate page of number by 90° (usable multiple times on same page!)")
157     parser.add_argument("-s", "--symmetry", action="store_true", help="alternate horizontal crops between odd and even pages")
158     parser.add_argument("-n", "--nup4", action='store_true', help="puts 4 input pages onto 1 output page, adds binding cut stencil")
159     parser.add_argument("-a", "--analyze", action="store_true", help="in --nup4, print lines identifying spine, page borders")
160     parser.add_argument("-m", "--print_margin", type=float, default=0.43, help="print margin for --nup4 in cm (default 0.43)")
161     args = parser.parse_args()
162
163     # some basic input validation
164     for filename in args.input_file:
165         if not os.path.isfile(filename):
166             raise HandledException(f"-i: {filename} is not a file")
167         try:
168             with open(filename, 'rb') as file:
169                 pypdf.PdfReader(file)
170         except pypdf.errors.PdfStreamError:
171             raise HandledException(f"-i: cannot interpret {filename} as PDF file")
172     if args.page_range:
173         for p_string in args.page_range:
174             validate_page_range(p_string, "-p")
175         if len(args.page_range) > len(args.input_file):
176             raise HandledException("-p: more --page_range arguments than --input_file arguments")
177     if args.crops:
178         for c_string in args.crops:
179             initial_split = c_string.split(':')
180             if len(initial_split) > 2:
181                 raise HandledException(f"-c: cropping string has multiple ':': {c_string}")
182             page_range, crops = split_crops_string(c_string)
183             crops = crops.split(",")
184             if page_range:
185                 validate_page_range(page_range, "-c")
186             if len(crops) != 4:
187                 raise HandledException(f"-c: cropping does not contain exactly three ',': {c_string}")
188             for crop in crops:
189                 try:
190                     float(crop)
191                 except ValueError:
192                     raise HandledException(f"-c: non-number crop in: {c_string}")
193     if args.rotate_page:
194         for r in args.rotate_page:
195             try:
196                 int(r)
197             except ValueError:
198                 raise HandledException(f"-r: non-integer value: {r}")
199             if r < 1:
200                 raise HandledException(f"-r: value must not be <1: {r}")
201     try:
202         float(args.print_margin)
203     except ValueError:
204         raise HandledException(f"-m: non-float value: {arg.print_margin}")
205
206     return args
207
208 def main():
209     args = parse_args()
210     if args.nup4:
211         try:
212             import reportlab.pdfgen.canvas
213         except ImportError:
214             raise HandledException("-n: need reportlab library installed for --nup4")
215
216     # select pages from input files
217     pages_to_add = []
218     opened_files = []
219     new_page_num = 0
220     for i, input_file in enumerate(args.input_file):
221         file = open(input_file, 'rb')
222         opened_files += [file]
223         reader = pypdf.PdfReader(file)
224         range_string = None
225         if args.page_range and len(args.page_range) > i:
226             range_string = args.page_range[i]
227         start_page, end_page = parse_page_range(range_string, reader.pages)
228         if end_page > len(reader.pages):  # no need to test start_page cause start_page > end_page is checked above
229             raise HandledException(f"-p: page range goes beyond pages of input file: {range_string}")
230         for old_page_num in range(start_page, end_page):
231             new_page_num += 1
232             page = reader.pages[old_page_num]
233             pages_to_add += [page]
234             print(f"-i, -p: read in {input_file} page number {old_page_num+1} as new page {new_page_num}")
235
236     # we can do some more input validations now that we know how many pages output should have
237     if args.crops:
238         for c_string in args.crops:
239             page_range, _= split_crops_string(c_string)
240             if page_range:
241                 start, end = parse_page_range(page_range, pages_to_add)
242                 if end > len(pages_to_add):
243                      raise HandledException(f"-c: page range goes beyond number of pages we're building: {page_range}")
244     if args.rotate_page:
245         for r in args.rotate_page:
246             if r > len(pages_to_add):
247                  raise HandledException(f"-r: page number beyond number of pages we're building: {r}")
248
249     # rotate page canvas (as opposed to using PDF's /Rotate command)
250     if args.rotate_page:
251         for rotate_page in args.rotate_page:
252             page = pages_to_add[rotate_page - 1]
253             page.add_transformation(pypdf.Transformation().translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2))
254             page.add_transformation(pypdf.Transformation().rotate(-90))
255             page.add_transformation(pypdf.Transformation().translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2))
256             print(f"-r: rotating (by 90°) page {rotate_page}")
257
258     # if necessary, pad pages to multiple of 8
259     if args.nup4:
260         mod_to_8 = len(pages_to_add) % 8
261         if mod_to_8 > 0:
262             print(f"-n: number of input pages {len(pages_to_add)} not multiple of 8, padding to that")
263             for _ in range(8 - mod_to_8):
264                 new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
265                 pages_to_add += [new_page]
266
267     # normalize all pages to portrait A4
268     for page in pages_to_add:
269         if "/Rotate" in page:
270             page.rotate(360 - page["/Rotate"])
271         page.mediabox.left = 0
272         page.mediabox.bottom = 0
273         page.mediabox.top = A4_HEIGHT
274         page.mediabox.right = A4_WIDTH
275         page.cropbox = page.mediabox
276
277     # determine page crops, zooms, crop symmetry
278     crops_at_page = [(0,0,0,0)]*len(pages_to_add)
279     zoom_at_page = [1]*len(pages_to_add)
280     if args.crops:
281         for c_string in args.crops:
282             page_range, crops = split_crops_string(c_string)
283             start_page, end_page = parse_page_range(page_range, pages_to_add)
284             crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm = [float(x) for x in  crops.split(',')]
285             crop_left = crop_left_cm * POINTS_PER_CM
286             crop_bottom = crop_bottom_cm * POINTS_PER_CM
287             crop_right = crop_right_cm * POINTS_PER_CM
288             crop_top = crop_top_cm * POINTS_PER_CM
289             prefix = "-c, -t" if args.symmetry else "-c"
290             suffix = " (but alternating left and right crop between even and odd pages)" if args.symmetry else ""
291             print(f"{prefix}: to pages {start_page + 1} to {end_page} applying crops: left {crop_left_cm}cm, bottom {crop_bottom_cm}cm, right {crop_right_cm}cm, top {crop_top_cm}cm{suffix}")
292             cropped_width  = A4_WIDTH - crop_left - crop_right
293             cropped_height = A4_HEIGHT - crop_bottom - crop_top
294             zoom = 1
295             zoom_horizontal = A4_WIDTH / (A4_WIDTH - crop_left - crop_right)
296             zoom_vertical = A4_HEIGHT / (A4_HEIGHT - crop_bottom - crop_top)
297             if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
298                 raise HandledException("-c: crops would create opposing zoom directions")
299             elif zoom_horizontal + zoom_vertical > 2:
300                 zoom = min(zoom_horizontal, zoom_vertical)
301             else:
302                 zoom = max(zoom_horizontal, zoom_vertical)
303             for page_num in range(start_page, end_page):
304                 if args.symmetry and page_num % 2:
305                     crops_at_page[page_num] = (crop_right, crop_bottom, crop_left, crop_top)
306                 else:
307                     crops_at_page[page_num] = (crop_left, crop_bottom, crop_right, crop_top)
308                 zoom_at_page[page_num] = zoom
309
310     writer = pypdf.PdfWriter()
311     if not args.nup4:
312         # single-page output
313         print("building 1-input-page-per-output-page book")
314         odd_page = True
315         for i, page in enumerate(pages_to_add):
316             crop_left, crop_bottom, crop_right, crop_top = crops_at_page[i]
317             zoom = zoom_at_page[i]
318             page.add_transformation(pypdf.Transformation().translate(tx=-crop_left, ty=-crop_bottom))
319             page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
320             cropped_width  = A4_WIDTH - crop_left - crop_right
321             cropped_height = A4_HEIGHT - crop_bottom - crop_top
322             page.mediabox.right = cropped_width * zoom
323             page.mediabox.top = cropped_height * zoom
324             writer.add_page(page)
325             odd_page = not odd_page
326             print(f"built page number {i+1} (of {len(pages_to_add)})")
327
328     else:
329         print("-n: building 4-input-pages-per-output-page book")
330         print(f"-m: applying printable-area margin of {args.print_margin}cm")
331         if args.analyze:
332             print("-a: drawing page borders, spine limits")
333         n_pages_per_axis = 2
334         printable_margin = args.print_margin * POINTS_PER_CM
335         printable_scale = (A4_WIDTH - 2*printable_margin)/A4_WIDTH
336         half_width = A4_WIDTH / n_pages_per_axis
337         half_height = A4_HEIGHT / n_pages_per_axis
338         section_scale_factor = 1 / n_pages_per_axis
339         spine_part_of_page = (SPINE_LIMIT / half_width) / printable_scale
340         bonus_shrink_factor = 1 - spine_part_of_page
341         new_page_order = []
342         new_i_order = []
343         eight_pack = []
344         i = 0
345         n_eights = 0
346         for page in pages_to_add:
347             if i == 0:
348                 eight_pack = []
349             eight_pack += [page]
350             i += 1
351             if i == 8:
352                 i = 0
353                 new_i_order += [8 * n_eights + 3,
354                                 8 * n_eights + 0,
355                                 8 * n_eights + 7,
356                                 8 * n_eights + 4,
357                                 8 * n_eights + 1,
358                                 8 * n_eights + 2,
359                                 8 * n_eights + 5,
360                                 8 * n_eights + 6]
361                 n_eights += 1
362                 new_page_order += [eight_pack[3]]  # page front, upper left
363                 new_page_order += [eight_pack[0]]  # page front, upper right
364                 new_page_order += [eight_pack[7]]  # page front, lower left
365                 new_page_order += [eight_pack[4]]  # page front, lower right
366                 new_page_order += [eight_pack[1]]  # page back, upper left
367                 new_page_order += [eight_pack[2]]  # page back, upper right
368                 new_page_order += [eight_pack[5]]  # page back, lower left
369                 new_page_order += [eight_pack[6]]  # page back, lower right
370         i = 0
371         page_count = 0
372         front_page = True
373         for j, page in enumerate(new_page_order):
374             if i == 0:
375                 new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
376
377             # in-section transformations: align pages on top, left-hand pages to left, right-hand to right
378             new_i = new_i_order[j]
379             crop_left, crop_bottom, crop_right, crop_top = crops_at_page[new_i]
380             zoom = zoom_at_page[new_i]
381             page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / zoom - (A4_HEIGHT - crop_top))))
382             if i == 0 or i == 2:
383                 page.add_transformation(pypdf.Transformation().translate(tx=-crop_left))
384             elif i == 1 or i == 3:
385                 page.add_transformation(pypdf.Transformation().translate(tx=(A4_WIDTH / zoom - (A4_WIDTH - crop_right))))
386             page.add_transformation(pypdf.Transformation().scale(zoom * bonus_shrink_factor, zoom * bonus_shrink_factor))
387             if i == 2 or i == 3:
388                 page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin/printable_scale))
389
390             # outer section transformations
391             page.add_transformation(pypdf.Transformation().translate(ty=(1-bonus_shrink_factor)*A4_HEIGHT))
392             if i == 0 or i == 1:
393                 y_section = A4_HEIGHT
394                 page.mediabox.bottom = half_height
395                 page.mediabox.top    = A4_HEIGHT
396             if i == 2 or i == 3:
397                 y_section = 0
398                 page.mediabox.bottom = 0
399                 page.mediabox.top  =   half_height
400             if i == 0 or i == 2:
401                 x_section = 0
402                 page.mediabox.left   = 0
403                 page.mediabox.right  = half_width
404             if i == 1 or i == 3:
405                 page.add_transformation(pypdf.Transformation().translate(tx=(1-bonus_shrink_factor)*A4_WIDTH))
406                 x_section = A4_WIDTH
407                 page.mediabox.left   = half_width
408                 page.mediabox.right  = A4_WIDTH
409             page.add_transformation(pypdf.Transformation().translate(tx=x_section, ty=y_section))
410             page.add_transformation(pypdf.Transformation().scale(section_scale_factor, section_scale_factor))
411             new_page.merge_page(page)
412             page_count += 1
413             print(f"merged page number {page_count} (of {len(pages_to_add)})")
414             i += 1
415             if i > 3:
416                 if args.analyze:
417                     # borders
418                     packet = io.BytesIO()
419                     c = reportlab.pdfgen.canvas.Canvas(packet, pagesize=A4)
420                     c.setLineWidth(0.1)
421                     c.line(0, A4_HEIGHT, A4_WIDTH, A4_HEIGHT)
422                     c.line(0, half_height, A4_WIDTH, half_height)
423                     c.line(0, 0, A4_WIDTH, 0)
424                     c.line(0, A4_HEIGHT, 0, 0)
425                     c.line(half_width, A4_HEIGHT, half_width, 0)
426                     c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0)
427                     c.save()
428                     new_pdf = pypdf.PdfReader(packet)
429                     new_page.merge_page(new_pdf.pages[0])
430                 printable_offset_x = printable_margin
431                 printable_offset_y = printable_margin * A4_HEIGHT / A4_WIDTH
432                 new_page.add_transformation(pypdf.Transformation().scale(printable_scale, printable_scale))
433                 new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y))
434                 x_left_spine_limit = half_width * bonus_shrink_factor
435                 x_right_spine_limit = A4_WIDTH - x_left_spine_limit
436                 if args.analyze or front_page:
437                     packet = io.BytesIO()
438                     c = reportlab.pdfgen.canvas.Canvas(packet, pagesize=A4)
439                 if args.analyze:
440                     # spine lines
441                     c.setLineWidth(0.1)
442                     c.line(x_left_spine_limit, A4_HEIGHT, x_left_spine_limit, 0)
443                     c.line(x_right_spine_limit, A4_HEIGHT, x_right_spine_limit, 0)
444                 if front_page:
445                     c.setLineWidth(0.2)
446                     draw_cut(c, x_left_spine_limit, (1), half_height)
447                     draw_cut(c, x_right_spine_limit, (-1), half_height)
448                 if args.analyze or front_page:
449                     c.save()
450                     new_pdf = pypdf.PdfReader(packet)
451                     new_page.merge_page(new_pdf.pages[0])
452                 writer.add_page(new_page)
453                 i = 0
454                 front_page = not front_page
455
456     # write and close
457     for file in opened_files:
458         file.close()
459     with open(args.output_file, 'wb') as output_file:
460         writer.write(output_file)
461
462
463 if __name__ == "__main__":
464     try:
465         main()
466     except HandledException as e:
467         handled_error_exit(e)