home · contact · privacy
Bookmaker: use actual Python exception for zoom error.
[misc] / bookmaker.py
1 #!/usr/bin/env python3
2 import pypdf
3 import argparse
4 import io
5 from reportlab.lib.pagesizes import A4
6 a4_width, a4_height = A4
7 points_per_cm = 10 * 72 / 25.4
8 cut_depth = 1.95 * points_per_cm
9 cut_width = 1.05 * points_per_cm
10 middle_point_depth = 0.4 * points_per_cm
11 spine_limit = 1 * points_per_cm
12
13 desc = """bookmaker.py is a helper for optimizing PDFs of books for the production of small self-printed, self-bound physical books  To this goal it offers various PDF manipulation options potentially that can also be used indepéndently and for other purposes.
14 """
15 epilogue = """
16 EXAMPLES:
17
18 Concatenate two PDFs A.pdf and B.pdf to COMBINED.pdf:
19     bookmaker.py --input_file A.pdf --input_file B.pdf --output_file COMBINED.pdf
20
21 Produce OUTPUT.pdf containing all pages of (inclusive) page number range 3-7 from INPUT.pdf:
22     bookmaker.py -i INPUT.pdf --page_range 3-7 -o OUTPUT.pdf
23
24 Produce COMBINED-pdf from A.pdf's first 7 pages, B.pdf's pages except its first two, and all pages of C.pdf:
25     bookmaker.py -i A.pdf -p start-7 -i B.pdf -p 3-end -i C.pdf -o COMBINED.pdf
26
27 Crop each page 5cm from the left, 10cm from the bottom, 2cm from the right, and 0cm from the top:
28     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --crops "5,10,2,0"
29
30 Include all pages from INPUT.pdf, but crop pages 10-20 by 5cm each from bottom and top:
31     bookmaker.py -i INPUT.pdf -c "10-20:0,5,0,5" -o OUTPUT.pdf
32
33 Same crops from on pages 10-20, but also crop all pages 30 and later by 3cm each from left and right:
34     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -c "10-20:0,5,0,5" -c "30-end:3,0,3,0"
35
36 Rotate by 90° pages 3, 5, 7; rotate page 7 once more by 90% (i.e. 180° in total):
37     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --rotate 3 -r 5 -r 7 -r 7
38
39 Initially declare 5cm crop from the left and 1cm crop from right, but alternate direction between even and odd pages:
40     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -c "5,0,1,0" -s
41
42 Quarter each OUTPUT.pdf page to carry 4 pages from INPUT.pdf, draw stencils into inner margins for cuts to carry binding strings:
43     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --nup4
44
45 Same as --nup4, but define a printable-region margin of 1.3cm to limit the space for the INPUT.pdf pages in OUTPUT.pdf page quarters:
46     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --print_margin 1.3
47
48 Same as -n, but draw lines marking printable-region margins, page quarts, spine margins:
49     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --analyze
50
51 NOTES:
52
53 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).
54
55 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):
56
57  (front)      (back)
58 +-------+   +-------+
59 | 4 | 1 |   | 3 | 2 |
60 |-------|   |-------|
61 | 8 | 5 |   | 7 | 6 |
62 +-------+   +-------+
63
64 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.
65
66 (To turn this 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 it 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.)
67 """
68
69 # parser = argparse.ArgumentParser(description="build print-ready book PDF")
70 parser = argparse.ArgumentParser(description=desc, epilog=epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
71 parser._optionals.title = "OPTIONS"
72 parser.add_argument("-i", "--input_file", action="append", required=True, help="input PDF file")
73 parser.add_argument("-o", "--output_file", required=True, help="output PDF file")
74 parser.add_argument("-p", "--page_range", action="append", help="page range, e.g., '3-end'")
75 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")
76 parser.add_argument("-r", "--rotate_page", type=int, action="append", help="rotate page of number by 90° (usable multiple times on same page!)")
77 parser.add_argument("-s", "--symmetry", action="store_true", help="alternate horizontal crops between odd and even pages")
78 parser.add_argument("-n", "--nup4", action='store_true', help="puts 4 input pages onto 1 output page, adds binding cut stencil")
79 parser.add_argument("-a", "--analyze", action="store_true", help="in --nup4, print lines identifying spine, page borders")
80 parser.add_argument("-m", "--print_margin", type=float, default=0.43, help="print margin for --nup4 in cm (default 0.43)")
81 parser.add_argument("-H", "--long_help", action="store_true", help="show examples, explanations, additional usage notes")
82 args = parser.parse_args()
83
84 # select pages from input files
85 def parse_page_range(range_string, pages):
86     start_page = 0
87     end_page = len(pages)
88     if range_string:
89         start, end = range_string.split('-')
90         if not (len(start) == 0 or start == "start"):
91             start_page = int(start) - 1
92         if not (len(end) == 0 or end == "end"):
93             end_page = int(end)
94     return start_page, end_page
95 pages_to_add = []
96 opened_files = []
97 new_page_num = 0
98 for i, input_file in enumerate(args.input_file):
99     file = open(input_file, 'rb')
100     opened_files += [file]
101     reader = pypdf.PdfReader(file)
102     range_string = None
103     if args.page_range and len(args.page_range) > i:
104         range_string = args.page_range[i]
105     start_page, end_page = parse_page_range(range_string, reader.pages)
106     for old_page_num in range(start_page, end_page):
107         new_page_num += 1
108         page = reader.pages[old_page_num]
109         pages_to_add += [page]
110         print("-i, -p: read in %s page number %d as new page %d" % (input_file, old_page_num+1, new_page_num))
111
112 # if necessary, pad pages to multiple of 8
113 if args.nup4:
114     mod_to_8 = len(pages_to_add) % 8
115     if mod_to_8 > 0:
116         print("-n: number of input pages %d not multiple of 8, padding to that" % len(pages_to_add))
117         for _ in range(8 - mod_to_8):
118             new_page = pypdf.PageObject.create_blank_page(width=a4_width, height=a4_height)
119             pages_to_add += [new_page]
120
121 # rotate page canvas
122 if args.rotate_page:
123     for rotate_page in args.rotate_page:
124         page = pages_to_add[rotate_page - 1]
125         page.add_transformation(pypdf.Transformation().translate(tx=-a4_width/2, ty=-a4_height/2))
126         page.add_transformation(pypdf.Transformation().rotate(-90))
127         page.add_transformation(pypdf.Transformation().translate(tx=a4_width/2, ty=a4_height/2))
128         print("-r: rotating (by 90°) page", rotate_page)
129
130 # normalize all pages to portrait A4
131 for page in pages_to_add:
132     if "/Rotate" in page:
133         page.rotate(360 - page["/Rotate"])
134     page.mediabox.left = 0
135     page.mediabox.bottom = 0
136     page.mediabox.top = a4_height
137     page.mediabox.right = a4_width
138     page.cropbox = page.mediabox
139
140 # determine page crops, zooms, crop symmetry
141 crops_at_page = [(0,0,0,0)]*len(pages_to_add)
142 zoom_at_page = [1]*len(pages_to_add)
143 if args.crops:
144   for crops in args.crops:
145       initial_split = crops.split(':')
146       if len(initial_split) > 1:
147           page_range = initial_split[0]
148           crops = initial_split[1]
149       else:
150           page_range = None
151           crops = initial_split[0]
152       start_page, end_page = parse_page_range(page_range, pages_to_add)
153       crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm = [float(x) for x in  crops.split(',')]
154       crop_left = crop_left_cm * points_per_cm
155       crop_bottom = crop_bottom_cm * points_per_cm
156       crop_right = crop_right_cm * points_per_cm
157       crop_top = crop_top_cm * points_per_cm
158       if args.symmetry:
159           print("-c, -t: to pages %d to %d applying crops: left %.2fcm, bottom %.2fcm, right %.2fcm, top %.2fcm (but alternating left and right crop between even and odd pages)" % (start_page + 1, end_page, crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm))
160       else:
161           print("-c: to pages %d to %d applying crops: left %.2fcm, bottom %.2fcm, right %.2fcm, top %.2fcm" % (start_page + 1, end_page, crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm))
162       cropped_width  = a4_width - crop_left - crop_right
163       cropped_height = a4_height - crop_bottom - crop_top
164       zoom = 1
165       zoom_horizontal = a4_width / (a4_width - crop_left - crop_right)
166       zoom_vertical = a4_height / (a4_height - crop_bottom - crop_top)
167       if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
168           raise ValueError("crops would create opposing zoom directions")
169       elif zoom_horizontal + zoom_vertical > 2:
170           zoom = min(zoom_horizontal, zoom_vertical)
171       else:
172           zoom = max(zoom_horizontal, zoom_vertical)
173       for page_num in range(start_page, end_page):
174           if args.symmetry and page_num % 2:
175               crops_at_page[page_num] = (crop_right, crop_bottom, crop_left, crop_top)
176           else:
177               crops_at_page[page_num] = (crop_left, crop_bottom, crop_right, crop_top)
178           zoom_at_page[page_num] = zoom
179
180 writer = pypdf.PdfWriter()
181 if not args.nup4:
182     # single-page output
183     print("building 1-input-page-per-output-page book")
184     odd_page = True
185     for i, page in enumerate(pages_to_add):
186         crop_left, crop_bottom, crop_right, crop_top = crops_at_page[i]
187         zoom = zoom_at_page[i]
188         page.add_transformation(pypdf.Transformation().translate(tx=-crop_left, ty=-crop_bottom))
189         page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
190         cropped_width  = a4_width - crop_left - crop_right
191         cropped_height = a4_height - crop_bottom - crop_top
192         page.mediabox.right = cropped_width * zoom
193         page.mediabox.top = cropped_height * zoom
194         writer.add_page(page)
195         odd_page = not odd_page
196         print("built page number %d (of %d)" % (i+1, len(pages_to_add)))
197
198 else:
199     print("-n: building 4-input-pages-per-output-page book")
200     print("-m: applying printable-area margin of %.2fcm" % args.print_margin)
201     if args.analyze:
202         print("-a: drawing page borders, spine limits")
203     n_pages_per_axis = 2
204     printable_margin = args.print_margin * points_per_cm
205     printable_scale = (a4_width - 2*printable_margin)/a4_width
206     half_width = a4_width / n_pages_per_axis
207     half_height = a4_height / n_pages_per_axis
208     section_scale_factor = 1 / n_pages_per_axis
209     spine_part_of_page = (spine_limit / half_width) / printable_scale
210     bonus_shrink_factor = 1 - spine_part_of_page
211     new_page_order = []
212     new_i_order = []
213     eight_pack = []
214     i = 0
215     n_eights = 0
216     for page in pages_to_add:
217         if i == 0:
218             eight_pack = []
219         eight_pack += [page]
220         i += 1
221         if i == 8:
222             i = 0
223             new_i_order += [8 * n_eights + 3,
224                             8 * n_eights + 0,
225                             8 * n_eights + 7,
226                             8 * n_eights + 4,
227                             8 * n_eights + 1,
228                             8 * n_eights + 2,
229                             8 * n_eights + 5,
230                             8 * n_eights + 6]
231             n_eights += 1
232             new_page_order += [eight_pack[3]]  # page front, upper left
233             new_page_order += [eight_pack[0]]  # page front, upper right
234             new_page_order += [eight_pack[7]]  # page front, lower left
235             new_page_order += [eight_pack[4]]  # page front, lower right
236             new_page_order += [eight_pack[1]]  # page back, upper left
237             new_page_order += [eight_pack[2]]  # page back, upper right
238             new_page_order += [eight_pack[5]]  # page back, lower left
239             new_page_order += [eight_pack[6]]  # page back, lower right
240     i = 0
241     page_count = 0
242     front_page = True
243     for j, page in enumerate(new_page_order):
244         if i == 0:
245             new_page = pypdf.PageObject.create_blank_page(width=a4_width, height=a4_height)
246
247         # in-section transformations: align pages on top, left-hand pages to left, right-hand to right
248         new_i = new_i_order[j]
249         crop_left, crop_bottom, crop_right, crop_top = crops_at_page[new_i]
250         zoom = zoom_at_page[new_i]
251         page.add_transformation(pypdf.Transformation().translate(ty=(a4_height / zoom - (a4_height - crop_top))))
252         if i == 0 or i == 2:
253             page.add_transformation(pypdf.Transformation().translate(tx=-crop_left))
254         elif i == 1 or i == 3:
255             page.add_transformation(pypdf.Transformation().translate(tx=(a4_width / zoom - (a4_width - crop_right))))
256         page.add_transformation(pypdf.Transformation().scale(zoom * bonus_shrink_factor, zoom * bonus_shrink_factor))
257         if i == 2 or i == 3:
258             page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin/printable_scale))
259
260         # outer section transformations
261         page.add_transformation(pypdf.Transformation().translate(ty=(1-bonus_shrink_factor)*a4_height))
262         if i == 0 or i == 1:
263             y_section = a4_height
264             page.mediabox.bottom = half_height
265             page.mediabox.top    = a4_height
266         if i == 2 or i == 3:
267             y_section = 0
268             page.mediabox.bottom = 0
269             page.mediabox.top  =   half_height
270         if i == 0 or i == 2:
271             x_section = 0
272             page.mediabox.left   = 0
273             page.mediabox.right  = half_width
274         if i == 1 or i == 3:
275             page.add_transformation(pypdf.Transformation().translate(tx=(1-bonus_shrink_factor)*a4_width))
276             x_section = a4_width
277             page.mediabox.left   = half_width
278             page.mediabox.right  = a4_width
279         page.add_transformation(pypdf.Transformation().translate(tx=x_section, ty=y_section))
280         page.add_transformation(pypdf.Transformation().scale(section_scale_factor, section_scale_factor))
281         new_page.merge_page(page)
282         page_count += 1
283         print("merged page number %d (of %d)" % (page_count, len(pages_to_add)))
284         i += 1
285         if i > 3:
286             from reportlab.pdfgen import canvas
287             if args.analyze:
288                 # borders
289                 packet = io.BytesIO()
290                 c = canvas.Canvas(packet, pagesize=A4)
291                 c.setLineWidth(0.1)
292                 c.line(0, a4_height, a4_width, a4_height)
293                 c.line(0, half_height, a4_width, half_height)
294                 c.line(0, 0, a4_width, 0)
295                 c.line(0, a4_height, 0, 0)
296                 c.line(half_width, a4_height, half_width, 0)
297                 c.line(a4_width, a4_height, a4_width, 0)
298                 c.save()
299                 new_pdf = pypdf.PdfReader(packet)
300                 new_page.merge_page(new_pdf.pages[0])
301             printable_offset_x = printable_margin
302             printable_offset_y = printable_margin * a4_height / a4_width
303             new_page.add_transformation(pypdf.Transformation().scale(printable_scale, printable_scale))
304             new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y))
305             x_left_spine_limit = half_width * bonus_shrink_factor
306             x_right_spine_limit = a4_width - x_left_spine_limit
307             if args.analyze or front_page:
308                 packet = io.BytesIO()
309                 c = canvas.Canvas(packet, pagesize=A4)
310             if args.analyze:
311                 # # spine lines
312                 c.setLineWidth(0.1)
313                 c.line(x_left_spine_limit, a4_height, x_left_spine_limit, 0)
314                 c.line(x_right_spine_limit, a4_height, x_right_spine_limit, 0)
315             if front_page:
316                 c.setLineWidth(0.2)
317
318                 start_up_left_left_x = x_left_spine_limit - 0.5 * cut_width
319                 start_up_left_right_x = x_left_spine_limit + 0.5 * cut_width
320                 middle_point_up_left_y = half_height + middle_point_depth
321                 end_point_up_left_y = half_height + cut_depth
322                 c.line(start_up_left_right_x, half_height, x_left_spine_limit, end_point_up_left_y)
323                 c.line(x_left_spine_limit, end_point_up_left_y, x_left_spine_limit, middle_point_up_left_y)
324                 c.line(x_left_spine_limit, middle_point_up_left_y, start_up_left_left_x, half_height)
325
326                 start_down_right_left_x = x_right_spine_limit - 0.5 * cut_width
327                 start_down_right_right_x = x_right_spine_limit + 0.5 * cut_width
328                 middle_point_down_right_y = half_height - middle_point_depth
329                 end_point_down_right_y = half_height - cut_depth
330                 c.line(start_down_right_left_x, half_height, x_right_spine_limit, end_point_down_right_y)
331                 c.line(x_right_spine_limit, end_point_down_right_y, x_right_spine_limit, middle_point_down_right_y)
332                 c.line(x_right_spine_limit, middle_point_down_right_y, start_down_right_right_x, half_height)
333
334             if args.analyze or front_page:
335                 c.save()
336                 new_pdf = pypdf.PdfReader(packet)
337                 new_page.merge_page(new_pdf.pages[0])
338             writer.add_page(new_page)
339             i = 0
340             front_page = not front_page
341
342 # write and close
343 for file in opened_files:
344     file.close()
345 with open(args.output_file, 'wb') as output_file:
346     writer.write(output_file)