async parsing + tex caching
This commit is contained in:
+122
-48
@@ -5,9 +5,13 @@
|
||||
#include <jkqtmathtext/jkqtmathtext.h>
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFontDatabase>
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
#include <QThreadPool>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
@@ -15,6 +19,9 @@ namespace {
|
||||
// A few px of breathing room around the equation.
|
||||
constexpr int kRenderMargin = 2;
|
||||
constexpr unsigned int kResolutionDpi = 96;
|
||||
// The render cache is unbounded between clears; cap it so a very long
|
||||
// session with many distinct equations cannot grow it forever.
|
||||
constexpr int kCacheLimit = 512;
|
||||
|
||||
// Registers the embedded Latin Modern faces with the font database
|
||||
// (once per process) and reports which families became available.
|
||||
@@ -65,20 +72,23 @@ const LatinModern& loadLatinModern() {
|
||||
} // namespace
|
||||
|
||||
LlmMathText::LlmMathText(QObject* parent)
|
||||
: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) {
|
||||
// No parent: the renderer is used from pool threads, and a parented
|
||||
// QObject would taint children it creates there.
|
||||
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>(
|
||||
nullptr, /* useFontsForGUI */ true)) {
|
||||
// Latin Modern is the default font of modern LaTeX; use the embedded
|
||||
// faces instead of whatever the system happens to have installed.
|
||||
const LatinModern& fonts = loadLatinModern();
|
||||
if (fonts.roman)
|
||||
m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
if (fonts.math) {
|
||||
// Same pattern as JKQTMathText's useXITS(): the OpenType math
|
||||
// font supplies the math alphabet and operators from its MATH
|
||||
// table.
|
||||
m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer.setFallbackFontSymbols(
|
||||
m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
m_renderer->setFallbackFontSymbols(
|
||||
QStringLiteral("Latin Modern Math"),
|
||||
JKQTMathTextFontEncoding::MTFEUnicode);
|
||||
}
|
||||
@@ -112,7 +122,28 @@ void LlmMathText::setDevicePixelRatio(qreal value) {
|
||||
reRender();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Process-wide render cache; the key is the full render state, so
|
||||
// re-opening a chat reuses already-rendered equations. Accessed on the
|
||||
// GUI thread only.
|
||||
struct MathRender {
|
||||
bool ok = false;
|
||||
QImage image;
|
||||
QUrl url;
|
||||
qreal width = 0;
|
||||
qreal height = 0;
|
||||
};
|
||||
|
||||
QHash<QString, MathRender>& mathCache() {
|
||||
static QHash<QString, MathRender> cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void LlmMathText::reRender() {
|
||||
++m_requestId;
|
||||
if (m_latex.trimmed().isEmpty()) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
@@ -123,54 +154,97 @@ void LlmMathText::reRender() {
|
||||
return;
|
||||
}
|
||||
|
||||
m_renderer.setFontPointSize(m_fontPointSize);
|
||||
m_renderer.setFontColor(m_color);
|
||||
|
||||
const bool ok = m_renderer.parse(
|
||||
m_latex,
|
||||
JKQTMathText::LatexParser,
|
||||
JKQTMathText::DefaultParseOptions);
|
||||
if (!ok) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
m_width = 0;
|
||||
m_height = 0;
|
||||
m_ok = false;
|
||||
const QString key = m_latex
|
||||
+ QLatin1Char(0x1f) + m_color.name()
|
||||
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize)
|
||||
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
|
||||
if (auto it = mathCache().find(key); it != mathCache().end()) {
|
||||
m_image = it->image;
|
||||
m_imageUrl = it->url;
|
||||
m_width = it->width;
|
||||
m_height = it->height;
|
||||
m_ok = it->ok;
|
||||
Q_EMIT changed();
|
||||
return;
|
||||
}
|
||||
|
||||
const QImage image = m_renderer.drawIntoImage(
|
||||
/* drawBoxes */ false,
|
||||
QColor(Qt::transparent),
|
||||
kRenderMargin,
|
||||
m_devicePixelRatio,
|
||||
kResolutionDpi);
|
||||
if (image.isNull()) {
|
||||
m_image = QImage();
|
||||
m_imageUrl = QUrl();
|
||||
m_width = 0;
|
||||
m_height = 0;
|
||||
m_ok = false;
|
||||
Q_EMIT changed();
|
||||
// A render is already running; it re-renders the latest state when
|
||||
// it completes (id mismatch), so there is nothing to do here.
|
||||
if (m_inFlight)
|
||||
return;
|
||||
}
|
||||
m_inFlight = true;
|
||||
|
||||
m_image = image;
|
||||
QByteArray png;
|
||||
{
|
||||
QBuffer buffer(&png);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
image.save(&buffer, "PNG");
|
||||
}
|
||||
m_imageUrl = QUrl(
|
||||
QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64()));
|
||||
// drawIntoImage renders at devicePixelRatio; convert back to
|
||||
// logical pixels.
|
||||
m_width = image.width() / m_devicePixelRatio;
|
||||
m_height = image.height() / m_devicePixelRatio;
|
||||
m_ok = true;
|
||||
Q_EMIT changed();
|
||||
const QString latex = m_latex;
|
||||
const QColor color = m_color;
|
||||
const double pointSize = m_fontPointSize;
|
||||
const qreal dpr = m_devicePixelRatio;
|
||||
// The worker captures the renderer by value (shared_ptr) so it
|
||||
// stays alive even if this object is destroyed mid-render; it is
|
||||
// only ever used by the single in-flight worker (m_inFlight),
|
||||
// never concurrently.
|
||||
auto renderer = m_renderer;
|
||||
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
|
||||
MathRender render;
|
||||
renderer->setFontPointSize(pointSize);
|
||||
renderer->setFontColor(color);
|
||||
if (renderer->parse(
|
||||
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) {
|
||||
const QImage image = renderer->drawIntoImage(
|
||||
/* drawBoxes */ false,
|
||||
QColor(Qt::transparent),
|
||||
kRenderMargin,
|
||||
dpr,
|
||||
kResolutionDpi);
|
||||
if (!image.isNull()) {
|
||||
QByteArray png;
|
||||
{
|
||||
QBuffer buffer(&png);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
image.save(&buffer, "PNG");
|
||||
}
|
||||
render.image = image;
|
||||
render.url = QUrl(
|
||||
QStringLiteral("data:image/png;base64,")
|
||||
+ QString::fromLatin1(png.toBase64()));
|
||||
// drawIntoImage renders at devicePixelRatio; convert
|
||||
// back to logical pixels.
|
||||
render.width = image.width() / dpr;
|
||||
render.height = image.height() / dpr;
|
||||
render.ok = true;
|
||||
}
|
||||
}
|
||||
// Deliver through the app instance (never destroyed) and
|
||||
// re-check the pointer on the GUI thread: posting to `this`
|
||||
// from the pool thread would race with its destruction.
|
||||
QPointer<LlmMathText> guard(this);
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[guard, id, key, render = std::move(render)]() mutable {
|
||||
LlmMathText* self = guard;
|
||||
if (!self)
|
||||
return;
|
||||
self->m_inFlight = false;
|
||||
if (id != self->m_requestId) {
|
||||
// Superseded while the worker ran; render the
|
||||
// latest state.
|
||||
self->reRender();
|
||||
return;
|
||||
}
|
||||
if (render.ok) {
|
||||
auto& cache = mathCache();
|
||||
if (cache.size() >= kCacheLimit)
|
||||
cache.clear();
|
||||
cache.insert(key, render);
|
||||
}
|
||||
self->m_image = render.image;
|
||||
self->m_imageUrl = render.url;
|
||||
self->m_width = render.width;
|
||||
self->m_height = render.height;
|
||||
self->m_ok = render.ok;
|
||||
Q_EMIT self->changed();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
Reference in New Issue
Block a user