Compare commits

..

No commits in common. "a85a4721e3988756e3da1524219381214f8d599b" and "37ac400f230591edb0e14efaaff50de127b7e19a" have entirely different histories.

7 changed files with 75 additions and 156 deletions

View file

@ -529,15 +529,11 @@ class CanvasAPI extends Module {
this.#canvas.loadImageAsync(imgData.source).then(() => {
if(this.#redrawCount === currentRedrawCount)
callback(imgData)
else
console.log("1Discard 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)

View file

@ -201,6 +201,7 @@ class IOAPI extends Module {
// TODO: Error handling
return
}
Canvas.redraw()
this.#alert.show(qsTranslate("io", "Loaded file '%1'.").arg(basename))
this.#saved = true
this.emit(new LoadedEvent())

View file

@ -19,12 +19,10 @@
from PySide6.QtCore import QtMsgType, qInstallMessageHandler, QMessageLogContext
from math import ceil, log10
from os import path
from re import compile
CURRENT_PATH = path.dirname(path.realpath(__file__))
SOURCEMAP_PATH = path.realpath(f"{CURRENT_PATH}/../qml/eu/ad5001/LogarithmPlotter/js/index.mjs.map")
SOURCEMAP_INDEX = None
INDEX_REG = compile(r"build\/runtime-pyside6\/LogarithmPlotter\/qml\/eu\/ad5001\/LogarithmPlotter\/js\/index.mjs:(\d+)")
class LOG_COLORS:
@ -79,7 +77,6 @@ def create_log_terminal_message(mode: QtMsgType, context: QMessageLogContext, me
# Check MJS
if line is not None and source_file is not None and source_file.endswith("index.mjs"):
source_file, line = map_javascript_source(source_file, line)
# Parse message
prefix = f"{LOG_COLORS.INVERT}{mode[1]}[{mode[0].upper()}]{LOG_COLORS.RESET_INVERT}"
message = message + LOG_COLORS.RESET
context = f"{context.function} at {source_file}:{line}"

View file

@ -82,7 +82,6 @@ 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)
@ -118,68 +117,19 @@ class Latex(QObject):
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.
"""
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(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
return PyPromise(self.renderSync, [latex_markup, font_size, color])
@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, render_hash, export_path = self.create_export_path(latex_markup, font_size, color)
markup_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
@ -188,12 +138,14 @@ 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()}'
@ -203,7 +155,7 @@ class Latex(QObject):
"""
Finds a prerendered image and returns its data if possible, and an empty string if not.
"""
markup_hash, render_hash, export_path = self.create_export_path(latex_markup, font_size, color)
markup_hash, export_path = self.create_export_path(latex_markup, font_size, color)
data = ""
if path.exists(export_path + ".png"):
img = QImage(export_path)
@ -213,13 +165,10 @@ 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())
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
export_path = path.join(self.tempdir, f'{markup_hash}_{int(font_size)}_{color.rgb()}')
return markup_hash, export_path
def create_latex_doc(self, export_path: str, latex_markup: str):
"""

View file

@ -17,24 +17,12 @@
"""
from typing import Callable
from PySide6.QtCore import QRunnable, Signal, Property, QObject, Slot, QThreadPool
from PySide6.QtCore import QRunnable, Signal, QObject, Slot, QThreadPool
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
@ -63,10 +51,10 @@ class PyPromiseRunner(QRunnable):
data = data.qjs_value
else:
raise InvalidReturnValue("Must return either a primitive, a valid QObject, JS Value, or None.")
self.promise.fulfilled.emit(data)
self.promise.finished.emit(data)
except Exception as e:
try:
self.promise.rejected.emit(repr(e))
self.promise.errored.emit(repr(e))
except RuntimeError as e2:
# Happens when the PyPromise has already been garbage collected.
# In other words, nothing to report to nowhere.
@ -78,36 +66,18 @@ class PyPromise(QObject):
Asynchronous Promise-like object meant to interface between Python and Javascript easily.
Runs to_run in another thread, and calls fulfilled (populated by then) with its return value.
"""
fulfilled = Signal((QJSValue,), (QObject,))
rejected = Signal(Exception)
finished = Signal((QJSValue,), (QObject,))
errored = Signal(Exception)
def __init__(self, to_run: Callable|QJSValue, args=[], start_automatically=True):
def __init__(self, to_run: Callable, args):
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.finished.connect(self._fulfill)
self.errored.connect(self._reject)
self._runner = PyPromiseRunner(to_run, self, args)
if start_automatically:
self._start()
QThreadPool.globalInstance().start(self._runner)
@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
@Slot(QJSValue, result=QObject)
@Slot(QJSValue, QJSValue, result=QObject)
@ -115,23 +85,23 @@ class PyPromise(QObject):
"""
Adds listeners for both fulfilment and catching errors of the Promise.
"""
on_fulfill = check_callable(on_fulfill)
on_reject = check_callable(on_reject)
if on_fulfill is not None:
if isinstance(on_fulfill, QJSValue):
self._fulfills.append(PyJSValue(on_fulfill))
elif isinstance(on_fulfill, Callable):
self._fulfills.append(on_fulfill)
if on_reject is not None:
if isinstance(on_reject, QJSValue):
self._rejects.append(PyJSValue(on_reject))
elif isinstance(on_reject, Callable):
self._rejects.append(on_reject)
return self
@Slot(QJSValue)
@Slot(QObject)
def _fulfill(self, data):
self._state = "fulfilled"
no_return = [None, QJSValue.SpecialValue.UndefinedValue]
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))
@ -140,7 +110,6 @@ class PyPromise(QObject):
@Slot(QJSValue)
@Slot(str)
def _reject(self, error):
self._state = "rejected"
no_return = [None, QJSValue.SpecialValue.UndefinedValue]
for on_reject in self._rejects:
result = on_reject(error)

View file

@ -17,7 +17,7 @@
"""
import pytest
from os import getcwd, remove, path
from os import getcwd, remove
from os.path import join
from tempfile import TemporaryDirectory
from json import loads
@ -25,12 +25,11 @@ from shutil import copy2
from PySide6.QtCore import QObject, Signal, QThreadPool
from PySide6.QtGui import QImage
from PySide6.QtQml import QJSValue
from PySide6.QtWidgets import QApplication
from LogarithmPlotter import __VERSION__ as version
from LogarithmPlotter.util import config, helper
from LogarithmPlotter.util.helper import Helper, InvalidFileException
from LogarithmPlotter.util.helper import ChangelogFetcher, Helper, InvalidFileException
pwd = getcwd()
helper.SHOW_GUI_MESSAGES = False
@ -44,45 +43,41 @@ def temporary():
directory.cleanup()
def create_changelog_callback_asserter(promise, expect_404=False):
def cb(changelog, expect_404=expect_404):
# print("Got changelog", changelog)
assert isinstance(changelog, QJSValue)
assert changelog.isString()
changlogValue = changelog.toVariant()
assert ('404' in changlogValue) == expect_404
def error(e):
raise eval(e)
promise.then(cb, error)
class MockHelperSignals(QObject):
changelogFetched = Signal(str)
CHANGELOG_BASE_PATH = path.realpath(path.join(path.dirname(path.realpath(__file__)), "..", "CHANGELOG.md"))
def __init__(self, expect_404):
QObject.__init__(self)
self.expect_404 = expect_404
self.changelogFetched.connect(self.changelog_fetched)
self.changelog = None
def changelog_fetched(self, changelog):
self.changelog = changelog
class TestChangelog:
def test_exists(self, qtbot):
helper.CHANGELOG_VERSION = '0.5.0'
mock_helper = MockHelperSignals(False)
fetcher = ChangelogFetcher(mock_helper)
fetcher.run() # Does not raise an exception
qtbot.waitSignal(mock_helper.changelogFetched, timeout=10000)
assert type(mock_helper.changelog) == str
assert '404' not in mock_helper.changelog
def tests_no_exist(self, qtbot):
mock_helper = MockHelperSignals(True)
helper.CHANGELOG_VERSION = '1.0.0'
fetcher = ChangelogFetcher(mock_helper)
fetcher.run()
qtbot.waitSignal(mock_helper.changelogFetched, timeout=10000)
assert type(mock_helper.changelog) == str
assert '404' in mock_helper.changelog
class TestHelper:
def test_changelog(self, temporary, qtbot):
helper.CHANGELOG_VERSION = '0.5.0'
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
promise = obj.fetchChangelog()
create_changelog_callback_asserter(promise, expect_404=False)
qtbot.waitSignal(promise.fulfilled, timeout=10000)
# No exist
helper.CHANGELOG_VERSION = '2.0.0'
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
promise = obj.fetchChangelog()
create_changelog_callback_asserter(promise, expect_404=True)
qtbot.waitSignal(promise.fulfilled, timeout=10000)
# Local
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
assert path.exists(CHANGELOG_BASE_PATH)
copy2(CHANGELOG_BASE_PATH, helper.CHANGELOG_CACHE_PATH)
assert path.exists(helper.CHANGELOG_CACHE_PATH)
promise = obj.fetchChangelog()
create_changelog_callback_asserter(promise, expect_404=False)
qtbot.waitSignal(promise.fulfilled, timeout=10000) # Local
def test_read(self, temporary):
# Test file reading and information loading.
tmpfile, directory = temporary
@ -173,3 +168,15 @@ class TestHelper:
obj.setSetting("last_install_greet", obj.getSetting("last_install_greet"))
obj.setSetting("check_for_updates", obj.getSetting("check_for_updates"))
obj.setSetting("default_graph.xzoom", obj.getSetting("default_graph.xzoom"))
def test_fetch_changelog(self, temporary, qtbot):
tmpfile, directory = temporary
obj = Helper(pwd, tmpfile)
copy2("../../CHANGELOG.md", "../../LogarithmPlotter/util/CHANGELOG.md")
obj.fetchChangelog()
assert QThreadPool.globalInstance().activeThreadCount() == 0
qtbot.waitSignal(obj.changelogFetched, timeout=10000)
remove("../../LogarithmPlotter/util/CHANGELOG.md")
obj.fetchChangelog()
assert QThreadPool.globalInstance().activeThreadCount() > 0
qtbot.waitSignal(obj.changelogFetched, timeout=10000)

View file

@ -54,8 +54,8 @@ class TestLatex:
# Reset
[latex.DVIPNG_PATH, latex.LATEX_PATH] = bkp
def test_render_sync(self, latex_obj: latex.Latex) -> None:
result = latex_obj.renderSync(r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255))
def test_render(self, latex_obj: latex.Latex) -> None:
result = latex_obj.render(r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255))
# Ensure result format
assert type(result) == str
[path, width, height] = result.split(",")
@ -64,17 +64,17 @@ class TestLatex:
assert match(r"\d+", height)
# Ensure it returns errors on invalid latex.
with pytest.raises(latex.RenderError):
latex_obj.renderSync(r"\nonexistant", 14, QColor(0, 0, 0, 255))
latex_obj.render(r"\nonexistant", 14, QColor(0, 0, 0, 255))
# Replace latex bin with one that returns errors
bkp = latex.LATEX_PATH
latex.LATEX_PATH = which("false")
with pytest.raises(latex.RenderError):
latex_obj.renderSync(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255))
latex_obj.render(r"\mathrm{f}(x)", 14, QColor(0, 0, 0, 255))
latex.LATEX_PATH = bkp
def test_prerendered(self, latex_obj: latex.Latex) -> None:
args = [r"\frac{d\sqrt{\mathrm{f}(x \times 2.3)}}{dx}", 14, QColor(0, 0, 0, 255)]
latex_obj.renderSync(*args)
latex_obj.render(*args)
prerendered = latex_obj.findPrerendered(*args)
assert type(prerendered) == str
[path, width, height] = prerendered.split(",")