-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrasterizer.py
More file actions
198 lines (165 loc) · 6.7 KB
/
Copy pathrasterizer.py
File metadata and controls
198 lines (165 loc) · 6.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""Threaded interruptible PDF rasterizer."""
from dataclasses import dataclass
import os
import queue
import threading
import time
from typing import Any, Dict
import pdf2image
CHUNK_PAGES = 32
MAX_THREADS = os.cpu_count() - 1
LRU_SIZE = 4
_EXIT_SENTINEL = -1
_IDLE_SENTINEL = -2
# There are pip packages for this, but we try to minimize dependencies.
#
# Implementation note: Theoretically, enough operations self.counter will
# become bignum. But in practice, nobody will resize their window billions of
# times, so we are OK to use this logic instead of the more complex logic
# needed to ensure that age counters are no larger than the cache size. Also,
# we could use a priority queue for O(log N) evicition, but our cache size is
# not big enough for that to matter.
class LRUDict:
@dataclass
class _LRUEntry:
used: int
item: Any
def __init__(self, size):
self.dict: Dict[Any, LRUDict._LRUEntry] = {}
self.size: int = size
self.counter: int = 0
def __contains__(self, key):
return key in self.dict
def __getitem__(self, key):
entry = self.dict[key]
entry.used = self.counter
self.counter += 1
return entry.item
def __setitem__(self, key, value):
if (len(self.dict) == self.size) and (key not in self.dict):
key_oldest, _ = min(self.dict.items(), key=lambda item: item[1].used)
del self.dict[key_oldest]
self.dict[key] = LRUDict._LRUEntry(self.counter, value)
self.counter += 1
def _winsize2rasterargs(window_size, aspect):
width, height = window_size
window_aspect = float(width) / height
if window_aspect >= aspect:
width = None
else:
height = None
return (width, height)
def _parse_aspect_from_pdfinfo(info):
size_str = info["Page size"]
width, _, height, _ = size_str.split(" ")
return float(width) / float(height)
def _rasterize_worker(pdfpath, aspect, pagelimit, size_queue, image_queue):
"""Threaded interruptible PDF rasterizer.
Listens on size_queue for (width, height) tuples representing window resize
events. When an event arrives, discards any in-progress rasterization and
starts over. Calls the callback on its own thread when the images for the
entire PDF are complete and the size has not changed during rasterization.
Args:
pdfpath (str): Path of PDF file.
aspect (float): Aspect ratio (width/height) of PDF file.
pagelimit (int): Read this many pages from the file. (Mostly for
development purposes to keep load time down.)
size_queue (queue-like): Queue to monitor for size changes.
image_queue (queue-like): Queue to return completed renders.
"""
images = [None] * pagelimit
# Loop invariant: this is the (one-based) index of the page we should
# rasterize next. If it exceeds page_limit, we have no work to do.
page = 1
# Block indefinitely for first size.
image_size = size_queue.get()
while True:
# Get freshest item in size_queue. This loop would not be necessary if
# it was possible for a Queue with a maxsize to discard old items
# instead of blocking when it's full and put() is called.
try:
while True:
image_size = size_queue.get(timeout=0.1)
if image_size == _EXIT_SENTINEL:
# Stop the thread and exit cleanly.
return
elif image_size == _IDLE_SENTINEL:
# If working, stop.
page = pagelimit + 1
images = [None] * pagelimit
else:
page = 1
except queue.Empty:
pass
if page == pagelimit + 1:
# Got through them all without changing size - push exactly once.
if images[-1] is not None:
image_queue.put((image_size, images))
images = [None] * pagelimit
else:
# One might hope that pdf2image.convert_from_bytes is faster by
# staying in-memory, but it just writes the bytes to a temp file.
chunk = pdf2image.convert_from_path(
pdfpath,
thread_count=MAX_THREADS,
size=image_size,
first_page=page,
last_page=page+CHUNK_PAGES-1,
)
for img in chunk:
images[page - 1] = img
page += 1
class ThreadedRasterizer:
"""Shared state for communicating with _rasterize_worker thread.
Also implements the behavior of showing a black slide for an out-of-bounds
index instead of crashing. In a larger program this should probably be a
separate layer between the rasterizer and the platform-specific GUI. For
now it goes here to keep the Pyglet-specific layer as thin as possible.
"""
def __init__(self, path, pagelimit=None):
self.images = None
self.black = None
self.render_start_time = None
self.cache = LRUDict(4)
info = pdf2image.pdfinfo_from_path(path)
self.aspect = _parse_aspect_from_pdfinfo(info)
self.size_queue = queue.Queue()
self.image_queue = queue.Queue()
self.thread = threading.Thread(
target=_rasterize_worker,
args=(path, self.aspect, pagelimit, self.size_queue, self.image_queue),
)
self.thread.start()
def push_resize(self, w, h):
if (w, h) not in self.cache:
self.size_queue.put((w, h))
self.render_start_time = time.time()
else:
# _IDLE_SENTINEL avoids the following bug:
# 1) resize to non-cached size 1, start a render
# 2) resize to cached size 2 before render is done
# 3) render of now-invalid size 1 finishes, is pushed to image_queue
self.size_queue.put(_IDLE_SENTINEL)
self._set_images(self.cache[(w, h)])
print(f"retrieved ({w:.1f}, {h:.1f}) render from cache.")
def get(self, index):
try:
(w, h), images = self.image_queue.get(block=False)
self.cache[(w, h)] = images
self._set_images(images)
duration = time.time() - self.render_start_time
print(f"rendered ({w:.1f}, {h:.1f}) in {duration:.2f} sec.")
except queue.Empty:
pass
if self.images is None:
return None
if 0 <= index < len(self.images):
return self.images[index]
return self.black
def shutdown(self):
self.size_queue.put(_EXIT_SENTINEL)
self.thread.join()
def _set_images(self, images):
self.images = images
lut = [0] * (256 * 3)
self.black = images[0].point(lut)