Checkpoint. Not working

This commit is contained in:
Christopher T. Johnson
2024-04-05 10:56:22 -04:00
parent e69e958562
commit ea882a6de3
4 changed files with 346 additions and 796 deletions

View File

@@ -4,4 +4,5 @@ from .books import Book
from .person import PersonDialog
from .read import ReadDialog
from .session import SessionDialog
from .words import Definition, DefinitionArea, Word
from .words import DefinitionArea, Word
from .definition import Fragment, Line, Definition

View File

@@ -1,624 +1,46 @@
import copy
import importlib
import pkgutil
import json
import re
from typing import Any, Dict, Optional, cast
from typing import Any, Dict, cast
from PyQt6.QtCore import (
QByteArray,
QEventLoop,
QMargins,
QPoint,
QRect,
QSize,
Qt,
QThread,
QUrl,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
QFontDatabase,
QFontMetrics,
QMouseEvent,
QPainter,
QPaintEvent,
QResizeEvent,
QTextOption,
QTransform,
)
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt6.QtNetwork import QNetworkAccessManager
from PyQt6.QtSql import QSqlQuery
from PyQt6.QtWidgets import QScrollArea, QWidget
from PyQt6.QtWidgets import QScrollArea
from lib import query_error
from lib.sounds import SoundOff
from lib.definition import Definition, Line, Fragment
import plugins
def find_plugins(ns_pkg):
return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + '.')
class Fragment:
"""A fragment of text to be displayed"""
def __init__(
self,
text: str,
font: QFont,
audio: str = "",
color: Optional[QColor] = None,
asis: bool = False,
) -> None:
self._text = text
self._font = font
self._audio: QUrl = QUrl(audio)
self._align = QTextOption(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBaseline
)
self._padding = QMargins()
self._border = QMargins()
self._margin = QMargins()
self._wref = ""
self._position = QPoint()
self._rect = QRect()
self._borderRect = QRect()
self._clickRect = QRect()
if color:
self._color = color
else:
self._color = QColor()
self._asis = asis
self._left = 0
self._target = "word"
return
def __str__(self) -> str:
return self.__repr__()
def size(self, width: int) -> QSize:
rect = QRect(self._position, QSize(width - self._position.x(), 2000))
flags = (
Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignBaseline
| Qt.TextFlag.TextWordWrap
)
fm = QFontMetrics(self._font)
bounding = fm.boundingRect(rect, flags, self._text)
size = bounding.size()
size = size.grownBy(self._padding)
size = size.grownBy(self._border)
size = size.grownBy(self._margin)
return size
def height(self, width: int) -> int:
return self.size(width).height()
def width(self, width: int) -> int:
return self.size(width).width()
def __repr__(self) -> str:
return f"({self._position.x()}, {self._position.y()}): {self._text}"
def repaintEvent(self, painter: QPainter) -> int:
painter.save()
painter.setFont(self._font)
painter.setPen(self._color)
rect = QRect()
rect.setLeft(self._position.x())
rect.setTop(
self._position.y()
+ painter.fontMetrics().descent()
- painter.fontMetrics().height()
)
rect.setWidth(painter.viewport().width() - self._position.x())
rect.setHeight(2000)
flags = (
Qt.AlignmentFlag.AlignLeft
| Qt.AlignmentFlag.AlignBaseline
| Qt.TextFlag.TextWordWrap
)
bounding = painter.boundingRect(rect, flags, self._text)
size = bounding.size()
painter.setPen(QColor("#f00"))
if self._audio.isValid():
radius = self._borderRect.height() / 2
painter.drawRoundedRect(self._borderRect, radius, radius)
if self._wref:
start = bounding.bottomLeft()
end = bounding.bottomRight()
painter.drawLine(start, end)
painter.setPen(self._color)
painter.drawText(rect, flags, self._text)
painter.restore()
size = size.grownBy(self._margin)
size = size.grownBy(self._border)
size = size.grownBy(self._padding)
return size.height()
#
# Setters
#
def setText(self, text: str) -> None:
self._text = text
return
def setTarget(self, target: str) -> None:
self._target = target
return
def setFont(self, font: QFont) -> None:
self._font = font
return
def setAudio(self, audio: str | QUrl) -> None:
if type(audio) is str:
self._audio = QUrl(audio)
else:
self._audio = cast(QUrl, audio)
return
def setAlign(self, align: QTextOption) -> None:
self._align = align
return
def setRect(self, rect: QRect) -> None:
self._rect = rect
return
def setPadding(self, *args: int, **kwargs: int) -> None:
top = kwargs.get("top", -1)
right = kwargs.get("right", -1)
bottom = kwargs.get("bottom", -1)
left = kwargs.get("left", -1)
if top > -1 or right > -1 or bottom > -1 or left > -1:
if top >= 0:
self._padding.setTop(top)
if right >= 0:
self._padding.setRight(right)
if bottom >= 0:
self._padding.setBottom(bottom)
if left >= 0:
self._padding.setLeft(left)
return
if len(args) == 4:
(top, right, bottom, left) = [args[0], args[1], args[2], args[3]]
elif len(args) == 3:
(top, right, bottom, left) = [args[0], args[1], args[2], args[1]]
elif len(args) == 2:
(top, right, bottom, left) = [args[0], args[1], args[0], args[1]]
elif len(args) == 1:
(top, right, bottom, left) = [args[0], args[0], args[0], args[0]]
else:
raise Exception("argument error")
self._padding = QMargins(left, top, right, bottom)
return
def setBorder(self, *args: int, **kwargs: int) -> None:
top = kwargs.get("top", -1)
right = kwargs.get("right", -1)
bottom = kwargs.get("bottom", -1)
left = kwargs.get("left", -1)
if top > -1 or right > -1 or bottom > -1 or left > -1:
if top >= 0:
self._border.setTop(top)
if right >= 0:
self._border.setRight(right)
if bottom >= 0:
self._border.setBottom(bottom)
if left >= 0:
self._border.setLeft(left)
return
if len(args) == 4:
(top, right, bottom, left) = [args[0], args[1], args[2], args[3]]
elif len(args) == 3:
(top, right, bottom, left) = [args[0], args[1], args[2], args[1]]
elif len(args) == 2:
(top, right, bottom, left) = [args[0], args[1], args[0], args[1]]
elif len(args) == 1:
(top, right, bottom, left) = [args[0], args[0], args[0], args[0]]
else:
raise Exception("argument error")
self._border = QMargins(left, top, right, bottom)
return
def setMargin(self, *args: int, **kwargs: int) -> None:
top = kwargs.get("top", -1)
right = kwargs.get("right", -1)
bottom = kwargs.get("bottom", -1)
left = kwargs.get("left", -1)
if top > -1 or right > -1 or bottom > -1 or left > -1:
if top >= 0:
self._margin.setTop(top)
if right >= 0:
self._margin.setRight(right)
if bottom >= 0:
self._margin.setBottom(bottom)
if left >= 0:
self._margin.setLeft(left)
return
if len(args) == 4:
(top, right, bottom, left) = [args[0], args[1], args[2], args[3]]
elif len(args) == 3:
(top, right, bottom, left) = [args[0], args[1], args[2], args[1]]
elif len(args) == 2:
(top, right, bottom, left) = [args[0], args[1], args[0], args[1]]
elif len(args) == 1:
(top, right, bottom, left) = [args[0], args[0], args[0], args[0]]
else:
raise Exception("argument error")
self._margin = QMargins(left, top, right, bottom)
return
def setWRef(self, ref: str) -> None:
self._wref = ref
return
def setPosition(self, pnt: QPoint) -> None:
self._position = pnt
return
def setBorderRect(self, rect: QRect) -> None:
self._borderRect = rect
return
def setClickRect(self, rect: QRect) -> None:
self._clickRect = rect
return
def setColor(self, color: QColor) -> None:
self._color = color
return
def setLeft(self, left: int) -> None:
self._left = left
return
#
# Getters
#
def wRef(self) -> str:
return self._wref
def text(self) -> str:
return self._text
def font(self) -> QFont:
return self._font
def audio(self) -> QUrl:
return self._audio
def align(self) -> QTextOption:
return self._align
def rect(self) -> QRect:
return self._rect
def padding(self) -> QMargins:
return self._padding
def border(self) -> QMargins:
return self._border
def margin(self) -> QMargins:
return self._margin
def position(self) -> QPoint:
return self._position
def borderRect(self) -> QRect:
return self._borderRect
def clickRect(self) -> QRect:
return self._clickRect
def color(self) -> QColor:
return self._color
def asis(self) -> bool:
return self._asis
def left(self) -> int:
return self._left
discovered_plugins = {
# finder, name, ispkg
importlib.import_module(name).registration['source']: importlib.import_module(name) for _, name, _ in find_plugins(plugins)
}
API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
MWAPI = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}?key=51d9df34-ee13-489e-8656-478c215e846c"
class Word:
"""All processing of a dictionary word."""
_words: dict[str, Any] = {}
class Line:
def __init__(self) -> None:
self._maxHeight = -1
self._baseLine = -1
self._leading = -1
self._fragments: list[Fragment] = []
return
def __repr__(self) -> str:
return (
"|".join([x.text() for x in self._fragments])
+ f"|{self._maxHeight}"
)
def repaintEvent(self, painter: QPainter) -> int:
#
# we do not have an event field because we are not a true widget
#
lineSpacing = 0
for frag in self._fragments:
ls = frag.repaintEvent(painter)
if ls > lineSpacing:
lineSpacing = ls
return lineSpacing
def parseText(self, frag: Fragment) -> list[Fragment]:
org = frag.text()
if frag.asis():
return [frag]
#
# Needed Fonts
# We can't use _resources because that might not be the font
# for this piece of text
#
bold = QFont(frag.font())
bold.setWeight(QFont.Weight.Bold)
italic = QFont(frag.font())
italic.setItalic(True)
smallCaps = QFont(frag.font())
smallCaps.setCapitalization(QFont.Capitalization.SmallCaps)
script = QFont(frag.font())
script.setPixelSize(int(script.pixelSize() / 4))
caps = QFont(frag.font())
caps.setCapitalization(QFont.Capitalization.AllUppercase)
results: list[Fragment] = []
while True:
text = frag.text()
start = text.find("{")
if start < 0:
results.append(frag)
return results
if start > 0:
newFrag = copy.copy(frag)
newFrag.setText(text[:start])
results.append(newFrag)
frag.setText(text[start:])
continue
#
# Start == 0
#
#
# If the token is an end-token, return now.
#
if text.startswith("{/"):
results.append(frag)
return results
#
# extract this token
#
end = text.find("}")
token = text[1:end]
frag.setText(text[end + 1 :])
newFrag = copy.copy(frag)
oldFont = QFont(frag.font())
if token == "bc":
results.append(Fragment(": ", bold, color=QColor("#fff")))
continue
if token in [
"b",
"inf",
"it",
"sc",
"sup",
"phrase",
"parahw",
"gloss",
"qword",
"wi",
"dx",
"dx_def",
"dx_ety",
"ma",
]:
if token == "b":
frag.setFont(bold)
elif token in ["it", "qword", "wi"]:
frag.setFont(italic)
elif token == "sc":
frag.setFont(smallCaps)
elif token in ["inf", "sup"]:
frag.setFont(script)
elif token == "phrase":
font = QFont(bold)
font.setItalic(True)
frag.setFont(font)
elif token == "parahw":
font = QFont(smallCaps)
font.setWeight(QFont.Weight.Bold)
frag.setFont(font)
elif token == "gloss":
frag.setText("[" + frag.text())
elif token in ["dx", "dx_ety"]:
frag.setText("\u2014" + frag.text())
elif token == "ma":
frag.setText("\u2014 more at " + frag.text())
elif token == "dx_def":
frag.setText("(" + frag.text())
else:
raise Exception(f"Unknown block marker: {token}")
results += self.parseText(frag)
frag = results.pop()
frag.setFont(oldFont)
text = frag.text()
if not text.startswith("{/" + token + "}"):
raise Exception(
f"No matching close for {token} in {org}"
)
if token == "gloss":
results[-1].setText(results[-1].text() + "]")
elif token == "dx_def":
results[-1].setText(results[-1].text() + ")")
end = text.find("}")
text = text[end + 1 :]
frag.setText(text)
continue
#
# These are codes that include all information within the token
#
fields = token.split("|")
token = fields[0]
if token in [
"a_link",
"d_link",
"dxt",
"et_link",
"i_link",
"mat",
"sx",
]:
wref = ""
htext = fields[1]
oldFont = QFont(frag.font())
target = "word"
if token == "a_link":
wref = fields[1]
elif token in ["d_link", "et_link", "mat", "sx", "i_link"]:
if fields[2] == "":
wref = fields[1]
else:
wref = fields[2]
if token == "i_link":
frag.setFont(italic)
elif token == "sx":
frag.setFont(caps)
elif token == "dxt":
if fields[3] == "illustration":
wref = fields[2]
target = "article"
elif fields[3] == "table":
wref = fields[2]
target = "table"
elif fields[3] != "":
wref = fields[3]
target = "sense"
else:
wref = fields[1]
target = "word"
elif token == "a_link":
target = "word"
wref = fields[1]
else:
raise Exception(f"Unknown code: {token} in {org}")
newFrag = copy.copy(frag)
newFrag.setText(htext)
newFrag.setWRef(wref)
newFrag.setTarget(target)
results.append(newFrag)
frag.setFont(oldFont)
text = frag.text()
continue
raise Exception(
f"Unable to locate a known token {token} in {org}"
)
def addFragment(
self,
frag: Fragment,
) -> None:
SPEAKER = "\U0001F508"
if frag.audio().isValid():
frag.setText(frag.text() + " " + SPEAKER)
text = frag.text()
text = re.sub(r"\*", "\u2022", text)
text = re.sub(r"\{ldquo\}", "\u201c", text)
text = re.sub(r"\{rdquo\}", "\u201d", text)
frag.setText(text)
if frag.audio().isValid():
frag.setPadding(3, 0, 0, 5)
frag.setBorder(1)
frag.setMargin(0, 0, 0, 0)
items = self.parseText(frag)
self._fragments += items
return
def finalizeLine(self, width: int, base: int) -> None:
"""Create all of the positions for all the fragments."""
#
# Find the maximum hight and max baseline
#
maxHeight = -1
baseLine = -1
leading = -1
for frag in self._fragments:
fm = QFontMetrics(frag.font())
height = frag.height(width)
bl = fm.height() - fm.descent()
if fm.leading() > leading:
leading = fm.leading()
if height > maxHeight:
maxHeight = height
if bl > baseLine:
baseLine = bl
self._baseLine = baseLine
self._maxHeight = maxHeight
self._leading = leading
x = 0
for frag in self._fragments:
if x < frag.left():
x = frag.left()
#
# We need to calculate the location to draw the
# text. We also need to calculate the bounding Rectangle
# for this fragment
#
size = frag.size(width)
fm = QFontMetrics(frag.font())
offset = (
frag.margin().left()
+ frag.border().left()
+ frag.padding().left()
)
frag.setPosition(QPoint(x + offset, self._baseLine))
if not frag.border().isNull() or not frag.wRef():
#
# self._baseLine is where the text will be drawn
# fm.descent is the distance from the baseline of the
# text to the bottom of the rect
# The top of the bounding rect is at self._baseLine
# + fm.descent - rect.height
# The border is drawn at top-padding-border-margin+marin
#
top = self._baseLine + fm.descent() - fm.height()
y = top - frag.padding().top() - frag.border().top()
pos = QPoint(x, y)
rect = QRect(pos, size.shrunkBy(frag.margin()))
frag.setBorderRect(rect)
pos.setY(pos.y() + base)
frag.setClickRect(QRect(pos, size.shrunkBy(frag.margin())))
x += size.width()
return
def getLine(self) -> list[Fragment]:
return self._fragments
def getLineSpacing(self) -> int:
return self._leading + self._maxHeight
_lines: list[Line] = []
_resources: Dict[str, Any] = {}
_nam = QNetworkAccessManager()
def __init__(self, word: str) -> None:
self._resources = {}
Word.set_resources()
#
# Have we already retrieved this word?
#
@@ -645,23 +67,8 @@ class Word:
# key for MW to decide on the source to use.
#
source = "mw"
manager = QNetworkAccessManager()
request = QNetworkRequest()
url = QUrl(MWAPI.format(word=word))
request.setUrl(url)
reply = manager.get(request)
assert reply is not None
loop = QEventLoop()
reply.finished.connect(loop.quit)
loop.exec()
content = reply.readAll()
data = json.loads(content.data().decode("utf-8"))
print(data)
self._words[word] = {
"word": word,
"source": source,
"definition": data,
}
self._words[word] = discovered_plugins[source].fetch(word)
self.current = Word._words[word]
query.prepare(
"INSERT INTO words "
@@ -675,37 +82,10 @@ class Word:
query_error(query)
return
@pyqtSlot()
def playPRS(self) -> None:
try:
prs = self.current[0]["hwi"]["prs"]
audio = QUrl(self.mw_sound_url(prs))
snd = SoundOff()
snd.playSound(audio)
except KeyError:
pass
@classmethod
def set_resources(cls) -> None:
if len(cls._resources.keys()) > 0:
return
@pyqtSlot()
def finished(self) -> None:
print("finished")
return
def getWord(self) -> str:
return cast(str, self.current["word"])
def get_html(self) -> str | None:
if self.current["source"] == "mw":
return self.mw_html()
elif self.current["source"] == "apidictionary":
return self.apidictionary_html()
else:
raise Exception(f"Unknown source: {self.current['source']}")
def get_def(self) -> list[Line] | None:
if len(self._lines) > 0:
return self._lines
if len(self._resources.keys()) < 1:
#
# Colors we used
#
@@ -729,10 +109,11 @@ class Word:
phonicFont = QFontDatabase.font("Gentium", None, 10)
phonicFont.setPixelSize(20)
self._resources = {
cls._resources = {
"colors": {
"base": QColor(Qt.GlobalColor.white),
"blue": QColor("#4a7d95"),
"link": QColor("#4a7d95"),
"subdued": QColor(Qt.GlobalColor.gray),
},
"fonts": {
"header": headerFont,
@@ -745,15 +126,35 @@ class Word:
"smallCaps": smallCapsFont,
},
}
if self.current["source"] == "mw":
return self.mw_def()
elif self.current["source"] == "apidictionary":
return None
else:
@pyqtSlot()
def playSound(self) -> None:
url = discovered_plugins[self.current['source']].getFirstSound(self.current['definition'])
if url.isValid():
snd = SoundOff()
snd.playSound(url)
return
def getWord(self) -> str:
return cast(str, self.current["word"])
def get_html(self) -> str | None:
src = self.current['source']
try:
return discovered_plugins[src].getHtml(self.current)
except KeyError:
raise Exception(f"Unknown source: {src}")
def get_def(self) -> list[Line]:
if len(self._lines) > 0:
return self._lines
src = self.current['source']
try:
return discovered_plugins[src].getDef(self.current)
except KeyError:
raise Exception(f"Unknown source: {self.current['source']}")
def mw_def(self) -> list[Line]:
lines: list[Word.Line] = []
lines: list[Line] = []
# print(json.dumps(self.current,indent=2))
for entry in self.current["definition"]:
lines += self.mw_def_entry(entry)
@@ -761,7 +162,7 @@ class Word:
return lines
def mw_seq(self, seq: list[Any]) -> list[Line]:
lines: list[Word.Line] = []
lines: list[Line] = []
outer = " "
inner = " "
for value in seq:
@@ -785,7 +186,7 @@ class Word:
inner = sns[0]
try:
text = ", ".join(sense["sls"])
line = Word.Line()
line = Line()
frag = Fragment(
f"{outer} {inner} ",
self._resources["fonts"]["bold"],
@@ -805,7 +206,7 @@ class Word:
try:
for dt in sense["dt"]:
if dt[0] == "text":
line = Word.Line()
line = Line()
frag = Fragment(
f"{outer} {inner} ",
self._resources["fonts"]["bold"],
@@ -824,7 +225,7 @@ class Word:
lines.append(line)
elif dt[0] == "vis":
for vis in dt[1]:
line = Word.Line()
line = Line()
frag = Fragment(
f" ",
self._resources["fonts"]["bold"],
@@ -846,7 +247,7 @@ class Word:
try:
line = lines.pop()
except IndexError:
line = Word.Line()
line = Line()
frag = Fragment(
"\u27F6 " + seg[1],
self._resources["fonts"]["text"],
@@ -855,7 +256,8 @@ class Word:
frag.setLeft(30)
line.addFragment(frag)
lines.append(line)
elif dt[0] == 'ca':
continue
else:
raise Exception(f"Unknown key {dt[0]} in {sense['dt']}")
except KeyError:
@@ -869,8 +271,8 @@ class Word:
base = self._resources["colors"]["base"]
blue = self._resources["colors"]["blue"]
lines: list[Word.Line] = []
line = Word.Line()
lines: list[Line] = []
line = Line()
hw = re.sub(r"\*", "", entry["hwi"]["hw"])
frag = Fragment(hw, self._resources["fonts"]["header"], color=base)
line.addFragment(frag)
@@ -881,7 +283,7 @@ class Word:
lines.append(line)
if "vrs" in entry.keys():
line = self.Line()
line = Line()
space = ""
for vrs in entry["vrs"]:
frag = Fragment(
@@ -892,8 +294,8 @@ class Word:
space = " "
line.addFragment(frag)
lines.append(line)
if "prs" in entry["hwi"].keys():
line = self.Line()
if "prs" in entry["hwi"]:
line = Line()
frag = Fragment(
entry["hwi"]["hw"] + " ",
self._resources["fonts"]["phonic"],
@@ -911,7 +313,7 @@ class Word:
line.addFragment(frag)
lines.append(line)
if "ins" in entry.keys():
line = self.Line()
line = Line()
space = ""
for ins in entry["ins"]:
try:
@@ -931,7 +333,7 @@ class Word:
space = "; "
lines.append(line)
if "lbs" in entry.keys():
line = self.Line()
line = Line()
frag = Fragment(
"; ".join(entry["lbs"]),
self._resources["fonts"]["bold"],
@@ -946,7 +348,7 @@ class Word:
r = self.mw_seq(seq)
lines += r
elif k == "vd":
line = self.Line()
line = Line()
line.addFragment(
Fragment(
v, self._resources["fonts"]["italic"], color=blue
@@ -955,20 +357,6 @@ class Word:
lines.append(line)
return lines
def mw_sound_url(self, prs: dict[str, Any], fmt: str = "ogg") -> str | None:
"""Create a URL from a PRS structure."""
base = f"https://media.merriam-webster.com/audio/prons/en/us/{fmt}"
if "sound" not in prs.keys():
return None
audio = prs["sound"]["audio"]
m = re.match(r"(bix|gg|[a-zA-Z])", audio)
if m:
url = base + f"/{m.group(1)}/"
else:
url = base + "/number/"
url += audio + f".{fmt}"
return url
def mw_html(self) -> str:
#
# Create the header, base word and its label
@@ -1047,98 +435,6 @@ class Word:
return html
class Definition(QWidget):
pronounce = pyqtSignal(str)
def __init__(
self, word: Optional[Word] = None, *args: Any, **kwargs: Any
) -> None:
super(Definition, self).__init__(*args, **kwargs)
self._word = word
if word is not None:
self.setWord(word)
return
def setWord(self, word: Word) -> None:
self._word = word
lines = word.get_def()
assert lines is not None
self._lines = lines
self._buttons: list[Fragment] = []
base = 0
for line in self._lines:
line.finalizeLine(self.width(), base)
for frag in line.getLine():
if frag.audio().isValid():
self._buttons.append(frag)
if frag.wRef():
print(f"Adding {frag} as an anchor")
self._buttons.append(frag)
base += line.getLineSpacing()
self.setFixedHeight(base)
return
def resizeEvent(self, event: Optional[QResizeEvent] = None) -> None:
base = 0
for line in self._lines:
line.finalizeLine(self.width(), base)
base += line.getLineSpacing()
self.setFixedHeight(base)
super(Definition, self).resizeEvent(event)
return
_downFrag: Optional[Fragment | None] = None
def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
if not event:
return super().mousePressEvent(event)
print(f"mousePressEvent: {event.pos()}")
for frag in self._buttons:
rect = frag.clickRect()
if rect.contains(event.pos()):
self._downFrag = frag
return
return super().mousePressEvent(event)
def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None:
if not event:
return super().mouseReleaseEvent(event)
if self._downFrag is not None and self._downFrag.clickRect().contains(
event.pos()
):
audio = self._downFrag.audio().url()
print(audio)
self.pronounce.emit(audio)
print("emit done")
self._downFrag = None
return
self._downFrag = None
return super().mouseReleaseEvent(event)
def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa
painter = QPainter(self)
painter.save()
painter.setBrush(QBrush())
painter.setPen(QColor("white"))
#
# Each line needs a base calculated. To do that, we need to find the
# bounding rectangle of the text. Once we have the bounding rectangle,
# we can use the descendant to calculate the baseline within that
# bounding box.
#
# All text on this line needs to be on the same baseline
#
assert self._lines is not None
base = 0
for line in self._lines:
transform = QTransform()
transform.translate(0, base)
painter.setTransform(transform)
base += line.repaintEvent(painter)
painter.restore()
return
class DefinitionArea(QScrollArea):

0
plugins/__init__.py Normal file
View File

253
plugins/merriam-webster.py Normal file
View File

@@ -0,0 +1,253 @@
from trycast import trycast
import json
import re
from typing import Any, NamedTuple, TypedDict, cast
from PyQt6.QtCore import QEventLoop, QUrl, Qt
from PyQt6.QtGui import QColor, QFont
from PyQt6.QtNetwork import QNetworkRequest
from lib.words import Word
from lib.definition import Line, Fragment
registration = {
'source': 'mw',
'name': 'Merriam-Webster',
}
API = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}?key={key}"
key = "51d9df34-ee13-489e-8656-478c215e846c"
class TextTuple(NamedTuple):
type_: str # 'text'
text: str
class TTuple(NamedTuple):
type_: str # 't'
text: str
class VerbalIllustration(TypedDict):
t: str
aq: str
class VerbalIllustrationTuple(NamedTuple):
type_: str # 'vis'
data: list[VerbalIllustration]
class Sound(TypedDict):
audio: str
ref: str
stat: str
class Pronunciation(TypedDict):
mw: str
l: str
l2: str
pun: str
sound: Sound
class Meta(TypedDict):
id: str
uuid: str
sort: str
src: str
section: str
stems: list[str]
offensive: bool
class HeadWordInfo(TypedDict):
hw: str
prs: list[Pronunciation]
class HeadWord(TypedDict):
hw: str
prs: list[Pronunciation]
psl: str
class Variant(TypedDict):
va: str
vl: str
prs: list[Pronunciation]
spl: str
class Inflection(TypedDict):
if_: str
ifc: str
il: str
prs: list[Pronunciation]
spl: str
class DividedSense(TypedDict):
sd: str
et: list[str] # Not full
ins: list[Inflection]
lbs: list[str]
prs: list[Pronunciation]
sgram: str
sls: list[str]
vrs: list[Variant]
class BioGraphicalNameWrap(TypedDict):
pname: str
sname: str
altname: str
prs: list[Pronunciation]
class CalledAlsoTarget(TypedDict):
cat: str
catref: str
pn: str
prs: list[Pronunciation]
psl: str
class CalledAlso(TypedDict):
intro: str
cats: list[CalledAlsoTarget]
class RunInWrap(TypedDict):
rie: str
prs: list[Pronunciation]
text: str
vrs: list[Variant]
class Sense:
dt: list[str] # not full
et: list[str] # not full
ins: list[Inflection]
lbs: list[str]
prs: list[Pronunciation]
sdsense: DividedSense
sgram: str
sls: list[str]
sn: str
vrs: list[Variant]
class SenseSequence(TypedDict):
sense: Sense
sen: Sense
class Definition(TypedDict):
sseq: list[SenseSequence]
vd: str
class Entry(TypedDict):
meta: Meta
hom: str
hwi: HeadWordInfo
ahws: list[HeadWord]
vrs: list[Variant]
fl: str
def_: list[Definition]
def fetch(word:str) -> dict[str, Any]:
request = QNetworkRequest()
url = QUrl(API.format(word=word, key=key))
request.setUrl(url)
request.setTransferTimeout(3000)
reply = Word._nam.get(request)
assert reply is not None
loop = QEventLoop()
reply.finished.connect(loop.quit)
loop.exec()
content = reply.readAll()
data = json.loads(content.data().decode('utf-8'))
return {
'word': word,
'source': 'mw',
'definition': data,
}
def soundUrl(sound:Sound, fmt='ogg') -> QUrl:
"""Create a URL from a PRS structure."""
base = f"https://media.merriam-webster.com/audio/prons/en/us/{fmt}"
audio = sound['audio']
m = re.match(r"(bix|gg|[a-zA-Z])", audio)
if m:
url = base + f"/{m.group(1)}/"
else:
url = base + "/number/"
url += audio + f".{fmt}"
return QUrl(url)
def getFirstSound(definition: list[Entry]) -> QUrl:
# ahws, cats, dros, hwi, ins, ri, sdsense, sen, sense, uros, vrs
for entry in definition:
for v in entry.values():
hwi = trycast(HeadWordInfo, v)
if hwi is None:
continue
if 'prs' in hwi:
for pr in hwi['prs']:
if 'sound' in pr:
url = soundUrl(pr['sound'])
if url.isValid():
return url
return QUrl()
def do_prs(prs: list[Pronunciation]) -> list[Fragment]:
frags: list[Fragment] = []
font = trycast(QFont, Word._resources['fonts']['label'])
assert font is not None
linkColor = trycast(QColor, Word._resources['colors']['link'])
assert linkColor is not None
subduedColor = trycast(QColor, Word._resources['colors']['subdued'])
assert subduedColor is not None
for pr in prs:
if 'pun' in pr:
pun = pr['pun']
else:
pun = ' '
if 'l' in pr:
frags.append(
Fragment(pr['l'] + pun, font, color=subduedColor)
)
frag = Fragment(pr['mw'], font, color=subduedColor)
if 'sound' in pr:
frag.setAudio(soundUrl(pr['sound']))
frags.append(frag)
if 'l2' in pr:
frags.append(
Fragment(pun + pr['l2'], font, color=subduedColor)
)
return frags
def getDef(definition: list[Entry]) -> list[Line]:
lines = []
headerFont = trycast(QFont, Word._resources['fonts']['header'])
assert headerFont is not None
textFont = trycast(QFont, Word._resources['fonts']['text'])
assert textFont is not None
labelFont = trycast(QFont, Word._resources['fonts']['label'])
assert labelFont is not None
baseColor = trycast(QColor, Word._resources['colors']['base'])
assert baseColor is not None
linkColor = trycast(QColor, Word._resources['colors']['link'])
assert linkColor is not None
subduedColor = trycast(QColor, Word._resources['colors']['subdued'])
assert subduedColor is not None
entries = len(definition)
for count, entry in enumerate(definition):
#
# Create the First line from the hwi and fl
#
line = Line()
hwi = trycast(HeadWordInfo, entry['hwi'])
assert hwi is not None
hw = re.sub(r'\*', '', hwi['hw'])
line.addFragment(Fragment(hw + ' ', headerFont, color=baseColor))
frag = Fragment(f"{count} of {entries} ", textFont, color=linkColor)
frag.setBackground(QColor(Qt.GlobalColor.gray))
line.addFragment(frag)
line.addFragment(Fragment(entry['fl'], labelFont, color=baseColor))
lines.append(line)
#
# Next is the pronunciation.
#
line = Line()
hw = re.sub(r'\*', '\u00b7', hwi['hw'])
line.addFragment(Fragment(hw + ' ', textFont, color=subduedColor))
for frag in do_prs(hwi['prs']):
line.addFragment(frag)
return [Line()]