import copy import json import re from typing import Any, Dict, Optional, Self import requests from PyQt6.QtCore import QPoint, QRect, QUrl, Qt, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, QFont, QFontDatabase, QFontMetrics, QMouseEvent, QPainter, QPaintEvent, QTextOption, QTransform, ) from PyQt6.QtSql import QSqlQuery 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: Dict[str, Any] _currentWord: str class Fragment: """A structure to hold typed values of a fragment.""" _type: str # Function of this fragment. Think text, span, button _text: str # The simple utf-8 text _content: list['Word.Fragment'] _audio: QUrl # Optional audio URL _font: QFont # The font, with all options, to use for this fragment _align: QTextOption # Alignment information _rect: QRect # The rect that contains _text _padding: list[int] # space to add around the text _border: list[int] # Size of the border (all the same) _margin: list[int] # Space outside of the border _color: QColor # the pen color _wref: str # a word used as a 'href' _position: QPoint # where to drawText _borderRect: QRect # where to drawRect _radius: int # Radius for rounded rects TYPES = [ 'text', 'span', 'button' ] def __init__(self, text:str, font:QFont, t:str = 'text', audio:str = '', color: QColor = None ) -> None: if t not in self.TYPES: raise Exception(f"Unknown fragment type{t}") self._type = t self._text = text self._font = font self._audio = QUrl(audio) self._align = QTextOption( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBaseline ) self._padding = [0, 0, 0, 0] self._border = [0, 0, 0, 0] self._margin = [0, 0, 0, 0] self._wref = '' self._position = QPoint() self._borderRect = QRect() if color: self._color = color else: self._color = QColor() return # # Setters # def setType(self, t:str) -> None: if t not in self.TYPES: raise Exception(f"Unknown fragment type{t}") self._type = t 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 = 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, top:int = -1, right:int = -1, bottom:int = -1, left:int = -1, *args:int) -> None: if top > -1 or right > -1 or bottom > -1 or left > -1: if top >= 0: self._padding[0] = top if right >= 0: self._padding[1] = right if bottom >= 0: self._padding[2] = bottom if left >= 0: self._padding[3] = left return if len(args) == 4: self._padding = [args[0], args[1], args[2], args[3]] elif len(args) == 3: self._padding = [args[0], args[1], args[2], args[1]] elif len(args) == 2: self._padding = [args[0], args[1], args[0], args[1]] elif len(args) == 1: self._padding = [args[0], args[0], args[0], args[0]] else: raise Exception("argument error") return def setBorder(self, top:int = -1, right:int = -1, bottom:int = -1, left:int = -1, *args:int) -> None: if top > -1 or right > -1 or bottom > -1 or left > -1: if top >= 0: self._border[0] = top if right >= 0: self._border[1] = right if bottom >= 0: self._border[2] = bottom if left >= 0: self._border[3] = left return if len(args) == 4: self._border = [args[0], args[1], args[2], args[3]] elif len(args) == 3: self._border = [args[0], args[1], args[2], args[1]] elif len(args) == 2: self._border = [args[0], args[1], args[0], args[1]] elif len(args) == 1: self._border = [args[0], args[0], args[0], args[0]] else: raise Exception("argument error") return def setMargin(self, top:int = -1, right:int = -1, bottom:int = -1, left:int = -1, *args:int) -> None: if top > -1 or right > -1 or bottom > -1 or left > -1: if top >= 0: self._margin[0] = top if right >= 0: self._margin[1] = right if bottom >= 0: self._margin[2] = bottom if left >= 0: self._margin[3] = left return if len(args) == 4: self._margin = [args[0], args[1], args[2], args[3]] elif len(args) == 3: self._margin = [args[0], args[1], args[2], args[1]] elif len(args) == 2: self._margin = [args[0], args[1], args[0], args[1]] elif len(args) == 1: self._margin = [args[0], args[0], args[0], args[0]] else: raise Exception("argument error") 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 setColor(self,color:QColor) -> None: self._color = color return # # Getters # def wRef(self) -> str: return self._wref 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.url() def align(self) -> QTextOption: return self._align def rect(self) -> QRect: return self._rect def padding(self) -> list[int]: return self._padding def border(self) -> list[int]: return self._border def margin(self) -> list[int]: return self._margin def position(self) -> QPoint: return self._position def borderRect(self) -> QRect: return self._borderRect def color(self) -> QColor: return self._color 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 parseText(self, frag: 'Word.Fragment') -> list['Word.Fragment']: org = frag.text() print(org) # # Needed Fonts # 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)) results: list['Word.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(Word.Fragment(': ', bold)) 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']: 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_line', 'd_link', 'dxt', 'et_link', 'i_link', 'mat', 'sx']: wref = '' htext = fields[1] oldFont = QFont(frag.font()) 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 == 'i_link': if fields[2] == '': wref = fields[1] else: wref = fields[2] else: raise Exception(f"Unknown code: {token} in {org}") newFrag = copy.copy(frag) newFrag.setText(htext) newFrag.setWRef(wref) 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: 'Word.Fragment',) -> None: SPEAKER = "\U0001F508" if frag.audio(): 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.type() == 'btn': frag.setPadding(3) frag.setBorder(1) frag.setMargin(2) items = self.parseText(frag) self._fragments += items return def finalizeLine(self, width: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()) rect = fm.boundingRect(frag.text(), frag.align()) height = rect.height() baseLine = 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] b = frag.border() height += b[0] + b[2] baseLine += b[2] b = frag.margin() height += b[0] + b[2] baseLine += b[2] if height > maxHeight: maxHeight = height if baseLine > baseLine: baseLine = baseLine self._baseLine = baseLine self._maxHeight = maxHeight self._leading = 0 # XXX - How should this be calculated? x = 0 for frag in self._fragments: # # TODO - Wordwrap # TODO - indent fm = QFontMetrics(frag.font()) rect = fm.boundingRect(frag.text()) width = rect.width() padding = frag.padding() offset = padding[3] # Left margin width += padding[1] + padding[3] border = frag.border() offset += border[3] width += border[1] + border[3] margin = frag.margin() offset += margin[3] width += margin[1] + margin[3] frag.setPosition(QPoint(x+offset, self._baseLine)) if frag.border()[0] != 0: # # 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() - rect.height() -1 y = top - padding[0] - border[0] frag.setBorderRect(QRect(x+margin[3], y, width, height)) x += width return def getLine(self) -> list['Word.Fragment']: return self._fragments def getLeading(self) -> int: return self._leading + self._maxHeight _lines: list[Line] = [] def __new__(cls: type[Self], _: 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: self._currentWord = word # # Have we already retrieved this word? # try: self._current = json.loads(self._words[word]) return except KeyError: pass query = QSqlQuery() query.prepare("SELECT * FROM words " "WHERE word = :word") query.bindValue(":word", word) if not query.exec(): query_error(query) if query.next(): self._words[word] = query.value("definition") self._current = json.loads(self._words[word]) return response = requests.get(MWAPI.format(word=word)) if response.status_code != 200: self._current = None 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] 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 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(): return self.mw_html() else: 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 def mw_def(self) -> list[Line]: 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) 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) if "vrs" in self._current.keys(): line = self.Line() space = '' for vrs in self._current["vrs"]: frag = Word.Fragment(space + vrs["va"], labelFont, color=base) space = ' ' line.addFragment(frag) self._lines.append(line) if "prs" in self._current["hwi"].keys(): line = self.Line() frag = Word.Fragment(self._current['hwi']['hw'] + ' ', phonicFont, color=base) line.addFragment(frag) for prs in self._current["hwi"]["prs"]: audio = self.sound_url(prs) if audio is None: audio = "" frag = Word.Fragment(prs['mw'], phonicFont, 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(): line = self.Line() space = '' for ins in self._current['ins']: try: frag = Word.Fragment(ins['il'], textFont, color=base) line.addFragment(frag) space = ' ' except KeyError: space = '' frag = Word.Fragment(space + ins['if'], boldFont, color=base) line.addFragment(frag) space = '; ' self._lines.append(line) if 'lbs' in self._current.keys(): print('lbs') line = self.Line() frag = Word.Fragment('; '.join(self._current['lbs']), boldFont, color=base) line.addFragment(frag) 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 there is a pronunciation section, create it # if "prs" in self._current["hwi"].keys(): tmp = [] 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}\\") html += '' html += ''.join(tmp) html += "\n" # # 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 += "\n" return html def apidictionary_html(self) -> str: html = "" return html class Definition(QWidget): pronounce = pyqtSignal(str) _word: str _lines: list[Word.Line] _buttons: list[QRect] def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None: super(Definition, self).__init__(*args, **kwargs) self._word = w.getCurrent() lines = w.get_def() assert lines is not None self._lines = lines self._buttons = [] assert self._lines is not None base = 0 for line in self._lines: line.finalizeLine(80) base += line.getLeading() return _downRect: QRect | None = None def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: if not event: return super().mousePressEvent(event) for rect in self._buttons: if rect.contains(event.pos()): self._downRect = rect return return super().mousePressEvent(event) def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None: if not event: return super().mouseReleaseEvent(event) if self._downRect is not None and self._downRect.contains(event.pos()): self.pronounce.emit( "https://media.merriam-webster.com/audio/prons/en/us/ogg/a/await001.ogg" ) self._downRect = None return self._downRect = 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) for frag in line.getLine(): painter.save() painter.setFont(frag.font()) painter.setPen(frag.color()) # # Is this a button? # href = frag.audio() if href: radius = frag.borderRect().height()/2 painter.drawRoundedRect(frag.borderRect(), radius, radius) # # is it an anchor? # 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