From ea882a6de3608e54c3d063919d866e894c26e517 Mon Sep 17 00:00:00 2001 From: "Christopher T. Johnson" Date: Fri, 5 Apr 2024 10:56:22 -0400 Subject: [PATCH] Checkpoint. Not working --- lib/__init__.py | 3 +- lib/words.py | 886 ++++--------------------------------- plugins/__init__.py | 0 plugins/merriam-webster.py | 253 +++++++++++ 4 files changed, 346 insertions(+), 796 deletions(-) create mode 100644 plugins/__init__.py create mode 100644 plugins/merriam-webster.py diff --git a/lib/__init__.py b/lib/__init__.py index 77356d7..d0e6412 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -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 diff --git a/lib/words.py b/lib/words.py index 660397a..f57df96 100644 --- a/lib/words.py +++ b/lib/words.py @@ -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,85 +82,79 @@ 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 - return + @classmethod + def set_resources(cls) -> None: + if len(cls._resources.keys()) > 0: + return + # + # Colors we used + # + headerFont = QFontDatabase.font("OpenDyslexic", None, 10) + headerFont.setPixelSize(48) + labelFont = QFont(headerFont) + labelFont.setPixelSize(30) + boldFont = QFont(headerFont) + boldFont.setPixelSize(20) + textFont = QFont(boldFont) + italicFont = QFont(boldFont) + capsFont = QFont(boldFont) + smallCapsFont = QFont(boldFont) + headerFont.setWeight(QFont.Weight.Bold) + boldFont.setBold(True) + italicFont.setItalic(True) + capsFont.setCapitalization(QFont.Capitalization.AllUppercase) + smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps) + + phonicFont = QFontDatabase.font("Gentium", None, 10) + phonicFont.setPixelSize(20) + + cls._resources = { + "colors": { + "base": QColor(Qt.GlobalColor.white), + "link": QColor("#4a7d95"), + "subdued": QColor(Qt.GlobalColor.gray), + }, + "fonts": { + "header": headerFont, + "label": labelFont, + "phonic": phonicFont, + "bold": boldFont, + "italic": italicFont, + "text": textFont, + "caps": capsFont, + "smallCaps": smallCapsFont, + }, + } @pyqtSlot() - def finished(self) -> None: - print("finished") + 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: - 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']}") + 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] | None: + def get_def(self) -> list[Line]: if len(self._lines) > 0: return self._lines - if len(self._resources.keys()) < 1: - # - # Colors we used - # - headerFont = QFontDatabase.font("OpenDyslexic", None, 10) - headerFont.setPixelSize(48) - labelFont = QFont(headerFont) - labelFont.setPixelSize(30) - boldFont = QFont(headerFont) - boldFont.setPixelSize(20) - textFont = QFont(boldFont) - italicFont = QFont(boldFont) - capsFont = QFont(boldFont) - smallCapsFont = QFont(boldFont) - - headerFont.setWeight(QFont.Weight.Bold) - boldFont.setBold(True) - italicFont.setItalic(True) - capsFont.setCapitalization(QFont.Capitalization.AllUppercase) - smallCapsFont.setCapitalization(QFont.Capitalization.SmallCaps) - - phonicFont = QFontDatabase.font("Gentium", None, 10) - phonicFont.setPixelSize(20) - - self._resources = { - "colors": { - "base": QColor(Qt.GlobalColor.white), - "blue": QColor("#4a7d95"), - }, - "fonts": { - "header": headerFont, - "label": labelFont, - "phonic": phonicFont, - "bold": boldFont, - "italic": italicFont, - "text": textFont, - "caps": capsFont, - "smallCaps": smallCapsFont, - }, - } - if self.current["source"] == "mw": - return self.mw_def() - elif self.current["source"] == "apidictionary": - return None - else: + 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): diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/merriam-webster.py b/plugins/merriam-webster.py new file mode 100644 index 0000000..3211799 --- /dev/null +++ b/plugins/merriam-webster.py @@ -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()]