summaryrefslogtreecommitdiff
path: root/bin/photocollage
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 07:03:26 -0800
committerskylar <me@casper>2026-11-01 07:03:26 -0800
commit070a107873440d62b4519e6d91c2ba739d4b6fa2 (patch)
treeae4e1c3540cdd01b3289ed6f96ec3ea69507f73e /bin/photocollage
Literate Doom Emacs configuration
config.org tangles to config.el, which loads private/config.el last when it exists. bin/ holds the scripts some sections drive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bin/photocollage')
-rw-r--r--bin/photocollage/__init__.py0
-rw-r--r--bin/photocollage/collage.py568
-rw-r--r--bin/photocollage/render.py313
3 files changed, 881 insertions, 0 deletions
diff --git a/bin/photocollage/__init__.py b/bin/photocollage/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bin/photocollage/__init__.py
diff --git a/bin/photocollage/collage.py b/bin/photocollage/collage.py
new file mode 100644
index 0000000..134735b
--- /dev/null
+++ b/bin/photocollage/collage.py
@@ -0,0 +1,568 @@
+# Copyright (C) 2014 Adrien Vergé
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import random
+
+"""
+Summary of collage objects:
+
+----------------------
+| |
+| |
+| Page | The "Page" object represents the whole page that
+| | will give the final assembled image.
+| |
+----------------------
+
+----------------------
+| | | |
+| |Column| |
+| | |Column| A page is divided into columns.
+|Column| | |
+| | | |
+----------------------
+
+----------------------
+| Cell | Cell | Each column contains cells. When a
+|------| x--|----- CellExtent is located in several columns,
+| Cell |-------------| its "extended" flag is set, and a
+|------| Cell | | CellExtent object is added to the
+| Cell | | Cell | column on the right to reserve the
+---------------------- place.
+
+ ,------> Photo
+ / ,---------> Photo
+----------------------
+| | | Each cell is associated
+|------| | to a photo.
+| |-------------|
+|------| | |----> Photo
+| | | |
+----------------------
+ `----------> Photo
+
+The layout placing process is divided in three phases.
+
+Phase A: Fill columns with photos.
+ Photos are added to columns, one by one, until there are no more photos.
+ Each new photo is put in the smallest column, so as to have balanced
+ columns. If two columns have approximately the same height, a photo can be
+ "extended" to fit two columns. In this case, the "Cell" object is put in
+ the first column, and the second column takes a "CellExtent" object to
+ reserve the taken space.
+
+Phase B: Set all columns to same height.
+ A global common height is computed for all columns, and every of them is
+ stressed or extended to this common length. This can result in a decrease
+ or increase in columns' width.
+
+Phase C: Adapt columns' width.
+ Since cells in a column may have different widths, each column width is set
+ to the smallest width amongst its images.
+
+"""
+
+
+class Photo:
+ def __init__(self, filename, w, h, orientation=0):
+ self.filename = filename
+ self.w = w
+ self.h = h
+ self.orientation = orientation
+ self.offset_w = 0.5
+ self.offset_h = 0.5
+
+ @property
+ def ratio(self):
+ return float(self.h) / float(self.w)
+
+ def move(self, x, y):
+ self.offset_w = self.calculate_new_offset(self.offset_w, x)
+ self.offset_h = self.calculate_new_offset(self.offset_h, y)
+
+ @staticmethod
+ def calculate_new_offset(offset, value):
+ new_offset = offset + value
+ if new_offset < 0:
+ new_offset = 0
+ elif new_offset > 1:
+ new_offset = 1
+ return new_offset
+
+
+class Cell:
+ """Represents a cell in a column
+
+ Properties:
+ <- x -><- w ->
+ ---------------------- ^
+ | | | |y
+ |------| | |
+ | |-------------| v
+ |------| Cell | | ^
+ | | | | |h
+ ---------------------- v
+
+ """
+ def __init__(self, parents, photo):
+ self.parents = parents
+ self.photo = photo
+ self.extent = None
+ self.h = self.w * self.wanted_ratio
+
+ def __repr__(self):
+ """Representation of the cell in ASCII art"""
+ end = "]"
+ if self.extent is not None:
+ end = "--"
+ return "[%d %d%s" % (self.w, self.h, end)
+
+ @property
+ def x(self):
+ return self.parents[0].x
+
+ @property
+ def y(self):
+ """Returns the cell's y coordinate
+
+ It assumes that the cell is in a single column, so it is the previous
+ cell's y + h.
+
+ """
+ prev = None
+ for c in self.parents[0].cells:
+ if self is c:
+ if prev:
+ return prev.y + prev.h
+ return 0
+ prev = c
+
+ @property
+ def w(self):
+ return sum(c.w for c in self.parents)
+
+ @property
+ def ratio(self):
+ return self.h / self.w
+
+ @property
+ def wanted_ratio(self):
+ return self.photo.ratio
+
+ def scale(self, alpha):
+ self.h *= alpha
+
+ def is_extended(self):
+ return hasattr(self, 'extent') and self.extent is not None
+
+ def is_extension(self):
+ return isinstance(self, CellExtent)
+
+ def content_coords(self):
+ """Returns the coordinates of the contained image
+
+ These are computed in order not to loose space, so the content area
+ will always be greater than the cell itself. It is the space taken by
+ the contained image if it wasn't cropped.
+
+ """
+ # If the contained image is too thick to fit
+ if self.wanted_ratio < self.ratio:
+ h = self.h
+ w = self.h / self.wanted_ratio
+ y = self.y
+ x = self.x - (w - self.w) / 2.0
+ # If the contained image is too tall to fit
+ elif self.wanted_ratio > self.ratio:
+ w = self.w
+ h = self.w * self.wanted_ratio
+ x = self.x
+ y = self.y - (h - self.h) / 2.0
+ else:
+ w = self.w
+ h = self.h
+ x = self.x
+ y = self.y
+ return x, y, w, h
+
+ def top_neighbor(self):
+ """Returns the cell above this one"""
+ prev = None
+ for c in self.parents[0].cells:
+ if self is c:
+ return prev
+ prev = c
+
+ def bottom_neighbor(self):
+ """Returns the cell below this one"""
+ prev = None
+ for c in reversed(self.parents[0].cells):
+ if self is c:
+ return prev
+ prev = c
+
+
+class CellExtent(Cell):
+ def __init__(self, cell):
+ self.origin = cell
+ self.origin.extent = self
+
+ def __repr__(self):
+ """Representation of the cell in ASCII art"""
+ return "------]"
+
+ @property
+ def parents(self):
+ return (self.origin.parents[1],)
+
+ @property
+ def photo(self):
+ return self.origin.photo
+
+ @property
+ def y(self):
+ return self.origin.y
+
+ @property
+ def h(self):
+ return self.origin.h
+
+ def scale(self, alpha):
+ pass
+
+
+class Column:
+ """Represents a column in a page
+
+ Properties:
+ <----- x ----><-- w ->
+ ---------------------- ^
+ | | | | |
+ | | | |
+ | | |Column| h
+ | | | |
+ -------| | | |
+ | |------- v
+ --------
+
+ """
+ def __init__(self, parent, w):
+ self.parent = parent
+ self.cells = []
+ self.w = w
+
+ def __repr__(self):
+ """Representation of the column in ASCII art"""
+ return "\n".join(c.__repr__() for c in self.cells)
+
+ @property
+ def h(self):
+ """Returns the column's total height
+
+ This is not simply the sum of its cells heights, because there can be
+ empty spaces between cells.
+
+ """
+ if not self.cells:
+ return 0
+ return self.cells[-1].y + self.cells[-1].h
+
+ @property
+ def x(self):
+ x = 0
+ for c in self.parent.cols:
+ if self is c:
+ break
+ x += c.w
+ return x
+
+ def scale(self, alpha):
+ self.w *= alpha
+ for c in self.cells:
+ c.scale(alpha)
+
+ def left_neighbor(self):
+ """Returns the column on the left of this one"""
+ prev = None
+ for c in self.parent.cols:
+ if self is c:
+ return prev
+ prev = c
+
+ def right_neighbor(self):
+ """Returns the column on the right of this one"""
+ prev = None
+ for c in reversed(self.parent.cols):
+ if self is c:
+ return prev
+ prev = c
+
+ def adjust_height(self, target_h):
+ """Set the column's height to a given value by resizing cells"""
+ # First, make groups of "movable" cells. Since cell extents are not
+ # movable, these groups only contain pure cell objects. We only resize
+ # those groups.
+ class Group:
+ def __init__(self, y):
+ self.y = y
+ self.h = 0
+ self.cells = []
+
+ groups = []
+ groups.append(Group(0))
+ for c in self.cells:
+ # While a cell extent is not reached, keep add cells to the group
+ if not c.is_extension():
+ groups[-1].cells.append(c)
+ else:
+ # Close current group and create a new one
+ groups[-1].h = c.y - groups[-1].y
+ groups.append(Group(c.y + c.h))
+ groups[-1].h = target_h - groups[-1].y
+
+ # Adjust height for each group independently
+ for group in groups:
+ if not group.cells:
+ continue
+ alpha = group.h / sum(c.h for c in group.cells)
+ for c in group.cells:
+ c.h = c.h * alpha
+
+
+class Page:
+ """Represents a whole page
+
+ Properties:
+ <-------- w -------->
+ ---------------------- ^
+ | | |
+ | |
+ | Page | h
+ | |
+ | | |
+ ---------------------- v
+
+ """
+ def __init__(self, w, target_ratio, no_cols):
+ self.target_ratio = target_ratio
+ col_w = float(w)/no_cols
+ self.cols = []
+ for i in range(no_cols):
+ self.cols.append(Column(self, col_w))
+
+ def __repr__(self):
+ """Representation of the page in ASCII art
+
+ Returns something like:
+ [62 52] [125 134-- ------] [62 87]
+ [62 47] [62 66] [125 132-- [62 45]
+ [62 46] ------] [62 49] ------]
+ [62 78] ------] [62 49] [62 45]
+ [125 102-- ------] [62 49] [62 65]
+ [125 135-- [62 85] [62 53]
+ [125 91-- [125 89-- [62 64]
+ ------]
+ """
+ lines = []
+ n = 0
+ end = False
+ while not end:
+ lines.append("")
+ end = True
+ for col in self.cols:
+ cells = col.__repr__().split("\n")
+ w = max(len(cell) for cell in cells)
+ if col != self.cols[-1]:
+ w += 1
+ cell = w * " "
+ if n < len(cells):
+ cell = cells[n] + (w - len(cells[n])) * " "
+ if n < len(cells) - 1:
+ end = False
+ lines[-1] += cell
+ n += 1
+ return "\n".join(lines)
+
+ @property
+ def no_cols(self):
+ return len(self.cols)
+
+ @property
+ def w(self):
+ return sum(c.w for c in self.cols)
+
+ @property
+ def h(self):
+ return max(c.h for c in self.cols)
+
+ @property
+ def ratio(self):
+ return self.h / self.w
+
+ def scale(self, alpha):
+ for c in self.cols:
+ c.scale(alpha)
+
+ def scale_to_fit(self, max_w, max_h=None):
+ if max_h is None or self.w * max_h > self.h * max_w:
+ self.scale(max_w / self.w)
+ else:
+ self.scale(max_h / self.h)
+
+ def next_free_col(self):
+ """Returns the column with lowest height"""
+ minimum = min(c.h for c in self.cols)
+ candidates = []
+ for c in self.cols:
+ if c.h == minimum:
+ candidates.append(c)
+ return random.choice(candidates)
+
+ def add_cell_single_col(self, col, photo):
+ col.cells.append(Cell((col,), photo))
+
+ def add_cell_multi_col(self, col1, col2, photo):
+ cell = Cell((col1, col2), photo)
+ extent = CellExtent(cell)
+ col1.cells.append(cell)
+ col2.cells.append(extent)
+
+ def add_cell(self, photo):
+ """Add a new cell in the best computed place
+
+ If possible, and if it's worth, make a "multiple-column" cell.
+
+ """
+ col = self.next_free_col()
+ left = col.left_neighbor()
+ right = col.right_neighbor()
+ if 2 * random.random() > photo.ratio:
+ if left and abs(col.h - left.h) < 0.5 * col.w:
+ return self.add_cell_multi_col(left, col, photo)
+ elif right and abs(col.h - right.h) < 0.5 * col.w:
+ return self.add_cell_multi_col(col, right, photo)
+
+ self.add_cell_single_col(col, photo)
+
+ def remove_empty_cols(self):
+ i = 0
+ while i < len(self.cols):
+ if len(self.cols[i].cells) == 0:
+ self.cols.pop(i)
+ else:
+ i += 1
+
+ def remove_bottom_holes(self):
+ """Remove holes created by extended cells
+
+ Example (case A):
+ The bottom-right cell should be extended to fill the hole.
+ ---------------------- ----------------------
+ | | | | | | | |
+ | |-------------| | |-------------|
+ |------| | |------| |
+ | |-------------- | |--------------
+ | | | ^ | | ^ | |
+ --------------- hole -------- hole --------
+
+ Example (case B):
+ The bottom cell should be moved under the other extended cell.
+ ---------------------- ----------------------
+ | | | | | | | |
+ |------|-------------| |-------------|------|
+ | | | | | |
+ |--------------------- ---------------------|
+ | | <-- hole hole -> | |
+ --------------- ---------------
+
+ """
+ for col in self.cols:
+ cell = col.cells[-1]
+ if cell == col.cells[0]:
+ continue
+
+ # Case A
+ # If cell is not extended, is below an extended cell and has no
+ # neighbour under the latter, it should be extended.
+ if not cell.is_extended() and not cell.is_extension():
+ # Case A1
+ if cell.top_neighbor().is_extended() \
+ and cell.top_neighbor().extent \
+ .bottom_neighbor() is None:
+ # Extend cell to right
+ extent = CellExtent(cell)
+ col.right_neighbor().cells.append(extent)
+ cell.parents = (col, col.right_neighbor())
+ # Case A2
+ elif cell.top_neighbor().is_extension() \
+ and cell.top_neighbor().origin \
+ .bottom_neighbor() is None:
+ # Extend cell to left
+ col.cells.remove(cell)
+ col.left_neighbor().cells.append(cell)
+ extent = CellExtent(cell)
+ col.cells.append(extent)
+ cell.parents = (col.left_neighbor(), col)
+ # Case B
+ # If cell is extended and one of the cells above is extended too,
+ # the bottom cell should be placed right below the top one.
+ elif cell.is_extended() and cell.extent.bottom_neighbor() is None:
+ # Case B1
+ if cell.extent.top_neighbor().is_extended() \
+ and cell.extent.top_neighbor().extent \
+ .bottom_neighbor() is None:
+ # Move cell to right
+ col.cells.remove(cell)
+ col.right_neighbor().cells.remove(cell.extent)
+ col.right_neighbor().cells.append(cell)
+ col.right_neighbor().right_neighbor().cells \
+ .append(cell.extent)
+ cell.parents = (col.right_neighbor(),
+ col.right_neighbor().right_neighbor())
+ # Case B2
+ elif cell.top_neighbor().is_extension() \
+ and cell.top_neighbor().origin \
+ .bottom_neighbor() is None:
+ # Move cell to left
+ col.cells.remove(cell)
+ col.right_neighbor().cells.remove(cell.extent)
+ col.left_neighbor().cells.append(cell)
+ col.cells.append(cell.extent)
+ cell.parents = (col.left_neighbor(), col)
+
+ def adjust_cols_heights(self):
+ """Set all columns' heights to same value by shrinking them"""
+ target_h = self.w * self.target_ratio
+ for c in self.cols:
+ c.adjust_height(target_h)
+
+ def adjust(self):
+ self.remove_empty_cols()
+ self.remove_bottom_holes()
+ self.adjust_cols_heights()
+
+ def get_cell_at_position(self, x, y):
+ for col in self.cols:
+ if x >= col.x and x < col.x + col.w:
+ for cell in col.cells:
+ if y >= cell.y and y < cell.y + cell.h:
+ if cell.is_extension():
+ return cell.origin
+ return cell
+ return None
+
+ def swap_photos(self, cell1, cell2):
+ cell1.photo, cell2.photo = cell2.photo, cell1.photo
diff --git a/bin/photocollage/render.py b/bin/photocollage/render.py
new file mode 100644
index 0000000..f9b9024
--- /dev/null
+++ b/bin/photocollage/render.py
@@ -0,0 +1,313 @@
+# Copyright (C) 2014 Adrien Vergé
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import random
+from threading import Thread
+import time
+
+import PIL.Image
+import PIL.ImageDraw
+import PIL.ImageFile
+
+from photocollage.collage import Photo
+
+
+QUALITY_SKEL = 0
+QUALITY_FAST = 1
+QUALITY_BEST = 2
+
+
+# Try to continue even if the input file is corrupted.
+# See issue at https://github.com/adrienverge/PhotoCollage/issues/65
+PIL.ImageFile.LOAD_TRUNCATED_IMAGES = True
+
+
+class PIL_SUPPORTED_EXTS:
+ """File extensions supported by PIL
+
+ Compiled from:
+ - http://pillow.readthedocs.org/en/2.3.0/handbook/image-file-formats.html
+ - https://github.com/python-imaging/Pillow/blob/master/PIL/*ImagePlugin.py
+
+ """
+ RW = {
+ "BMP": ("bmp",),
+ # "EPS": ("ps", "eps",), # doesn't seem to work
+ "GIF": ("gif",),
+ "IM": ("im",),
+ "JPEG": ("jfif", "jpe", "jpg", "jpeg",),
+ "MSP": ("msp",),
+ "PCX": ("pcx",),
+ "PNG": ("png",),
+ "PPM": ("pbm", "pgm", "ppm",),
+ "TGA": ("tga",),
+ "TIFF": ("tif", "tiff",),
+ "WebP": ("webp",),
+ "XBM": ("xbm",),
+ }
+ RO = {
+ "CUR": ("cur",),
+ "DCX": ("dcx",),
+ "FLI": ("fli", "flc",),
+ "FPX": ("fpx",),
+ "GBR": ("gbr",),
+ "ICO": ("ico",),
+ "IPTC/NAA": ("iim",),
+ "PCD": ("pcd",),
+ "PSD": ("psd",),
+ "SGI": ("bw", "rgb", "rgba", "sgi",),
+ "XPM": ("xpm",),
+ }
+ WO = {
+ # "PALM": ("palm",), # doesn't seem to work
+ # "PDF": ("pdf",), # doesn't seem to work
+ }
+
+
+def random_color():
+ r = random.randrange(256)
+ g = random.randrange(256)
+ b = random.randrange(256)
+ if r + g + b > 0.7 * 3 * 256:
+ r -= 50
+ g -= 50
+ b -= 50
+ return (r, g, b)
+
+
+class BadPhoto(Exception):
+ def __init__(self, photoname):
+ self.photoname = photoname
+
+
+def build_photolist(filelist):
+ ret = []
+
+ for name in filelist:
+ try:
+ img = PIL.Image.open(name)
+ except OSError:
+ raise BadPhoto(name)
+ w, h = img.size
+
+ orientation = 0
+ try:
+ exif = img._getexif()
+ if 274 in exif: # orientation tag
+ orientation = exif[274]
+ if orientation == 6 or orientation == 8:
+ w, h = h, w
+ except Exception:
+ pass
+
+ ret.append(Photo(name, w, h, orientation))
+ return ret
+
+
+cache = {}
+
+
+class RenderingTask(Thread):
+ """Execution thread to do the actual poster rendering
+
+ Image computation is a heavy task, that can take several seconds. During
+ this, the program might be unresponding. To avoid this, rendering is done
+ is a separated thread.
+
+ """
+ def __init__(self, page, border_width=0.01, border_color=(0, 0, 0),
+ quality=QUALITY_FAST, output_file=None,
+ on_update=None, on_complete=None, on_fail=None):
+ super().__init__()
+
+ self.page = page
+ self.border_width = border_width
+ self.border_color = border_color
+ self.quality = quality
+
+ self.output_file = output_file
+
+ self.on_update = on_update
+ self.on_complete = on_complete
+ self.on_fail = on_fail
+
+ self.canceled = False
+
+ def abort(self):
+ self.canceled = True
+
+ def draw_skeleton(self, canvas):
+ for col in self.page.cols:
+ for c in col.cells:
+ if c.is_extension():
+ continue
+ color = random_color()
+ x, y, w, h = c.content_coords()
+ xy = (x, y)
+ xY = (x, y + h - 1)
+ Xy = (x + w - 1, y)
+ XY = (x + w - 1, y + h - 1)
+
+ draw = PIL.ImageDraw.Draw(canvas)
+ draw.line(xy + Xy, fill=color)
+ draw.line(xy + xY, fill=color)
+ draw.line(xY + XY, fill=color)
+ draw.line(Xy + XY, fill=color)
+ draw.line(xy + XY, fill=color)
+ draw.line(xY + Xy, fill=color)
+ return canvas
+
+ def draw_borders(self, canvas):
+ if self.border_width == 0:
+ return
+
+ W = self.page.w - 1
+ H = self.page.h - 1
+ border = self.border_width - 1
+ color = self.border_color
+
+ draw = PIL.ImageDraw.Draw(canvas)
+ draw.rectangle((0, 0) + (border, H), color)
+ draw.rectangle((W - border, 0) + (W, H), color)
+ draw.rectangle((0, 0) + (W, border), color)
+ draw.rectangle((0, H - border) + (W, H), color)
+
+ for col in self.page.cols:
+ # Draw horizontal borders
+ for c in col.cells[1:]:
+ xy = (col.x, c.y - border / 2)
+ XY = (col.x + col.w, c.y + border / 2)
+ draw.rectangle(xy + XY, color)
+ # Draw vertical borders
+ if col.x > 0:
+ for c in col.cells:
+ if not c.is_extension():
+ xy = (col.x - border / 2, c.y)
+ XY = (col.x + border / 2, c.y + c.h)
+ draw.rectangle(xy + XY, color)
+ return canvas
+
+ def resize_photo(self, cell, use_cache=False):
+ # If a thumbnail is already in cache, let's use it. But only if it is
+ # bigger than what we need, because we don't want to lose quality.
+ if (use_cache and cell.photo.filename in cache and
+ cache[cell.photo.filename].size[0] >= int(round(cell.w)) and
+ cache[cell.photo.filename].size[1] >= int(round(cell.h))):
+ img = cache[cell.photo.filename].copy()
+ else:
+ img = PIL.Image.open(cell.photo.filename)
+
+ # Rotate image is EXIF says so
+ if cell.photo.orientation == 3:
+ img = img.rotate(180, expand=True)
+ elif cell.photo.orientation == 6:
+ img = img.rotate(270, expand=True)
+ elif cell.photo.orientation == 8:
+ img = img.rotate(90, expand=True)
+
+ if self.quality == QUALITY_FAST:
+ method = PIL.Image.NEAREST
+ else:
+ method = PIL.Image.LANCZOS # renamed from ANTIALIAS in Pillow 10
+
+ shape = img.size[0] * cell.h - img.size[1] * cell.w
+ if shape > 0: # image is too thick
+ img = img.resize((int(round(cell.h * img.size[0] / img.size[1])),
+ int(round(cell.h))), method)
+ elif shape < 0: # image is too tall
+ img = img.resize((int(round(cell.w)),
+ int(round(cell.w * img.size[1] / img.size[0]))),
+ method)
+ else:
+ img = img.resize((int(round(cell.w)), int(round(cell.h))), method)
+
+ # Save this new image to cache (if it is larger than the previous one)
+ if (use_cache and (cell.photo.filename not in cache or
+ cache[cell.photo.filename].size[0] < img.size[0])):
+ cache[cell.photo.filename] = img
+
+ if shape > 0: # image is too thick
+ width_to_crop = img.size[0] - cell.w
+ img = img.crop((
+ int(round(width_to_crop * cell.photo.offset_w)),
+ 0,
+ int(round(img.size[0] - width_to_crop *
+ (1 - cell.photo.offset_w))),
+ int(round(cell.h))
+ ))
+ elif shape < 0: # image is too tall
+ height_to_crop = img.size[1] - cell.h
+ img = img.crop((
+ 0,
+ int(round(height_to_crop * cell.photo.offset_h)),
+ int(round(cell.w)),
+ int(round(img.size[1] - height_to_crop *
+ (1 - cell.photo.offset_h)))
+ ))
+
+ return img
+
+ def paste_photo(self, canvas, cell, img):
+ canvas.paste(img, (int(round(cell.x)), int(round(cell.y))))
+ return canvas
+
+ def run(self):
+ try:
+ canvas = PIL.Image.new(
+ "RGB", (int(self.page.w), int(self.page.h)), "white")
+
+ self.draw_skeleton(canvas)
+ self.draw_borders(canvas)
+
+ if self.quality != QUALITY_SKEL:
+ n = sum([len([cell for cell in col.cells if not
+ cell.is_extension()]) for col in self.page.cols])
+ i = 0.0
+ if self.on_update:
+ self.on_update(canvas, 0.0)
+ last_update = time.time()
+
+ for col in self.page.cols:
+ for c in col.cells:
+ if self.canceled: # someone clicked "abort"
+ return
+
+ if c.is_extension():
+ continue
+
+ img = self.resize_photo(c, use_cache=True)
+ self.paste_photo(canvas, c, img)
+
+ # Only needed for interactive rendering
+ if self.on_update:
+ self.draw_borders(canvas)
+
+ i += 1
+ now = time.time()
+ if self.on_update and now > last_update + 0.1:
+ self.on_update(canvas, i / n)
+ last_update = now
+
+ self.draw_borders(canvas)
+
+ if self.output_file:
+ canvas.save(self.output_file)
+
+ if self.on_complete:
+ self.on_complete(canvas)
+ except Exception as e:
+ if self.on_fail:
+ self.on_fail(e)