home · contact · privacy
Bookmaker: more refactoring.
[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 general paper geometry 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
82 # constants specifically for --nup4
83 A4_HALF_WIDTH = A4_WIDTH / 2
84 A4_HALF_HEIGHT = A4_HEIGHT / 2
85 CUT_DEPTH = 1.95 * POINTS_PER_CM
86 CUT_WIDTH = 1.05 * POINTS_PER_CM
87 MIDDLE_POINT_DEPTH = 0.4 * POINTS_PER_CM
88 SPINE_LIMIT = 1 * POINTS_PER_CM
89 QUARTER_SCALE_FACTOR = 0.5
90 PAGE_ORDER = (3,0,7,4,1,2,5,6)
91
92 # some helpers
93 class HandledException(Exception):
94     pass
95
96 def validate_page_range(p_string, err_msg_prefix):
97     prefix = f"{err_msg_prefix}: page range string"
98     if '-' not in p_string:
99         raise HandledException(f"{prefix} lacks '-': {p_string}")
100     tokens = p_string.split("-")
101     if len(tokens) > 2:
102         raise HandledException(f"{prefix} has too many '-': {p_string}")
103     for i, token in enumerate(tokens):
104         if token == "":
105             continue
106         if i == 0 and token == "start":
107             continue
108         if i == 1 and token == "end":
109             continue
110         try:
111             int(token)
112         except ValueError:
113             raise HandledException(f"{prefix} carries value neither integer, nor 'start', nor 'end': {p_string}")
114         if int(token) < 1:
115             raise HandledException(f"{prefix} carries page number <1: {p_string}")
116     start = -1
117     end = -1
118     try:
119         start = int(tokens[0])
120         end = int(tokens[1])
121     except ValueError:
122         pass
123     if start > 0 and end > 0 and start > end:
124         raise HandledException(f"{prefix} has higher start than end value: {p_string}")
125
126 def split_crops_string(c_string):
127     initial_split = c_string.split(':')
128     if len(initial_split) > 1:
129         page_range = initial_split[0]
130         crops = initial_split[1]
131     else:
132         page_range = None
133         crops = initial_split[0]
134     return page_range, crops
135
136 def parse_page_range(range_string, pages):
137     start_page = 0
138     end_page = len(pages)
139     if range_string:
140         start, end = range_string.split('-')
141         if not (len(start) == 0 or start == "start"):
142             start_page = int(start) - 1
143         if not (len(end) == 0 or end == "end"):
144             end_page = int(end)
145     return start_page, end_page
146
147 def draw_cut(canvas, x_spine_limit, direction):
148     outer_start_x = x_spine_limit - 0.5 * CUT_WIDTH * direction
149     inner_start_x = x_spine_limit + 0.5 * CUT_WIDTH * direction
150     middle_point_y =  A4_HALF_HEIGHT + MIDDLE_POINT_DEPTH * direction
151     end_point_y =  A4_HALF_HEIGHT + CUT_DEPTH * direction
152     canvas.line(inner_start_x, A4_HALF_HEIGHT, x_spine_limit, end_point_y)
153     canvas.line(x_spine_limit, end_point_y, x_spine_limit, middle_point_y)
154     canvas.line(x_spine_limit, middle_point_y, outer_start_x, A4_HALF_HEIGHT)
155
156 def parse_args():
157     parser = argparse.ArgumentParser(description=__doc__, epilog=help_epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
158     parser.add_argument("-i", "--input_file", action="append", required=True, help="input PDF file")
159     parser.add_argument("-o", "--output_file", required=True, help="output PDF file")
160     parser.add_argument("-p", "--page_range", action="append", help="page range, e.g., '2-9' or '3-end' or 'start-14'")
161     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")
162     parser.add_argument("-r", "--rotate_page", type=int, action="append", help="rotate page of number by 90° (usable multiple times on same page!)")
163     parser.add_argument("-s", "--symmetry", action="store_true", help="alternate horizontal crops between odd and even pages")
164     parser.add_argument("-n", "--nup4", action='store_true', help="puts 4 input pages onto 1 output page, adds binding cut stencil")
165     parser.add_argument("-a", "--analyze", action="store_true", help="in --nup4, print lines identifying spine, page borders")
166     parser.add_argument("-m", "--print_margin", type=float, default=0.43, help="print margin for --nup4 in cm (default 0.43)")
167     args = parser.parse_args()
168
169     # some basic input validation
170     for filename in args.input_file:
171         if not os.path.isfile(filename):
172             raise HandledException(f"-i: {filename} is not a file")
173         try:
174             with open(filename, 'rb') as file:
175                 pypdf.PdfReader(file)
176         except pypdf.errors.PdfStreamError:
177             raise HandledException(f"-i: cannot interpret {filename} as PDF file")
178     if args.page_range:
179         for p_string in args.page_range:
180             validate_page_range(p_string, "-p")
181         if len(args.page_range) > len(args.input_file):
182             raise HandledException("-p: more --page_range arguments than --input_file arguments")
183     if args.crops:
184         for c_string in args.crops:
185             initial_split = c_string.split(':')
186             if len(initial_split) > 2:
187                 raise HandledException(f"-c: cropping string has multiple ':': {c_string}")
188             page_range, crops = split_crops_string(c_string)
189             crops = crops.split(",")
190             if page_range:
191                 validate_page_range(page_range, "-c")
192             if len(crops) != 4:
193                 raise HandledException(f"-c: cropping does not contain exactly three ',': {c_string}")
194             for crop in crops:
195                 try:
196                     float(crop)
197                 except ValueError:
198                     raise HandledException(f"-c: non-number crop in: {c_string}")
199     if args.rotate_page:
200         for r in args.rotate_page:
201             try:
202                 int(r)
203             except ValueError:
204                 raise HandledException(f"-r: non-integer value: {r}")
205             if r < 1:
206                 raise HandledException(f"-r: value must not be <1: {r}")
207     try:
208         float(args.print_margin)
209     except ValueError:
210         raise HandledException(f"-m: non-float value: {arg.print_margin}")
211
212     return args
213
214 def main():
215     args = parse_args()
216     if args.nup4:
217         try:
218             import reportlab.pdfgen.canvas
219         except ImportError:
220             raise HandledException("-n: need reportlab library installed for --nup4")
221
222     # select pages from input files
223     pages_to_add = []
224     opened_files = []
225     new_page_num = 0
226     for i, input_file in enumerate(args.input_file):
227         file = open(input_file, 'rb')
228         opened_files += [file]
229         reader = pypdf.PdfReader(file)
230         range_string = None
231         if args.page_range and len(args.page_range) > i:
232             range_string = args.page_range[i]
233         start_page, end_page = parse_page_range(range_string, reader.pages)
234         if end_page > len(reader.pages):  # no need to test start_page cause start_page > end_page is checked above
235             raise HandledException(f"-p: page range goes beyond pages of input file: {range_string}")
236         for old_page_num in range(start_page, end_page):
237             new_page_num += 1
238             page = reader.pages[old_page_num]
239             pages_to_add += [page]
240             print(f"-i, -p: read in {input_file} page number {old_page_num+1} as new page {new_page_num}")
241
242     # we can do some more input validations now that we know how many pages output should have
243     if args.crops:
244         for c_string in args.crops:
245             page_range, _= split_crops_string(c_string)
246             if page_range:
247                 start, end = parse_page_range(page_range, pages_to_add)
248                 if end > len(pages_to_add):
249                      raise HandledException(f"-c: page range goes beyond number of pages we're building: {page_range}")
250     if args.rotate_page:
251         for r in args.rotate_page:
252             if r > len(pages_to_add):
253                  raise HandledException(f"-r: page number beyond number of pages we're building: {r}")
254
255     # rotate page canvas (as opposed to using PDF's /Rotate command)
256     if args.rotate_page:
257         for rotate_page in args.rotate_page:
258             page = pages_to_add[rotate_page - 1]
259             page.add_transformation(pypdf.Transformation().translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2))
260             page.add_transformation(pypdf.Transformation().rotate(-90))
261             page.add_transformation(pypdf.Transformation().translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2))
262             print(f"-r: rotating (by 90°) page {rotate_page}")
263
264     # if necessary, pad pages to multiple of 8
265     if args.nup4:
266         mod_to_8 = len(pages_to_add) % 8
267         if mod_to_8 > 0:
268             print(f"-n: number of input pages {len(pages_to_add)} not multiple of 8, padding to that")
269             for _ in range(8 - mod_to_8):
270                 new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
271                 pages_to_add += [new_page]
272
273     # normalize all pages to portrait A4
274     for page in pages_to_add:
275         if "/Rotate" in page:
276             page.rotate(360 - page["/Rotate"])
277         page.mediabox.left = 0
278         page.mediabox.bottom = 0
279         page.mediabox.top = A4_HEIGHT
280         page.mediabox.right = A4_WIDTH
281         page.cropbox = page.mediabox
282
283     # determine page crops, zooms, crop symmetry
284     crops_at_page = [(0,0,0,0)]*len(pages_to_add)
285     zoom_at_page = [1]*len(pages_to_add)
286     if args.crops:
287         for c_string in args.crops:
288             page_range, crops = split_crops_string(c_string)
289             start_page, end_page = parse_page_range(page_range, pages_to_add)
290             crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm = [float(x) for x in  crops.split(',')]
291             crop_left = crop_left_cm * POINTS_PER_CM
292             crop_bottom = crop_bottom_cm * POINTS_PER_CM
293             crop_right = crop_right_cm * POINTS_PER_CM
294             crop_top = crop_top_cm * POINTS_PER_CM
295             prefix = "-c, -t" if args.symmetry else "-c"
296             suffix = " (but alternating left and right crop between even and odd pages)" if args.symmetry else ""
297             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}")
298             cropped_width  = A4_WIDTH - crop_left - crop_right
299             cropped_height = A4_HEIGHT - crop_bottom - crop_top
300             zoom = 1
301             zoom_horizontal = A4_WIDTH / (A4_WIDTH - crop_left - crop_right)
302             zoom_vertical = A4_HEIGHT / (A4_HEIGHT - crop_bottom - crop_top)
303             if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
304                 raise HandledException("-c: crops would create opposing zoom directions")
305             elif zoom_horizontal + zoom_vertical > 2:
306                 zoom = min(zoom_horizontal, zoom_vertical)
307             else:
308                 zoom = max(zoom_horizontal, zoom_vertical)
309             for page_num in range(start_page, end_page):
310                 if args.symmetry and page_num % 2:
311                     crops_at_page[page_num] = (crop_right, crop_bottom, crop_left, crop_top)
312                 else:
313                     crops_at_page[page_num] = (crop_left, crop_bottom, crop_right, crop_top)
314                 zoom_at_page[page_num] = zoom
315
316     writer = pypdf.PdfWriter()
317     if not args.nup4:
318         # single-page output
319         print("building 1-input-page-per-output-page book")
320         odd_page = True
321         for i, page in enumerate(pages_to_add):
322             crop_left, crop_bottom, crop_right, crop_top = crops_at_page[i]
323             zoom = zoom_at_page[i]
324             page.add_transformation(pypdf.Transformation().translate(tx=-crop_left, ty=-crop_bottom))
325             page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
326             cropped_width  = A4_WIDTH - crop_left - crop_right
327             cropped_height = A4_HEIGHT - crop_bottom - crop_top
328             page.mediabox.right = cropped_width * zoom
329             page.mediabox.top = cropped_height * zoom
330             writer.add_page(page)
331             odd_page = not odd_page
332             print(f"built page number {i+1} (of {len(pages_to_add)})")
333
334     else:
335         # --nup4 output
336         print("-n: building 4-input-pages-per-output-page book")
337         print(f"-m: applying printable-area margin of {args.print_margin}cm")
338         if args.analyze:
339             print("-a: drawing page borders, spine limits")
340         printable_margin = args.print_margin * POINTS_PER_CM
341         printable_scale = (A4_WIDTH - 2 * printable_margin)/A4_WIDTH
342         spine_part_of_page = (SPINE_LIMIT / A4_HALF_WIDTH) / printable_scale
343         bonus_shrink_factor = 1 - spine_part_of_page
344         new_page_order = []
345         new_i_order = []
346         eight_pack = []
347         i = 0
348         n_eights = 0
349         for page in pages_to_add:
350             if i == 0:
351                 eight_pack = []
352             eight_pack += [page]
353             i += 1
354             if i == 8:
355                 i = 0
356                 for n in PAGE_ORDER:
357                     new_i_order += [8 * n_eights + n]
358                     new_page_order += [eight_pack[n]]
359                 n_eights += 1
360         i = 0
361         page_count = 0
362         front_page = True
363         for j, page in enumerate(new_page_order):
364             if i == 0:
365                 new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
366
367             # in-section transformations: align pages on top, left-hand pages to left, right-hand to right
368             new_i = new_i_order[j]
369             crop_left, crop_bottom, crop_right, crop_top = crops_at_page[new_i]
370             zoom = zoom_at_page[new_i]
371             page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / zoom - (A4_HEIGHT - crop_top))))
372             if i == 0 or i == 2:
373                 page.add_transformation(pypdf.Transformation().translate(tx=-crop_left))
374             elif i == 1 or i == 3:
375                 page.add_transformation(pypdf.Transformation().translate(tx=(A4_WIDTH / zoom - (A4_WIDTH - crop_right))))
376             page.add_transformation(pypdf.Transformation().scale(zoom * bonus_shrink_factor, zoom * bonus_shrink_factor))
377             if i == 2 or i == 3:
378                 page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin/printable_scale))
379
380             # outer section transformations
381             page.add_transformation(pypdf.Transformation().translate(ty=(1-bonus_shrink_factor)*A4_HEIGHT))
382             if i == 0 or i == 1:
383                 y_section = A4_HEIGHT
384                 page.mediabox.bottom = A4_HALF_HEIGHT
385                 page.mediabox.top    = A4_HEIGHT
386             if i == 2 or i == 3:
387                 y_section = 0
388                 page.mediabox.bottom = 0
389                 page.mediabox.top  =  A4_HALF_HEIGHT
390             if i == 0 or i == 2:
391                 x_section = 0
392                 page.mediabox.left   = 0
393                 page.mediabox.right  = A4_HALF_WIDTH
394             if i == 1 or i == 3:
395                 page.add_transformation(pypdf.Transformation().translate(tx=(1-bonus_shrink_factor)*A4_WIDTH))
396                 x_section = A4_WIDTH
397                 page.mediabox.left   = A4_HALF_WIDTH
398                 page.mediabox.right  = A4_WIDTH
399             page.add_transformation(pypdf.Transformation().translate(tx=x_section, ty=y_section))
400             page.add_transformation(pypdf.Transformation().scale(QUARTER_SCALE_FACTOR, QUARTER_SCALE_FACTOR))
401             new_page.merge_page(page)
402             page_count += 1
403             print(f"merged page number {page_count} (of {len(pages_to_add)})")
404             i += 1
405             if i > 3:
406                 if args.analyze:
407                     # borders
408                     packet = io.BytesIO()
409                     c = reportlab.pdfgen.canvas.Canvas(packet, pagesize=A4)
410                     c.setLineWidth(0.1)
411                     c.line(0, A4_HEIGHT, A4_WIDTH, A4_HEIGHT)
412                     c.line(0, A4_HALF_HEIGHT, A4_WIDTH, A4_HALF_HEIGHT)
413                     c.line(0, 0, A4_WIDTH, 0)
414                     c.line(0, A4_HEIGHT, 0, 0)
415                     c.line(A4_HALF_WIDTH, A4_HEIGHT, A4_HALF_WIDTH, 0)
416                     c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0)
417                     c.save()
418                     new_pdf = pypdf.PdfReader(packet)
419                     new_page.merge_page(new_pdf.pages[0])
420                 printable_offset_x = printable_margin
421                 printable_offset_y = printable_margin * A4_HEIGHT / A4_WIDTH
422                 new_page.add_transformation(pypdf.Transformation().scale(printable_scale, printable_scale))
423                 new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y))
424                 x_left_spine_limit = A4_HALF_WIDTH * bonus_shrink_factor
425                 x_right_spine_limit = A4_WIDTH - x_left_spine_limit
426                 if args.analyze or front_page:
427                     packet = io.BytesIO()
428                     c = reportlab.pdfgen.canvas.Canvas(packet, pagesize=A4)
429                 if args.analyze:
430                     # spine lines
431                     c.setLineWidth(0.1)
432                     c.line(x_left_spine_limit, A4_HEIGHT, x_left_spine_limit, 0)
433                     c.line(x_right_spine_limit, A4_HEIGHT, x_right_spine_limit, 0)
434                 if front_page:
435                     c.setLineWidth(0.2)
436                     draw_cut(c, x_left_spine_limit, (1))
437                     draw_cut(c, x_right_spine_limit, (-1))
438                 if args.analyze or front_page:
439                     c.save()
440                     new_pdf = pypdf.PdfReader(packet)
441                     new_page.merge_page(new_pdf.pages[0])
442                 writer.add_page(new_page)
443                 i = 0
444                 front_page = not front_page
445
446     # write and close
447     for file in opened_files:
448         file.close()
449     with open(args.output_file, 'wb') as output_file:
450         writer.write(output_file)
451
452
453 if __name__ == "__main__":
454     try:
455         main()
456     except HandledException as e:
457         handled_error_exit(e)