home · contact · privacy
Bookmaker: Improve documentation.
[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
85 # select pages from input files
86 def parse_page_range(range_string, pages):
87     start_page = 0
88     end_page = len(pages)
89     if range_string:
90         start, end = range_string.split('-')
91         if not (len(start) == 0 or start == "start"):
92             start_page = int(start) - 1
93         if not (len(end) == 0 or end == "end"):
94             end_page = int(end)
95     return start_page, end_page
96 pages_to_add = []
97 opened_files = []
98 new_page_num = 0
99 for i, input_file in enumerate(args.input_file):
100     file = open(input_file, 'rb')
101     opened_files += [file]
102     reader = pypdf.PdfReader(file)
103     range_string = None
104     if args.page_range and len(args.page_range) > i:
105         range_string = args.page_range[i]
106     start_page, end_page = parse_page_range(range_string, reader.pages)
107     for old_page_num in range(start_page, end_page):
108         new_page_num += 1
109         page = reader.pages[old_page_num]
110         pages_to_add += [page]
111         print("-i, -p: read in %s page number %d as new page %d" % (input_file, old_page_num+1, new_page_num))
112
113 # if necessary, pad pages to multiple of 8
114 if args.nup4:
115     mod_to_8 = len(pages_to_add) % 8
116     if mod_to_8 > 0:
117         print("-n: number of input pages %d not multiple of 8, padding to that" % len(pages_to_add))
118         for _ in range(8 - mod_to_8):
119             new_page = pypdf.PageObject.create_blank_page(width=a4_width, height=a4_height)
120             pages_to_add += [new_page]
121
122 # rotate page canvas
123 if args.rotate_page:
124     for rotate_page in args.rotate_page:
125         page = pages_to_add[rotate_page - 1]
126         page.add_transformation(pypdf.Transformation().translate(tx=-a4_width/2, ty=-a4_height/2))
127         page.add_transformation(pypdf.Transformation().rotate(-90))
128         page.add_transformation(pypdf.Transformation().translate(tx=a4_width/2, ty=a4_height/2))
129         print("-r: rotating (by 90°) page", rotate_page)
130
131 # normalize all pages to portrait A4
132 for page in pages_to_add:
133     if "/Rotate" in page:
134         page.rotate(360 - page["/Rotate"])
135     page.mediabox.left = 0
136     page.mediabox.bottom = 0
137     page.mediabox.top = a4_height
138     page.mediabox.right = a4_width
139     page.cropbox = page.mediabox
140
141 # determine page crops, zooms, crop symmetry
142 crops_at_page = [(0,0,0,0)]*len(pages_to_add)
143 zoom_at_page = [1]*len(pages_to_add)
144 if args.crops:
145   for crops in args.crops:
146       initial_split = crops.split(':')
147       if len(initial_split) > 1:
148           page_range = initial_split[0]
149           crops = initial_split[1]
150       else:
151           page_range = None
152           crops = initial_split[0]
153       start_page, end_page = parse_page_range(page_range, pages_to_add)
154       crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm = [float(x) for x in  crops.split(',')]
155       crop_left = crop_left_cm * points_per_cm
156       crop_bottom = crop_bottom_cm * points_per_cm
157       crop_right = crop_right_cm * points_per_cm
158       crop_top = crop_top_cm * points_per_cm
159       if args.symmetry:
160           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))
161       else:
162           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))
163       cropped_width  = a4_width - crop_left - crop_right
164       cropped_height = a4_height - crop_bottom - crop_top
165       zoom = 1
166       zoom_horizontal = a4_width / (a4_width - crop_left - crop_right)
167       zoom_vertical = a4_height / (a4_height - crop_bottom - crop_top)
168       if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
169           print("Error: opposing zooms.")
170           exit(1)
171       elif zoom_horizontal + zoom_vertical > 2:
172           zoom = min(zoom_horizontal, zoom_vertical)
173       else:
174           zoom = max(zoom_horizontal, zoom_vertical)
175       for page_num in range(start_page, end_page):
176           if args.symmetry and page_num % 2:
177               crops_at_page[page_num] = (crop_right, crop_bottom, crop_left, crop_top)
178           else:
179               crops_at_page[page_num] = (crop_left, crop_bottom, crop_right, crop_top)
180           zoom_at_page[page_num] = zoom
181
182 writer = pypdf.PdfWriter()
183 if not args.nup4:
184     # single-page output
185     print("building 1-input-page-per-output-page book")
186     odd_page = True
187     for i, page in enumerate(pages_to_add):
188         crop_left, crop_bottom, crop_right, crop_top = crops_at_page[i]
189         zoom = zoom_at_page[i]
190         page.add_transformation(pypdf.Transformation().translate(tx=-crop_left, ty=-crop_bottom))
191         page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
192         cropped_width  = a4_width - crop_left - crop_right
193         cropped_height = a4_height - crop_bottom - crop_top
194         page.mediabox.right = cropped_width * zoom
195         page.mediabox.top = cropped_height * zoom
196         writer.add_page(page)
197         odd_page = not odd_page
198         print("built page number %d (of %d)" % (i+1, len(pages_to_add)))
199
200 else:
201     print("-n: building 4-input-pages-per-output-page book")
202     print("-m: applying printable-area margin of %.2fcm" % args.print_margin)
203     if args.analyze:
204         print("-a: drawing page borders, spine limits")
205     n_pages_per_axis = 2
206     printable_margin = args.print_margin * points_per_cm
207     printable_scale = (a4_width - 2*printable_margin)/a4_width
208     half_width = a4_width / n_pages_per_axis
209     half_height = a4_height / n_pages_per_axis
210     section_scale_factor = 1 / n_pages_per_axis
211     spine_part_of_page = (spine_limit / half_width) / printable_scale
212     bonus_shrink_factor = 1 - spine_part_of_page
213     new_page_order = []
214     new_i_order = []
215     eight_pack = []
216     i = 0
217     n_eights = 0
218     for page in pages_to_add:
219         if i == 0:
220             eight_pack = []
221         eight_pack += [page]
222         i += 1
223         if i == 8:
224             i = 0
225             new_i_order += [8 * n_eights + 3,
226                             8 * n_eights + 0,
227                             8 * n_eights + 7,
228                             8 * n_eights + 4,
229                             8 * n_eights + 1,
230                             8 * n_eights + 2,
231                             8 * n_eights + 5,
232                             8 * n_eights + 6]
233             n_eights += 1
234             new_page_order += [eight_pack[3]]  # page front, upper left
235             new_page_order += [eight_pack[0]]  # page front, upper right
236             new_page_order += [eight_pack[7]]  # page front, lower left
237             new_page_order += [eight_pack[4]]  # page front, lower right
238             new_page_order += [eight_pack[1]]  # page back, upper left
239             new_page_order += [eight_pack[2]]  # page back, upper right
240             new_page_order += [eight_pack[5]]  # page back, lower left
241             new_page_order += [eight_pack[6]]  # page back, lower right
242     i = 0
243     page_count = 0
244     front_page = True
245     for j, page in enumerate(new_page_order):
246         if i == 0:
247             new_page = pypdf.PageObject.create_blank_page(width=a4_width, height=a4_height)
248
249         # in-section transformations: align pages on top, left-hand pages to left, right-hand to right
250         new_i = new_i_order[j]
251         crop_left, crop_bottom, crop_right, crop_top = crops_at_page[new_i]
252         zoom = zoom_at_page[new_i]
253         page.add_transformation(pypdf.Transformation().translate(ty=(a4_height / zoom - (a4_height - crop_top))))
254         if i == 0 or i == 2:
255             page.add_transformation(pypdf.Transformation().translate(tx=-crop_left))
256         elif i == 1 or i == 3:
257             page.add_transformation(pypdf.Transformation().translate(tx=(a4_width / zoom - (a4_width - crop_right))))
258         page.add_transformation(pypdf.Transformation().scale(zoom * bonus_shrink_factor, zoom * bonus_shrink_factor))
259         if i == 2 or i == 3:
260             page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin/printable_scale))
261
262         # outer section transformations
263         page.add_transformation(pypdf.Transformation().translate(ty=(1-bonus_shrink_factor)*a4_height))
264         if i == 0 or i == 1:
265             y_section = a4_height
266             page.mediabox.bottom = half_height
267             page.mediabox.top    = a4_height
268         if i == 2 or i == 3:
269             y_section = 0
270             page.mediabox.bottom = 0
271             page.mediabox.top  =   half_height
272         if i == 0 or i == 2:
273             x_section = 0
274             page.mediabox.left   = 0
275             page.mediabox.right  = half_width
276         if i == 1 or i == 3:
277             page.add_transformation(pypdf.Transformation().translate(tx=(1-bonus_shrink_factor)*a4_width))
278             x_section = a4_width
279             page.mediabox.left   = half_width
280             page.mediabox.right  = a4_width
281         page.add_transformation(pypdf.Transformation().translate(tx=x_section, ty=y_section))
282         page.add_transformation(pypdf.Transformation().scale(section_scale_factor, section_scale_factor))
283         new_page.merge_page(page)
284         page_count += 1
285         print("merged page number %d (of %d)" % (page_count, len(pages_to_add)))
286         i += 1
287         if i > 3:
288             from reportlab.pdfgen import canvas
289             if args.analyze:
290                 # borders
291                 packet = io.BytesIO()
292                 c = canvas.Canvas(packet, pagesize=A4)
293                 c.setLineWidth(0.1)
294                 c.line(0, a4_height, a4_width, a4_height)
295                 c.line(0, half_height, a4_width, half_height)
296                 c.line(0, 0, a4_width, 0)
297                 c.line(0, a4_height, 0, 0)
298                 c.line(half_width, a4_height, half_width, 0)
299                 c.line(a4_width, a4_height, a4_width, 0)
300                 c.save()
301                 new_pdf = pypdf.PdfReader(packet)
302                 new_page.merge_page(new_pdf.pages[0])
303             printable_offset_x = printable_margin
304             printable_offset_y = printable_margin * a4_height / a4_width
305             new_page.add_transformation(pypdf.Transformation().scale(printable_scale, printable_scale))
306             new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y))
307             x_left_spine_limit = half_width * bonus_shrink_factor
308             x_right_spine_limit = a4_width - x_left_spine_limit
309             if args.analyze or front_page:
310                 packet = io.BytesIO()
311                 c = canvas.Canvas(packet, pagesize=A4)
312             if args.analyze:
313                 # # spine lines
314                 c.setLineWidth(0.1)
315                 c.line(x_left_spine_limit, a4_height, x_left_spine_limit, 0)
316                 c.line(x_right_spine_limit, a4_height, x_right_spine_limit, 0)
317             if front_page:
318                 c.setLineWidth(0.2)
319
320                 start_up_left_left_x = x_left_spine_limit - 0.5 * cut_width
321                 start_up_left_right_x = x_left_spine_limit + 0.5 * cut_width
322                 middle_point_up_left_y = half_height + middle_point_depth
323                 end_point_up_left_y = half_height + cut_depth
324                 c.line(start_up_left_right_x, half_height, x_left_spine_limit, end_point_up_left_y)
325                 c.line(x_left_spine_limit, end_point_up_left_y, x_left_spine_limit, middle_point_up_left_y)
326                 c.line(x_left_spine_limit, middle_point_up_left_y, start_up_left_left_x, half_height)
327
328                 start_down_right_left_x = x_right_spine_limit - 0.5 * cut_width
329                 start_down_right_right_x = x_right_spine_limit + 0.5 * cut_width
330                 middle_point_down_right_y = half_height - middle_point_depth
331                 end_point_down_right_y = half_height - cut_depth
332                 c.line(start_down_right_left_x, half_height, x_right_spine_limit, end_point_down_right_y)
333                 c.line(x_right_spine_limit, end_point_down_right_y, x_right_spine_limit, middle_point_down_right_y)
334                 c.line(x_right_spine_limit, middle_point_down_right_y, start_down_right_right_x, half_height)
335
336             if args.analyze or front_page:
337                 c.save()
338                 new_pdf = pypdf.PdfReader(packet)
339                 new_page.merge_page(new_pdf.pages[0])
340             writer.add_page(new_page)
341             i = 0
342             front_page = not front_page
343
344 # write and close
345 for file in opened_files:
346     file.close()
347 with open(args.output_file, 'wb') as output_file:
348     writer.write(output_file)