import copy import json import re from typing import Any, Optional, cast import requests from PyQt6.QtCore import QMargins, QPoint, QRect, QSize, QUrl, Qt, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, QFont, QFontDatabase, QFontMetrics, QMouseEvent, QPainter, QPaintEvent, QResizeEvent, QTextOption, QTransform, ) from PyQt6.QtSql import QSqlQuery from PyQt6.QtWidgets import QScrollArea, QWidget from lib import query_error 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 return def __str__(self) -> str: return self.__repr__() def size(self, width:int) -> QSize: rect = QRect(self._position,QSize(width,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 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 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 # 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[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_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: 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] = [] def __init__(self, word: str) -> None: self.resources = {} # # Have we already retrieved this word? # try: self.current = json.loads(Word._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(): Word._words[word] = { 'word': word, 'source': query.value('source'), 'definition': json.loads(query.value("definition")), } self.current = Word._words[word] return # # The code should look at our settings to see if we have an API # key for MW to decide on the source to use. # source = 'mw' response = requests.get(MWAPI.format(word=word)) if response.status_code != 200: self.current = {} return data = json.loads(response.content.decode("utf-8")) print(data) self._words[word] = { 'word': word, 'source': source, 'definition': data, } self.current = Word._words[word] query.prepare( "INSERT INTO words " "(word, source, definition) " "VALUES (:word, :source, :definition)" ) 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 getWord(self) -> str: return 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 # 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) 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']}") 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( 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 = Fragment( f"{outer} {inner} ", self.resources['fonts']['bold'], color = self.resources['colors']['base'] ) outer = ' ' frag.setLeft(10) line.addFragment(frag) frag = 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 =Fragment(f" ", self.resources['fonts']['bold'], ) frag.setLeft(45) line.addFragment(frag) line.addFragment( 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 = Fragment(hw, self.resources['fonts']['header'], color=base) line.addFragment(frag) frag = 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 entry["vrs"]: frag = Fragment(space + vrs["va"], self.resources['fonts']['label'], color=base) space = ' ' line.addFragment(frag) lines.append(line) if "prs" in entry["hwi"].keys(): line = self.Line() frag = Fragment(entry['hwi']['hw'] + ' ', self.resources['fonts']['phonic'], color=base) line.addFragment(frag) for prs in entry["hwi"]["prs"]: audio = self.mw_sound_url(prs) if audio is None: audio = "" frag = Fragment(prs['mw'], self.resources['fonts']['phonic'], color=blue) frag.setAudio(audio) line.addFragment(frag) lines.append(line) if "ins" in entry.keys(): line = self.Line() space = '' for ins in entry['ins']: try: frag = Fragment(ins['il'], self.resources['fonts']['text'], color=base) line.addFragment(frag) space = ' ' except KeyError: pass frag = Fragment(space + ins['if'], self.resources['fonts']['bold'], color=base) line.addFragment(frag) space = '; ' lines.append(line) if 'lbs' in entry.keys(): line = self.Line() frag = Fragment('; '.join(entry['lbs']), self.resources['fonts']['bold'], color=base) line.addFragment(frag) 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(Fragment( v, self.resources['fonts']['italic'], color=blue )) 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 # word = self.current["hwi"]["hw"] label = self.current["fl"] html = f'