Skip to content

Commit

Permalink
Stitch pages
Browse files Browse the repository at this point in the history
* Pages with rotation rotate the content and move the content to
  the final position.
* Pages with a mediabox [x1, y1, x2, y2] where x1, y1 > 0 create a
  temporary page such that the mediabox is [0, 0, width, height]. Crop
  creates such pages. This facilitates the side-by-side placement of
  pages because rotations are around (0,0).
* Without a tmp page, it is not possible to stitch pages where the
  glue side is cropped. Ex: Crop A  l: 0, r: 50%, crop B l: 50%, r:0,
  stitch [A | B].
* Pages with scaling create a temporary page
  • Loading branch information
angsch committed Apr 9, 2021
1 parent a4b408d commit e267e8e
Show file tree
Hide file tree
Showing 3 changed files with 187 additions and 0 deletions.
8 changes: 8 additions & 0 deletions data/menu.ui
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ along with PDF Arranger. If not, see <http://www.gnu.org/licenses/>.
<attribute name="label" translatable="yes">_Split Pages</attribute>
<attribute name="action">win.split</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Stitch Pages</attribute>
<attribute name="action">win.stitch</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Cu_t</attribute>
<attribute name="action">win.cut</attribute>
Expand Down Expand Up @@ -327,6 +331,10 @@ along with PDF Arranger. If not, see <http://www.gnu.org/licenses/>.
<attribute name="label" translatable="yes">_Split Pages</attribute>
<attribute name="action">win.split</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Stitch Pages</attribute>
<attribute name="action">win.stitch</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Insert Blan_k Page</attribute>
<attribute name="action">win.insert-blank-page</attribute>
Expand Down
151 changes: 151 additions & 0 deletions pdfarranger/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,157 @@ def _mediabox(page, crop):
return [x1_new, y1_new, x2_new, y2_new]


def _rshift(lrotate, rrotate, lsize, rsize):
wl, hl = lsize
wr, hr = rsize
shift = {
# lrotate : rrotate : offset of right page
0: { 0 : [wl, 0],
1 : [wl, hr],
2 : [wl + wr, hr],
3 : [wl + wr, 0]},
1: { 0 : [wr, wl],
1 : [0, wl],
2 : [0, hr + wl],
3 : [hl, wl + wr]},
2: { 0 : [0, hr],
1 : [0, -wl + hr],
2 : [-wl, 0],
3 : [-wl + wr, hr]},
3: { 0 : [0, -hl + wr],
1 : [hr, 0],
2 : [hl, -wl],
3 : [0, -wl]}
}
return shift[lrotate][rrotate]

def _lshift(lrotate, lsize):
w, h = lsize
shift = {
0 : [0, 0],
1 : [0, h],
2 : [w, h],
3 : [w, 0]
}
return shift[lrotate]

def _create_tmp_page(tmpdir, page, angle, crop, scale):
rotation_matrices = {0 : [1, 0, 0, 1], # 0 degrees
1 : [0, -1, 1, 0], # 270 degrees
2 : [-1, 0, 0, -1], # 180 degrees
3 : [0, 1, -1, 0]} # 90 degrees

f = pikepdf.Pdf.new()
fd, filename = tempfile.mkstemp(suffix=".pdf", dir=tmpdir)
os.close(fd)
new_page = f.copy_foreign(page)

# Get the geometry of the original page.
new_page.Rotate = 0 # we work on the visual crop
mb = _mediabox(new_page, [0, 0, 0, 0])
w = float(mb[2] - mb[0])
h = float(mb[3] - mb[1])

# Rotate the content.
rotate_times = int(round((angle % 360) / 90) % 4)
if rotate_times == 1 or rotate_times == 3:
w, h = h, w
shift = _lshift(rotate_times, [w, h])
content_dict = pikepdf.Dictionary({})
content_dict['/0'] = pikepdf.Page(new_page).as_form_xobject()
R = rotation_matrices[rotate_times]
content_txt = 'q {} {} {} {} {} {} cm /0 Do Q'.format(R[0], R[1], R[2], R[3], shift[0], shift[1])

# Shrink the mediabox.
new_page = pikepdf.Dictionary(
Type=pikepdf.Name.Page,
MediaBox=[0, 0, scale * w * (1 - crop[0] - crop[1]), scale * h * (1 - crop[2] - crop[3])],
Resources=pikepdf.Dictionary(XObject=content_dict),
Contents=pikepdf.Stream(f, content_txt.encode())
)

# Move the content into the mediabox.
commands = []
for operands, operator in pikepdf.parse_content_stream(new_page):
commands.append([operands, operator])
original = pikepdf.PdfMatrix(commands[1][0])
new_matrix = original.translated(-crop[0] * w - float(mb[0]), -crop[3] * h - float(mb[1])).scaled(scale, scale)
commands[1][0] = pikepdf.Array([*new_matrix.shorthand])
new_content_stream = pikepdf.unparse_content_stream(commands)
new_page.Contents = f.make_stream(new_content_stream)

f.pages.append(new_page)
f.save(filename)
return f.pages[0]

