From d9fefb99ddadbba562a0ce0c7d3e0f02ea1c31a4 Mon Sep 17 00:00:00 2001 From: "Christopher T. Johnson" Date: Thu, 21 Mar 2024 09:42:09 -0400 Subject: [PATCH] Checkpoint of Word refactor --- deftest.py | 48 +++++ lib/words.py | 515 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 500 insertions(+), 63 deletions(-) create mode 100644 deftest.py diff --git a/deftest.py b/deftest.py new file mode 100644 index 0000000..3e2e70c --- /dev/null +++ b/deftest.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import os +import sys + +from PyQt6.QtCore import QResource +from PyQt6.QtGui import QFontDatabase +from PyQt6.QtSql import QSqlDatabase, QSqlQuery +from PyQt6.QtWidgets import QApplication + +from lib import Definition, Word +from lib.sounds import SoundOff +from lib.utils import query_error + + +def main() -> int: + db = QSqlDatabase() + db = db.addDatabase("QSQLITE") + db.setDatabaseName("test.db") + if not db.open(): + raise Exception(db.lastError()) + app = QApplication(sys.argv) + # + # Setup resources + # + if not QResource.registerResource( + os.path.join(os.path.dirname(__file__), "ui/resources.rcc"), "/" + ): + raise Exception("Unable to register resources.rcc") + QFontDatabase.addApplicationFont( + ":/fonts/opendyslexic/OpenDyslexic-Regular.otf" + ) + query = QSqlQuery() + if not query.exec( + "CREATE TABLE IF NOT EXISTS words " + "(word_id INTEGER PRIMARY KEY AUTOINCREMENT, word TEXT, definition TEXT)" + ): + query_error(query) + + word = Word("lady") + snd = SoundOff() + widget = Definition(word) # noqa: F841 + widget.pronounce.connect(snd.playSound) + widget.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/words.py b/lib/words.py index 623290e..5e62543 100644 --- a/lib/words.py +++ b/lib/words.py @@ -1,27 +1,238 @@ import json -import requests +import re +from typing import Any, Dict, List, Optional, Self, Type, cast +import requests +from PyQt6.QtCore import QPoint, QRect, Qt, pyqtSignal +from PyQt6.QtGui import ( + QBrush, + QColor, + QFont, + QFontDatabase, + QFontMetrics, + QMouseEvent, + QPainter, + QPaintEvent, + QTextFormat, + QTextOption, +) from PyQt6.QtSql import QSqlQuery -from typing import Type, Self, Dict, Any, Optional +from PyQt6.QtWidgets import QWidget from lib import query_error 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: _instance = None - _words: Dict[str,str] = {} - _current: Optional[Dict[str,Any]] = None - - def __new__(cls: Type[Self], word:str ) -> Self: + _words: Dict[str, str] = {} + _current: Optional[Dict[str, Any]] = None + _currentWord: Optional[str] = None + + class Fragment: + _type: str + _text: str + _font: QFont + _audio: str + _align: QTextOption + _rect: QRect + + def __init__(self, + text:str, + font:QFont, + t:str = 'text', + audio:str = '', + align:QTextOption = QTextOption(Qt.AlignmentFlag.AlignLeft| + Qt.AlignmentFlag.AlignBaseline), + rect:QRect = QRect(0,0,0,0) + ) -> None: + self._type = t # or 'container' + self._text = text + self._font = font + self._audio = audio + self._align = align + self._rect = rect + return + + def setType(self, t:str) -> None: + if t == 'text': + self._type = t + elif t== 'container': + self._type = t + else: + raise Exception("Bad Value") + return + def setText(self, text:str) -> None: + self._text = text + return + def setFont(self, font:QFont) -> None: + self._font = font + return + def setAudio(self, audio:str) -> None: + self._audio = audio + return + def setAlign(self, align:QTextOption) -> None: + self._align = align + return + def setRect(self,rect:QRect) -> None: + self._rect = rect + return + def type(self) -> str: + return self._type + def text(self) -> str: + return self._text + def font(self) -> QFont: + return self._font + def audio(self) -> str: + return self._audio + def align(self) -> QTextOption: + return self._align + def rect(self) -> QRect: + return self._rect + + class Line: + + _maxHeight: int + _leading: int + _baseLine: int + _fragments: List['Word.Fragment'] + + def __init__(self) -> None: + self._maxHeight = -1 + self._baseLine = -1 + self._leading = -1 + self._fragments = [] + return + + def fixText(self, frag: 'Word.Fragment') -> List['Word.Fragment']: + text = frag.text() + text = re.sub(r"\*", "\u2022", text) + text = re.sub(r"\{ldquo\}", "\u201c", text) + text = re.sub(r"\{rdquo\}", "\u201d", text) + parts: List[str] = [] + # + # Break the text into parts based on brace markup + # + while len(text) > 0: + start = text.find("{") + if start > 0: + parts.append(text[:start]) + text = text[start:] + if start >= 0: + end = text.find("}") + parts.append(text[:end]) + text = text[end:] + else: + parts.append(text) + text = '' + results: List[Word.Fragment] = [] + bold = QFont(frag.font()) + bold.setBold(True) + italic = QFont(frag.font()) + italic.setItalic(True) + script = QFont(frag.font()) + script.setPixelSize(int(script.pixelSize() / 4)) + while len(parts) > 0: + if parts[0] == '{bc}': + results.append(Word.Fragment(': ', bold)) + elif parts[0] == '{inf}': + parts.pop(0) + results.append(Word.Fragment(parts[0], script)) # baseAdjust=??? + parts.pop(0) + elif parts[0] == '{sup}': + parts.pop(0) + results.append(Word.Fragment(parts[0], script)) # baseAdjust=??? + parts.pop(0) + elif parts[0] == '{it}' or parts[0] == '{wi}': + parts.pop(0) + results.append(Word.Fragment(parts[0], italic)) # baseAdjust=??? + parts.pop(0) + elif parts[0] == '{sc}' or parts[0] == '{parahw}': + parts.pop(0) + font = QFont(frag.font()) + font.setCapitalization(QFont.Capitalization.SmallCaps) + results.append(Word.Fragment(parts[0], font)) + parts.pop(0) + elif parts[0] == '{phrase}': + font = QFont(bold) + font.setItalic(True) + parts.pop(0) + results.append(Word.Fragment(parts[0], font)) + parts.pop(0) + elif parts[0] == '{gloss}': + parts.pop(0) + results.append(Word.Fragment(f"[{parts[0]}]",frag.font())) + parts.pop(0) + else: + results.append(Word.Fragment(parts[0],frag.font())) + parts.pop(0) + return results + + def addFragment(self, frag: 'Word.Fragment',) -> None: + SPEAKER = "\U0001F508" + + if len(self._fragments) > 0: + frag._text = ' ' + frag._text + if frag._audio is not None: + frag._audio += ' ' + SPEAKER + items = self.fixText(frag)) + for item in items: + self._fragments.append(item) + return + + def getLine(self) -> List['Word.Fragment']: + for fragment in self._fragments: + font = fragment.font() + fm = QFontMetrics(font) + if fm.leading() > self._leading: + self._leading = fm.leading() + rect = fm.boundingRect(fragment.text(), fragment.align()) + height = rect.height() + baseLine = height - fm.descent() + if fragment.type() == "btn": + height += 6 + baseLine += 3 + if baseLine > self._baseLine: + self._baseLine = baseLine + if rect.height() > self._maxHeight: + self._maxHeight = rect.height() + + x = 0 + for fragment in self._fragments: + fragment.setPosition(QPoint(x,self._baseLine)) + fm = QFontMetrics(fragment.font()) + rect = fm.boundingRect(fragment.text(),fragment.align()) + x += rect.width() + if fragment.type() == "btn": + x += 6 + return self._fragments + + def getLeading(self) -> int: + return self._leading + self._maxHeight + + def getBtnRect( + self, frag: Dict[str, str | QTextOption | QFont | int] + ) -> QRect: + fm = QFontMetrics(cast(QFont, frag["font"])) + rect = fm.boundingRect( + cast(str, frag["text"]), cast(QTextOption, frag["align"]) + ) + rect.setHeight(rect.height() + 6) + rect.setWidth(rect.width() + 6) + return rect + + _lines: List[Line] = [] + + def __new__(cls: Type[Self], word: str) -> Self: # flycheck: ignore if cls._instance: return cls._instance cls._instance = super(Word, cls).__new__(cls) return cls._instance def __init__(self, word: str) -> None: - print(f"Word == {word}") + self._currentWord = word # # Have we already retrieved this word? # @@ -31,8 +242,7 @@ class Word: except KeyError: pass query = QSqlQuery() - query.prepare("SELECT * FROM words " - "WHERE word = :word") + query.prepare("SELECT * FROM words " "WHERE word = :word") query.bindValue(":word", word) if not query.exec(): query_error(query) @@ -50,18 +260,25 @@ class Word: # if there is a "hom" entry, then that will be appended to meta.id # word = "lady", hom=1, meta.id = "lady:1"; # - print(response.content.decode('utf-8')) + print(response.content.decode("utf-8")) self._words[word] = json.dumps(data[0]) self._current = data[0] - query.prepare("INSERT INTO words " - "(word, definition) " - "VALUES (:word, :definition)") + query.prepare( + "INSERT INTO words " + "(word, definition) " + "VALUES (:word, :definition)" + ) query.bindValue(":word", word) query.bindValue(":definition", self._words[word]) if not query.exec(): query_error(query) return - def get_html(self) -> str|None: + + def getCurrent(self) -> str: + assert self._currentWord is not None + return self._currentWord + + def get_html(self) -> str | None: if not self._current: return None if "meta" in self._current.keys(): @@ -69,76 +286,248 @@ class Word: else: return self.apidictionary_html() - def mw_html(self) -> str: + def get_def(self) -> List[Line] | None: + if not self._current: + return None + if "meta" in self._current.keys(): + return self.mw_def() + else: + return None - def sound_url(prs:Dict[str,Any]) -> str|None: - base = 'https://media.merriam-webster.com/audio/prons/en/us/ogg' - if 'sound' not in prs.keys(): - return None - audio = prs['sound']['audio'] - if audio.startswith('bix'): - url = base + '/bix/' - elif audio.startswith('gg'): - url = base + '/gg/' - elif audio[0] not in "abcdefghijklmnopqrstuvwxyz": - url = base + '/number/' - else: - url = base + '/' + audio[0] + '/' - url += audio + '.ogg' - return url - - def parse_sn(sn:str, old:str) -> str: - return sn - + def mw_def(self) -> List[Line]: + if len(self._lines) > 0: + return self._lines assert self._current is not None - word = self._current['hwi']['hw'] - label = self._current['fl'] - html = f"

