diff --git a/deftest.py b/deftest.py index 3e2e70c..34afc2e 100644 --- a/deftest.py +++ b/deftest.py @@ -36,7 +36,7 @@ def main() -> int: ): query_error(query) - word = Word("lady") + word = Word("boat") snd = SoundOff() widget = Definition(word) # noqa: F841 widget.pronounce.connect(snd.playSound) diff --git a/lib/words.py b/lib/words.py index 5e62543..ada2b88 100644 --- a/lib/words.py +++ b/lib/words.py @@ -1,9 +1,10 @@ +import copy import json import re -from typing import Any, Dict, List, Optional, Self, Type, cast +from typing import Any, Dict, Optional, Self import requests -from PyQt6.QtCore import QPoint, QRect, Qt, pyqtSignal +from PyQt6.QtCore import QPoint, QRect, QUrl, Qt, pyqtSignal from PyQt6.QtGui import ( QBrush, QColor, @@ -13,8 +14,8 @@ from PyQt6.QtGui import ( QMouseEvent, QPainter, QPaintEvent, - QTextFormat, QTextOption, + QTransform, ) from PyQt6.QtSql import QSqlQuery from PyQt6.QtWidgets import QWidget @@ -28,41 +29,65 @@ MWAPI = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}? class Word: _instance = None _words: Dict[str, str] = {} - _current: Optional[Dict[str, Any]] = None - _currentWord: Optional[str] = None + _current: Dict[str, Any] + _currentWord: str class Fragment: - _type: str - _text: str - _font: QFont - _audio: str - _align: QTextOption - _rect: QRect + """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 = '', - align:QTextOption = QTextOption(Qt.AlignmentFlag.AlignLeft| - Qt.AlignmentFlag.AlignBaseline), - rect:QRect = QRect(0,0,0,0) + color: QColor = None ) -> None: - self._type = t # or 'container' + if t not in self.TYPES: + raise Exception(f"Unknown fragment type{t}") + self._type = t 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 + 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: - raise Exception("Bad Value") + 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 @@ -71,7 +96,7 @@ class Word: self._font = font return def setAudio(self, audio:str) -> None: - self._audio = audio + self._audio = QUrl(audio) return def setAlign(self, align:QTextOption) -> None: self._align = align @@ -79,6 +104,89 @@ class Word: 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: @@ -86,18 +194,30 @@ class Word: def font(self) -> QFont: return self._font def audio(self) -> str: - return self._audio + 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'] + _fragments: list['Word.Fragment'] def __init__(self) -> None: self._maxHeight = -1 @@ -106,126 +226,228 @@ class Word: 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] = [] + def parseText(self, frag: 'Word.Fragment') -> list['Word.Fragment']: + org = frag.text() + print(org) # - # Break the text into parts based on brace markup + # Needed Fonts # - 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) + 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)) - while len(parts) > 0: - if parts[0] == '{bc}': + 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)) - 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 + 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 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) + 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 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()) + 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() - if fragment.type() == "btn": - height += 6 - baseLine += 3 - if baseLine > self._baseLine: - self._baseLine = baseLine - if rect.height() > self._maxHeight: - self._maxHeight = rect.height() - + # + # 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 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 + 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 - 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] = [] - _lines: List[Line] = [] - - def __new__(cls: Type[Self], word: str) -> Self: # flycheck: ignore + def __new__(cls: type[Self], _: str) -> Self: # flycheck: ignore if cls._instance: return cls._instance cls._instance = super(Word, cls).__new__(cls) @@ -286,7 +508,7 @@ class Word: else: return self.apidictionary_html() - def get_def(self) -> List[Line] | None: + def get_def(self) -> list[Line] | None: if not self._current: return None if "meta" in self._current.keys(): @@ -294,53 +516,80 @@ class Word: else: return None - def mw_def(self) -> List[Line]: + 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(32) + labelFont.setPixelSize(30) phonicFont = QFontDatabase.font("Gentium", None, 10) - phonicFont.setPixelSize(32) + phonicFont.setPixelSize(20) boldFont = QFontDatabase.font("OpenDyslexic", None, 10) - boldFont.setPixelSize(24) + boldFont.setPixelSize(20) boldFont.setBold(True) textFont = QFontDatabase.font("OpenDyslexic", None, 10) - textFont.setPixelSize(24) + textFont.setPixelSize(20) - line.addFragment(self._current["hwi"]["hw"], headerFont) - line.addFragment(self._current["fl"], labelFont, color="#4a7d95") + 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"]: - line.addFragment(vrs["va"], labelFont) + 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 = "" - line.addFragment( - prs["mw"], - phonicFont, - opt="btn", - audio=audio, - color="#4a7d95", - ) + 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() - line.addFragment( - "; ".join([x["if"] for x in self._current["ins"]]), boldFont - ) + 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: @@ -443,8 +692,8 @@ class Word: class Definition(QWidget): pronounce = pyqtSignal(str) _word: str - _lines: List[Word.Line] - _buttons: List[QRect] + _lines: list[Word.Line] + _buttons: list[QRect] def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None: super(Definition, self).__init__(*args, **kwargs) @@ -456,11 +705,7 @@ class Definition(QWidget): assert self._lines is not None base = 0 for line in self._lines: - for frag in line.getLine(): - if frag["opt"] == "btn": - rect = line.getBtnRect(frag) - rect.moveTop(base) - self._buttons.append(rect) + line.finalizeLine(80) base += line.getLeading() return @@ -487,7 +732,7 @@ class Definition(QWidget): self._downRect = None return super().mouseReleaseEvent(event) - def paintEvent(self, event: Optional[QPaintEvent]) -> None: # noqa + def paintEvent(self, _: Optional[QPaintEvent]) -> None: # noqa painter = QPainter(self) painter.save() painter.setBrush(QBrush()) @@ -504,30 +749,29 @@ class Definition(QWidget): 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(): - keys = frag.keys() - font = cast(QFont, frag["font"]) - painter.setFont(font) - if "color" in keys: - painter.save() - painter.setPen(QColor(frag["color"])) - if frag["opt"] == "btn": - rect = line.getBtnRect(frag) - rect.moveTop(base) - painter.drawRoundedRect(rect, 10.0, 10.0) - painter.drawText( - cast(int, frag["x"]) + 3, - base + 3 + cast(int, frag["y"]), - cast(str, frag["text"]), - ) - else: - painter.drawText( - cast(int, frag["x"]), - base + cast(int, frag["y"]), - cast(str, frag["text"]), - ) - if "color" in keys: - painter.restore() + 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