def create_stitched_page(tmpdir, input_files, pages):
"""
Stitch two pages vertically and save the result as a temporary PDF file.
"""
f = pikepdf.Pdf.new()
content_dict = pikepdf.Dictionary({})
content_txt = ''
rotation_matrices = {0 : [1, 0, 0, 1], # 0 degrees
1 : [0, -1, 1, 0], # 270 degrees
2 : [-1, 0, 0, -1], # 180 degrees
3 : [0, 1, -1, 0]} # 90 degrees
pdf_input = [pikepdf.open(p.copyname, password=p.password) for p in input_files]

width = 0
height = None
lsize = [0, 0]
lrotate = None
for count, cur_page in enumerate(pages, start = 1):
current_page = pdf_input[cur_page.nfile - 1].pages[cur_page.npage - 1]
angle = cur_page.angle
angle0 = current_page.Rotate if '/Rotate' in current_page else 0
bottom_left_corner = [current_page.MediaBox[0], current_page.MediaBox[1]]
rotate_times = int(round((angle % 360) / 90) % 4)
rotate_times0 = int(round((angle0 % 360) / 90) % 4)
if cur_page.crop != [0., 0., 0., 0.] or bottom_left_corner != [0., 0.] or cur_page.scale != 1.0:
current_page = _create_tmp_page(tmpdir, current_page, angle + angle0, cur_page.crop, cur_page.scale)
rotate_times = 0
rotate_times0 = 0

x1, y1, x2, y2 = [float(x) for x in current_page.MediaBox]
w = x2 - x1
h = y2 - y1

if (rotate_times + rotate_times0) % 2 == 1:
# Swap width and height
w, h = h, w
if count == 1: # left page
lsize = [w, h]
height = h
lrotate = rotate_times
shift = _lshift(lrotate, lsize)
else: # right page
shift = _rshift(lrotate, rotate_times, lsize, [w, h])
# Multiply rotation matrices.
rotate_times = (rotate_times - lrotate + 4) % 4

R = rotation_matrices[rotate_times]
new_page = f.copy_foreign(current_page)
pagekey = '/Page{0}'.format(count)
content_dict[pagekey] = pikepdf.Page(new_page).as_form_xobject()
width += w
content_txt += 'q {} {} {} {} {} {} cm {} Do Q'.format(R[0], R[1], R[2], R[3], shift[0], shift[1], pagekey)

# Create new page.
newmediabox = [0, 0, width, height]
newpage = pikepdf.Dictionary(
Type=pikepdf.Name.Page,
MediaBox=newmediabox,
Resources=pikepdf.Dictionary(XObject=content_dict),
Contents=pikepdf.Stream(f, content_txt.encode())
)
fd, filename = tempfile.mkstemp(suffix=".pdf", dir=tmpdir)
os.close(fd)
f.pages.append(newpage)
f.save(filename)
return filename


_report_pikepdf_err = True


Expand Down
28 changes: 28 additions & 0 deletions pdfarranger/pdfarranger.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ def __create_actions(self):
('undo', self.undomanager.undo),
('redo', self.undomanager.redo),
('split', self.split_pages),
('stitch', self.stitch_pages),
('metadata', self.edit_metadata),
('cut', self.on_action_cut),
('copy', self.on_action_copy),
Expand Down Expand Up @@ -1507,6 +1508,7 @@ def iv_selection_changed_event(self, _iconview=None, move_cursor_event=False):
("cut", ne),
("copy", ne),
("split", ne),
("stitch", self.stitch_available(selection)),
("select-same-file", ne),
("select-same-format", ne),
("crop-white-borders", ne),
Expand Down Expand Up @@ -1716,6 +1718,19 @@ def split_pages(self, _action, _parameter, _unknown):
model.set_value(iterator, 0, page)
self.iv_selection_changed_event()

def stitch_pages(self, _action, _parameter, _unknown):
"""Stitch the two selected pages"""
selection = self.iconview.get_selected_items()
pages = [row[0] for row in self.model if row.path in selection]
adder = PageAdder(self)
# TODO: Improve undo to delete stitched page and restore original pages
adder.move(Gtk.TreeRowReference.new(self.model, selection[0]), False)
filename = exporter.create_stitched_page(self.tmp_dir, self.pdfqueue, pages)
adder.addpages(filename)
adder.commit(select_added=False, add_to_undomanager=True)
self.clear_selected()
self.scroll_to_selection()

def edit_metadata(self, _action, _parameter, _unknown):
if metadata.edit(self.metadata, self.pdfqueue, self.window):
self.set_unsaved(True)
Expand Down Expand Up @@ -1773,6 +1788,19 @@ def duplicate(self, _action, _parameter, _unknown):
model.insert_after(iterator, [page, page.description()])
self.iv_selection_changed_event()

def stitch_available(self, selection):
"""Determine whether two pages with matching heights are selected."""
if len(selection) != 2:
return False

model = self.iconview.get_model()
selected_pages = []
for path in selection:
it = model.get_iter(path)
selected_pages.append(model.get_value(it, 0))
left_page, right_page = selected_pages
same_height = left_page.height_in_points() == right_page.height_in_points()
return same_height

@staticmethod
def reverse_order_available(selection):
Expand Down

0 comments on commit e267e8e

Please sign in to comment.