diff --git a/common/src/module/canvas.mjs b/common/src/module/canvas.mjs index 62649cf..6abbf2d 100644 --- a/common/src/module/canvas.mjs +++ b/common/src/module/canvas.mjs @@ -529,11 +529,15 @@ class CanvasAPI extends Module { this.#canvas.loadImageAsync(imgData.source).then(() => { if(this.#redrawCount === currentRedrawCount) callback(imgData) + else + console.log("2Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) }) } else { // Callback directly if(this.#redrawCount === currentRedrawCount) callback(imgData) + else + console.log("2Discard render of", imgData.source, this.#redrawCount, currentRedrawCount) } } const prerendered = Latex.findPrerendered(ltxText, this.textsize, color) diff --git a/runtime-pyside6/.coverage b/runtime-pyside6/.coverage deleted file mode 100644 index 8eab2ed..0000000 Binary files a/runtime-pyside6/.coverage and /dev/null differ diff --git a/runtime-pyside6/LogarithmPlotter/util/latex.py b/runtime-pyside6/LogarithmPlotter/util/latex.py index 0d720c3..2db62eb 100644 --- a/runtime-pyside6/LogarithmPlotter/util/latex.py +++ b/runtime-pyside6/LogarithmPlotter/util/latex.py @@ -82,6 +82,7 @@ class Latex(QObject): def __init__(self, cache_path): QObject.__init__(self) self.tempdir = path.join(cache_path, "latex") + self.render_pipeline_locks = {} makedirs(self.tempdir, exist_ok=True) @Property(bool) @@ -116,20 +117,71 @@ class Latex(QObject): except MissingPackageException: valid_install = False # Should have sent an error message if failed to render return valid_install + + def lock(self, markup_hash, render_hash, promise): + """ + Locks the render pipeline for a given markup hash and render hash. + """ + # print("Locking", markup_hash, render_hash) + if markup_hash not in self.render_pipeline_locks: + self.render_pipeline_locks[markup_hash] = promise + self.render_pipeline_locks[render_hash] = promise + + + def release_lock(self, markup_hash, render_hash): + """ + Release locks on the markup and render hashes. + """ + # print("Releasing", markup_hash, render_hash) + if markup_hash in self.render_pipeline_locks: + del self.render_pipeline_locks[markup_hash] + del self.render_pipeline_locks[render_hash] @Slot(str, float, QColor, result=PyPromise) def renderAsync(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise: """ Prepares and renders a latex string into a png file asynchronously. """ - return PyPromise(self.renderSync, [latex_markup, font_size, color]) + markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color) + promise = None + if render_hash in self.render_pipeline_locks: + # A PyPromise for this specific render is already running. + # print("Already running render of", latex_markup) + promise = self.render_pipeline_locks[render_hash] + elif markup_hash in self.render_pipeline_locks: + # A PyPromise with the same markup, but not the same color or font size is already running. + print("Chaining render of", latex_markup) + existing_promise = self.render_pipeline_locks[markup_hash] + promise = self._create_async_promise(latex_markup, font_size, color) + existing_promise.then(lambda x, latex_markup=latex_markup: print("> Starting chained render of", latex_markup)) + promise.then(lambda x, latex_markup=latex_markup: print("> Fulfilled chained render of", latex_markup, "\n with", x.toVariant())) + existing_promise.then(promise.start) + else: + # No such PyPromise is running. + promise = self._create_async_promise(latex_markup, font_size, color) + promise.start() + return promise + + def _create_async_promise(self, latex_markup: str, font_size: float, color: QColor) -> PyPromise: + """ + Createsa PyPromise to render a latex string into a PNG file. + Internal method. Use renderAsync that makes use of locks. + """ + markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color) + promise = PyPromise(self.renderSync, [latex_markup, font_size, color], start_automatically=False) + self.lock(markup_hash, render_hash, promise) + # Make the lock release at the end. + def unlock(data, markup_hash=markup_hash, render_hash=render_hash): + self.release_lock(markup_hash, render_hash) + promise.then(unlock, unlock) + return promise @Slot(str, float, QColor, result=str) def renderSync(self, latex_markup: str, font_size: float, color: QColor) -> str: """ Prepares and renders a latex string into a png file. """ - markup_hash, export_path = self.create_export_path(latex_markup, font_size, color) + markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color) if self.latexSupported and not path.exists(export_path + ".png"): print("Rendering", latex_markup, export_path) # Generating file @@ -138,14 +190,12 @@ class Latex(QObject): if not path.exists(latex_path + ".dvi"): self.create_latex_doc(latex_path, latex_markup) self.convert_latex_to_dvi(latex_path) - # self.cleanup(latex_path) + self.cleanup(latex_path) # Creating four pictures of different sizes to better handle dpi. self.convert_dvi_to_png(latex_path, export_path, font_size, color) # self.convert_dvi_to_png(latex_path, export_path+"@2", font_size*2, color) # self.convert_dvi_to_png(latex_path, export_path+"@3", font_size*3, color) # self.convert_dvi_to_png(latex_path, export_path+"@4", font_size*4, color) - else: - sleep(0) img = QImage(export_path) # Small hack, not very optimized since we load the image twice, but you can't pass a QImage to QML and expect it to be loaded return f'{export_path}.png,{img.width()},{img.height()}' @@ -155,7 +205,7 @@ class Latex(QObject): """ Finds a prerendered image and returns its data if possible, and an empty string if not. """ - markup_hash, export_path = self.create_export_path(latex_markup, font_size, color) + markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color) data = "" if path.exists(export_path + ".png"): img = QImage(export_path) @@ -165,10 +215,13 @@ class Latex(QObject): def create_export_path(self, latex_markup: str, font_size: float, color: QColor): """ Standardizes export path for renders. + Markup hash is unique for the markup + Render hash is unique for the markup, the font size and the color. """ markup_hash = "render" + str(sha512(latex_markup.encode()).hexdigest()) - export_path = path.join(self.tempdir, f'{markup_hash}_{int(font_size)}_{color.rgb()}') - return markup_hash, export_path + render_hash = f'{markup_hash}_{int(font_size)}_{color.rgb()}' + export_path = path.join(self.tempdir, render_hash) + return markup_hash, render_hash, export_path def create_latex_doc(self, export_path: str, latex_markup: str): """ diff --git a/runtime-pyside6/LogarithmPlotter/util/promise.py b/runtime-pyside6/LogarithmPlotter/util/promise.py index 41b916d..e475b36 100644 --- a/runtime-pyside6/LogarithmPlotter/util/promise.py +++ b/runtime-pyside6/LogarithmPlotter/util/promise.py @@ -23,6 +23,18 @@ from PySide6.QtQml import QJSValue from LogarithmPlotter.util.js import PyJSValue +def check_callable(function: Callable|QJSValue) -> Callable|None: + """ + Checks if the given function can be called (either a python callable + or a QJSValue function), and returns the object that can be called directly. + Returns None if not a function. + """ + if isinstance(function, QJSValue) and function.isCallable(): + return PyJSValue(function) + elif isinstance(function, Callable): + return function + return None + class InvalidReturnValue(Exception): pass @@ -69,16 +81,30 @@ class PyPromise(QObject): fulfilled = Signal((QJSValue,), (QObject,)) rejected = Signal(Exception) - def __init__(self, to_run: Callable, args=[]): + def __init__(self, to_run: Callable|QJSValue, args=[], start_automatically=True): QObject.__init__(self) self._fulfills = [] self._rejects = [] self._state = "pending" + self._started = False self.fulfilled.connect(self._fulfill) self.rejected.connect(self._reject) + to_run = check_callable(to_run) + if to_run is None: + raise ValueError("New PyPromise created with invalid function") self._runner = PyPromiseRunner(to_run, self, args) - QThreadPool.globalInstance().start(self._runner) - + if start_automatically: + self._start() + + @Slot() + def start(self, *args, **kwargs): + """ + Starts the thread that will run the promise. + """ + if not self._started: # Avoid getting started twice. + QThreadPool.globalInstance().start(self._runner) + self._started = True + @Property(str) def state(self): return self._state @@ -89,13 +115,11 @@ class PyPromise(QObject): """ Adds listeners for both fulfilment and catching errors of the Promise. """ - if isinstance(on_fulfill, QJSValue): - self._fulfills.append(PyJSValue(on_fulfill)) - elif isinstance(on_fulfill, Callable): + on_fulfill = check_callable(on_fulfill) + on_reject = check_callable(on_reject) + if on_fulfill is not None: self._fulfills.append(on_fulfill) - if isinstance(on_reject, QJSValue): - self._rejects.append(PyJSValue(on_reject)) - elif isinstance(on_reject, Callable): + if on_reject is not None: self._rejects.append(on_reject) return self @@ -107,6 +131,7 @@ class PyPromise(QObject): for on_fulfill in self._fulfills: try: result = on_fulfill(data) + result = result.qjs_value if isinstance(result, PyJSValue) else result data = result if result not in no_return else data # Forward data. except Exception as e: self._reject(repr(e))