From 3dcbd5f78dcc2c8b0a292f35554f3d4a5e0ab6fc Mon Sep 17 00:00:00 2001 From: "Christopher T. Johnson" Date: Wed, 27 Mar 2024 12:01:47 -0400 Subject: [PATCH] Almost working version of words --- deftest.py | 13 +- lib/__init__.py | 2 + lib/words.py | 359 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 268 insertions(+), 106 deletions(-) diff --git a/deftest.py b/deftest.py index 34afc2e..ca5aca6 100644 --- a/deftest.py +++ b/deftest.py @@ -1,15 +1,17 @@ #!/usr/bin/env python3 import os import sys +from typing import cast 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 import DefinitionArea, Word from lib.sounds import SoundOff from lib.utils import query_error +from lib.words import Definition def main() -> int: @@ -32,14 +34,17 @@ def main() -> int: query = QSqlQuery() if not query.exec( "CREATE TABLE IF NOT EXISTS words " - "(word_id INTEGER PRIMARY KEY AUTOINCREMENT, word TEXT, definition TEXT)" + "(word_id INTEGER PRIMARY KEY AUTOINCREMENT, " + "word TEXT, source TEXT, definition TEXT)" ): query_error(query) word = Word("boat") snd = SoundOff() - widget = Definition(word) # noqa: F841 - widget.pronounce.connect(snd.playSound) + widget = DefinitionArea(word) # xnoqa: F841 + d = cast(Definition,widget.widget()) + assert d is not None + d.pronounce.connect(snd.playSound) widget.show() return app.exec() diff --git a/lib/__init__.py b/lib/__init__.py index d565c31..dd554ce 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -1,5 +1,7 @@ +# pyright: ignore from .utils import query_error # isort: skip from .books import Book from .person import PersonDialog from .read import ReadDialog from .session import SessionDialog +from .words import Definition, Word, DefinitionArea diff --git a/lib/words.py b/lib/words.py index ada2b88..8c0f614 100644 --- a/lib/words.py +++ b/lib/words.py @@ -1,10 +1,10 @@ import copy import json import re -from typing import Any, Dict, Optional, Self +from typing import Any, Optional, Self import requests -from PyQt6.QtCore import QPoint, QRect, QUrl, Qt, pyqtSignal +from PyQt6.QtCore import QPoint, QRect, QSize, QUrl, Qt, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, @@ -14,11 +14,12 @@ from PyQt6.QtGui import ( QMouseEvent, QPainter, QPaintEvent, + QResizeEvent, QTextOption, QTransform, ) from PyQt6.QtSql import QSqlQuery -from PyQt6.QtWidgets import QWidget +from PyQt6.QtWidgets import QScrollArea, QScrollBar, QSizePolicy, QWidget from lib import query_error @@ -28,8 +29,8 @@ MWAPI = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}? class Word: _instance = None - _words: Dict[str, str] = {} - _current: Dict[str, Any] + _words: dict[str, Any] = {} + _current: dict[str, Any] = {} _currentWord: str class Fragment: @@ -50,6 +51,8 @@ class Word: _position: QPoint # where to drawText _borderRect: QRect # where to drawRect _radius: int # Radius for rounded rects + _asis: bool = False + _left: int = 0 TYPES = [ 'text', 'span', 'button' ] @@ -58,7 +61,8 @@ class Word: font:QFont, t:str = 'text', audio:str = '', - color: QColor = None + color: Optional[QColor] = None, + asis: bool = False, ) -> None: if t not in self.TYPES: raise Exception(f"Unknown fragment type{t}") @@ -80,7 +84,10 @@ class Word: self._color = color else: self._color = QColor() + self._asis = asis return + def __str__(self) -> str: + return self._text # # Setters # @@ -182,6 +189,9 @@ class Word: def setColor(self,color:QColor) -> None: self._color = color return + def setLeft(self, left:int) -> None: + self._left = left + return # # Getters # @@ -211,6 +221,10 @@ class Word: return self._borderRect def color(self) -> QColor: return self._color + def asis(self) -> bool: + return self._asis + def left(self) -> int: + return self._left class Line: @@ -226,9 +240,13 @@ class Word: self._fragments = [] return + def __repr__(self) -> str: + return '|'.join([x.text() for x in self._fragments])+f'|{self._maxHeight}' + def parseText(self, frag: 'Word.Fragment') -> list['Word.Fragment']: org = frag.text() - print(org) + if frag.asis(): + return [frag] # # Needed Fonts # @@ -272,13 +290,12 @@ class Word: token = text[1:end] frag.setText(text[end+1:]) newFrag = copy.copy(frag) - oldFont = QFont(frag.font) + oldFont = QFont(frag.font()) if token == 'bc': - results.append(Word.Fragment(': ', bold)) + results.append(Word.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']: - frag.setText(text) if token == 'b': frag.setFont(bold) elif token in ['it', 'qword', 'wi']: @@ -374,7 +391,7 @@ class Word: self._fragments += items return - def finalizeLine(self, width:int) -> None: + def finalizeLine(self, maxWidth:int) -> None: """Create all of the positions for all the fragments.""" # # Find the maximum hight and max baseline @@ -386,23 +403,23 @@ class Word: fm = QFontMetrics(frag.font()) rect = fm.boundingRect(frag.text(), frag.align()) height = rect.height() - baseLine = height - fm.descent() + bl = height - fm.descent() # # Add the padding, border and margin to adjust the baseline and height # b = frag.padding() height += b[0] + b[2] - baseLine += b[2] + bl += b[2] b = frag.border() height += b[0] + b[2] - baseLine += b[2] + bl += b[2] b = frag.margin() height += b[0] + b[2] - baseLine += b[2] + bl += b[2] if height > maxHeight: maxHeight = height - if baseLine > baseLine: - baseLine = baseLine + if bl > baseLine: + baseLine = bl self._baseLine = baseLine self._maxHeight = maxHeight self._leading = 0 # XXX - How should this be calculated? @@ -410,10 +427,11 @@ class Word: for frag in self._fragments: # # TODO - Wordwrap - # TODO - indent + # + if x < frag.left(): + x = frag.left() fm = QFontMetrics(frag.font()) - rect = fm.boundingRect(frag.text()) - width = rect.width() + width = fm.horizontalAdvance(frag.text()) padding = frag.padding() offset = padding[3] # Left margin width += padding[1] + padding[3] @@ -436,6 +454,8 @@ class Word: top = self._baseLine + fm.descent() - rect.height() -1 y = top - padding[0] - border[0] frag.setBorderRect(QRect(x+margin[3], y, width, height)) + if x + width > maxWidth: + print(f'Wrap text: {frag.text()}, {x}+{width} = {x + width}') x += width return @@ -454,7 +474,14 @@ class Word: return cls._instance def __init__(self, word: str) -> None: - self._currentWord = word + # + # reset the current definition + # + try: + if word != self._current['word']: + self._lines = [] + except KeyError: + pass # # Have we already retrieved this word? # @@ -469,130 +496,240 @@ class Word: if not query.exec(): query_error(query) if query.next(): - self._words[word] = query.value("definition") - self._current = json.loads(self._words[word]) + self._words[word] = { + 'word': word, + 'source': query.value('source'), + 'definition': json.loads(query.value("definition")), + } + self._current = self._words[word] return + source = 'mw' response = requests.get(MWAPI.format(word=word)) if response.status_code != 200: - self._current = None + self._current = {} return data = json.loads(response.content.decode("utf-8")) - # - # XXX - The first entry should be the correct entry. There could be more - # 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")) - self._words[word] = json.dumps(data[0]) - self._current = data[0] + print(data) + self._words[word] = { + 'word': word, + 'source': source, + 'definition': data, + } + self._current = self._words[word] query.prepare( "INSERT INTO words " - "(word, definition) " - "VALUES (:word, :definition)" + "(word, source, definition) " + "VALUES (:word, :source, :definition)" ) - query.bindValue(":word", word) - query.bindValue(":definition", self._words[word]) + query.bindValue(":word", self._current['word']) + query.bindValue(":source", self._current['source']) + query.bindValue(":definition", json.dumps(self._current['definition'])) if not query.exec(): query_error(query) return def getCurrent(self) -> str: - assert self._currentWord is not None - return self._currentWord + return self._current['word'] def get_html(self) -> str | None: - if not self._current: - return None - if "meta" in self._current.keys(): + if self._current['source'] == 'mw': return self.mw_html() - else: + elif self._current['source'] == 'apidictionary': return self.apidictionary_html() - - 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 + raise Exception(f"Unknown source: {self._current['source']}") - def mw_def(self) -> list[Line]: + _resources:dict[str,Any] = {} + def get_def(self) -> list[Line] | None: if len(self._lines) > 0: return self._lines - assert self._current is not None - line = self.Line() - # - # Colors we used - # - base = QColor(Qt.GlobalColor.white) - blue = QColor("#4a7d95") - headerFont = QFontDatabase.font("OpenDyslexic", None, 10) - headerFont.setPixelSize(48) - headerFont.setWeight(QFont.Weight.Bold) - labelFont = QFontDatabase.font("OpenDyslexic", None, 10) - labelFont.setPixelSize(30) - phonicFont = QFontDatabase.font("Gentium", None, 10) - phonicFont.setPixelSize(20) - boldFont = QFontDatabase.font("OpenDyslexic", None, 10) - boldFont.setPixelSize(20) - boldFont.setBold(True) - textFont = QFontDatabase.font("OpenDyslexic", None, 10) - textFont.setPixelSize(20) + 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) - hw = re.sub(r'\*', '', self._current['hwi']['hw']) - frag = Word.Fragment(hw, headerFont, color=base) - line.addFragment(frag) - frag = Word.Fragment(' '+self._current["fl"], labelFont, color=blue) - line.addFragment(frag) - self._lines.append(line) + headerFont.setWeight(QFont.Weight.Bold) + boldFont.setBold(True) + italicFont.setItalic(True) + + 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, + } + } + if self._current['source'] == 'mw': + return self.mw_def() + elif self._current['source'] == 'apidictionary': + return None + else: + raise Exception(f"Unknown source: {self._current['source']}") - if "vrs" in self._current.keys(): + def mw_def(self) -> list[Line]: + lines: list[Word.Line] = [] + for entry in self._current['definition']: + line = Word.Line() + meta = json.dumps(entry['meta']) + line.addFragment( + Word.Fragment( + meta, + self._resources['fonts']['text'],asis=True + ) + ) + lines.append(line) + lines += self.mw_def_entry(entry) + self._lines = lines + return lines + + def mw_seq(self, seq: list[Any]) -> list[Line]: + lines: list[Word.Line] = [] + outer = ' ' + inner = ' ' + for value in seq: + sense = value[1] + # + # The optional 'sn' field tells us what sort of labeling to do + # + sn = sense.get('sn', '') + sns = sn.split(' ') + if len(sns) == 2: + outer = sns[0] + inner = sns[1] + elif len(sns) == 1: + if inner == ' ': + outer = sns[0] + else: + inner = sns[0] + + for dt in sense['dt']: + if dt[0] == 'text': + line = Word.Line() + frag = Word.Fragment( + f"{outer} {inner} ", + self._resources['fonts']['bold'], + color = self._resources['colors']['base'] + ) + outer = ' ' + frag.setLeft(10) + line.addFragment(frag) + frag = Word.Fragment( + dt[1], + self._resources['fonts']['text'], + color = self._resources['colors']['base'] + ) + frag.setLeft(30) + line.addFragment(frag) + lines.append(line) + elif dt[0] == 'vis': + for vis in dt[1]: + line = Word.Line() + frag =Word.Fragment(f" ", + self._resources['fonts']['bold'], + ) + frag.setLeft(45) + line.addFragment(frag) + line.addFragment( + Word.Fragment(vis['t'], + self._resources['fonts']['text'], + color = QColor('#aaa') + ) + ) + lines.append(line) + return lines + def mw_def_entry(self, entry) -> list[Line]: + # + # Easy reference to colors + # + base = self._resources['colors']['base'] + blue = self._resources['colors']['blue'] + + lines: list[Word.Line] = [] + line = Word.Line() + hw = re.sub(r'\*', '', entry['hwi']['hw']) + frag = Word.Fragment(hw, self._resources['fonts']['header'], color=base) + line.addFragment(frag) + frag = Word.Fragment(' '+entry["fl"], self._resources['fonts']['label'], color=blue) + line.addFragment(frag) + lines.append(line) + + if "vrs" in entry.keys(): line = self.Line() space = '' - for vrs in self._current["vrs"]: - frag = Word.Fragment(space + vrs["va"], labelFont, color=base) + for vrs in entry["vrs"]: + frag = Word.Fragment(space + vrs["va"], self._resources['fonts']['label'], color=base) space = ' ' line.addFragment(frag) - self._lines.append(line) - if "prs" in self._current["hwi"].keys(): + lines.append(line) + if "prs" in entry["hwi"].keys(): line = self.Line() - frag = Word.Fragment(self._current['hwi']['hw'] + ' ', phonicFont, color=base) + frag = Word.Fragment(entry['hwi']['hw'] + ' ', self._resources['fonts']['phonic'], color=base) line.addFragment(frag) - for prs in self._current["hwi"]["prs"]: + for prs in entry["hwi"]["prs"]: audio = self.sound_url(prs) if audio is None: audio = "" - frag = Word.Fragment(prs['mw'], phonicFont, color=blue) + frag = Word.Fragment(prs['mw'], self._resources['fonts']['phonic'], color=blue) frag.setAudio(audio) frag.setPadding(0,10,3,12) frag.setBorder(1) frag.setMargin(0,3,0,3) line.addFragment(frag) - self._lines.append(line) - if "ins" in self._current.keys(): + lines.append(line) + if "ins" in entry.keys(): line = self.Line() space = '' - for ins in self._current['ins']: + for ins in entry['ins']: try: - frag = Word.Fragment(ins['il'], textFont, color=base) + frag = Word.Fragment(ins['il'], self._resources['fonts']['text'], color=base) line.addFragment(frag) space = ' ' except KeyError: - space = '' - frag = Word.Fragment(space + ins['if'], boldFont, color=base) + pass + frag = Word.Fragment(space + ins['if'], self._resources['fonts']['bold'], color=base) line.addFragment(frag) space = '; ' - self._lines.append(line) - if 'lbs' in self._current.keys(): - print('lbs') + lines.append(line) + if 'lbs' in entry.keys(): line = self.Line() - frag = Word.Fragment('; '.join(self._current['lbs']), boldFont, color=base) + frag = Word.Fragment('; '.join(entry['lbs']), self._resources['fonts']['bold'], color=base) line.addFragment(frag) - self._lines.append(line) - - return self._lines + lines.append(line) + for value in entry['def']: # has multiple 'sseg' or 'vd' init + for k,v in value.items(): + if k == 'sseq': # has multiple 'senses' + for seq in v: + r = self.mw_seq(seq) + lines += r + elif k == 'vd': + line = self.Line() + line.addFragment(Word.Fragment( + v, + self._resources['fonts']['italic'], + color=blue + )) + lines.append(line) + return lines - def sound_url(self, prs: Dict[str, Any], fmt: str = "ogg") -> str | None: + 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(): @@ -609,9 +746,6 @@ class Word: 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 # @@ -703,12 +837,23 @@ class Definition(QWidget): self._lines = lines self._buttons = [] assert self._lines is not None + #self.setFixedWidth(600) base = 0 for line in self._lines: - line.finalizeLine(80) + line.finalizeLine(self.width()) base += line.getLeading() + self.setFixedHeight(base) return + def resizeEvent(self, event: QResizeEvent) -> None: + base = 0 + for line in self._lines: + line.finalizeLine(event.size().width()) + base += line.getLeading() + self.setFixedHeight(base) + super(Definition,self).resizeEvent(event) + return + _downRect: QRect | None = None def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: @@ -768,10 +913,20 @@ class Definition(QWidget): # elif frag.wRef(): painter.drawLine(frag.borderRect().bottomLeft(), frag.borderRect().bottomRight()) - print(base, painter.pen().color().name(), frag.position(), frag.text()) painter.drawText(frag.position(), frag.text()) painter.restore() painter.resetTransform() base += line.getLeading() painter.restore() return + +class DefinitionArea(QScrollArea): + def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None: + super(DefinitionArea,self).__init__(*args,*kwargs) + d = Definition(w) + self.setWidget(d) + self.setWidgetResizable(True) + self.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOn + ) + return