{word} {label}

\n" + line = self.Line() + headerFont = QFontDatabase.font("OpenDyslexic", None, 10) + headerFont.setPixelSize(48) + headerFont.setWeight(QFont.Weight.Bold) + labelFont = QFontDatabase.font("OpenDyslexic", None, 10) + labelFont.setPixelSize(32) + phonicFont = QFontDatabase.font("Gentium", None, 10) + phonicFont.setPixelSize(32) + boldFont = QFontDatabase.font("OpenDyslexic", None, 10) + boldFont.setPixelSize(24) + boldFont.setBold(True) + textFont = QFontDatabase.font("OpenDyslexic", None, 10) + textFont.setPixelSize(24) + + line.addFragment(self._current["hwi"]["hw"], headerFont) + line.addFragment(self._current["fl"], labelFont, color="#4a7d95") + self._lines.append(line) + if "vrs" in self._current.keys(): - html += "
    \n" + line = self.Line() + for vrs in self._current["vrs"]: + line.addFragment(vrs["va"], labelFont) + self._lines.append(line) + if "prs" in self._current["hwi"].keys(): + line = self.Line() + for prs in self._current["hwi"]["prs"]: + audio = self.sound_url(prs) + if audio is None: + audio = "" + line.addFragment( + prs["mw"], + phonicFont, + opt="btn", + audio=audio, + color="#4a7d95", + ) + self._lines.append(line) + if "ins" in self._current.keys(): + line = self.Line() + line.addFragment( + "; ".join([x["if"] for x in self._current["ins"]]), boldFont + ) + self._lines.append(line) + return self._lines + + def 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: + def parse_sn(sn: str, old: str) -> str: + return sn + + assert self._current is not None + + # + # Create the header, base word and its label + # + word = self._current["hwi"]["hw"] + label = self._current["fl"] + html = f'

    {word} {label}

    \n' + + # + # If there are variants, then add them in an unordered list. + # CSS will make it pretty + # + if "vrs" in self._current.keys(): + html += "
\n" - if 'prs' in self._current['hwi'].keys(): + html += "\n
  • ".join( + [vrs["va"] for vrs in self._current["vrs"]] + ) + html += "
  • \n\n" + + # + # If there is a pronunciation section, create it + # + if "prs" in self._current["hwi"].keys(): tmp = [] - for prs in self._current['hwi']['prs']: - url = sound_url(prs) - how = prs['mw'] + for prs in self._current["hwi"]["prs"]: + url = self.sound_url(prs) + how = prs["mw"] if url: tmp.append(f'\\{how}\\') else: - tmp.append(f'\\{how}\\') + tmp.append(f"\\{how}\\") html += '' html += ''.join(tmp) html += "\n" - if 'ins' in self._current.keys(): - html += "

    " - html += ', '.join([ins['if'] for ins in self._current['ins']]) + + # + # If there are inflections, create a header for that. + # + if "ins" in self._current.keys(): + html += '

    ' + html += ", ".join([ins["if"] for ins in self._current["ins"]]) html += "

    \n" + + # + # Start creating the definition section + # html += "