diff --git a/lib/definition.py b/lib/definition.py new file mode 100644 index 0000000..6ed7dbe --- /dev/null +++ b/lib/definition.py @@ -0,0 +1,671 @@ +import re +import copy +from typing import Any, Optional, cast +import re +from PyQt6.QtCore import QMargins, QPoint, QRect, QSize, QUrl, Qt, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QFontMetrics, QMouseEvent, QPaintEvent, QPainter, QResizeEvent, QTextOption, QTransform, QBrush +from PyQt6.QtWidgets import QWidget + +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._background = QColor() + self._asis = asis + self._left = 0 + self._target = "word" + return + + def __str__(self) -> str: + return self.__repr__() + + def size(self, width: int) -> QSize: + rect = QRect(self._position, QSize(width - self._position.x(), 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 setTarget(self, target: str) -> None: + self._target = target + 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 setBackground(self, color: QColor) -> None: + self._background = 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 + +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 + # We can't use _resources because that might not be the font + # for this piece of text + # + 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)) + caps = QFont(frag.font()) + caps.setCapitalization(QFont.Capitalization.AllUppercase) + + 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_link", + "d_link", + "dxt", + "et_link", + "i_link", + "mat", + "sx", + ]: + wref = "" + htext = fields[1] + oldFont = QFont(frag.font()) + target = "word" + 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 == "sx": + frag.setFont(caps) + elif token == "dxt": + if fields[3] == "illustration": + wref = fields[2] + target = "article" + elif fields[3] == "table": + wref = fields[2] + target = "table" + elif fields[3] != "": + wref = fields[3] + target = "sense" + else: + wref = fields[1] + target = "word" + elif token == "a_link": + target = "word" + wref = fields[1] + else: + raise Exception(f"Unknown code: {token} in {org}") + newFrag = copy.copy(frag) + newFrag.setText(htext) + newFrag.setWRef(wref) + newFrag.setTarget(target) + 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 + +class Definition(QWidget): + pronounce = pyqtSignal(str) + + def __init__( + self, word: Optional[Any] = None, *args: Any, **kwargs: Any + ) -> None: + super(Definition, self).__init__(*args, **kwargs) + self._word = word + if word is not None: + self.setWord(word) + return + + def setWord(self, word: Any) -> None: + self._word = word + lines = word.get_def() + assert lines is not None + self._lines = lines + self._buttons: list[Fragment] = [] + base = 0 + + for line in self._lines: + line.finalizeLine(self.width(), base) + for frag in line.getLine(): + if frag.audio().isValid(): + self._buttons.append(frag) + if frag.wRef(): + print(f"Adding {frag} as an anchor") + self._buttons.append(frag) + base += line.getLineSpacing() + self.setFixedHeight(base) + return + + def resizeEvent(self, event: Optional[QResizeEvent] = None) -> None: + base = 0 + for line in self._lines: + line.finalizeLine(self.width(), base) + base += line.getLineSpacing() + self.setFixedHeight(base) + super(Definition, self).resizeEvent(event) + return + + _downFrag: Optional[Fragment | None] = None + + def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: + if not event: + return super().mousePressEvent(event) + print(f"mousePressEvent: {event.pos()}") + for frag in self._buttons: + rect = frag.clickRect() + if rect.contains(event.pos()): + self._downFrag = frag + return + return super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None: + if not event: + return super().mouseReleaseEvent(event) + if self._downFrag is not None and self._downFrag.clickRect().contains( + event.pos() + ): + audio = self._downFrag.audio().url() + print(audio) + self.pronounce.emit(audio) + print("emit done") + self._downFrag = None + return + self._downFrag = 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) + base += line.repaintEvent(painter) + painter.restore() + return diff --git a/plugins/merriam-webster.py b/plugins/merriam-webster.py index 3211799..323fab9 100644 --- a/plugins/merriam-webster.py +++ b/plugins/merriam-webster.py @@ -1,7 +1,7 @@ from trycast import trycast import json import re -from typing import Any, NamedTuple, TypedDict, cast +from typing import Any, Literal, NamedTuple, NotRequired, TypedDict, cast from PyQt6.QtCore import QEventLoop, QUrl, Qt from PyQt6.QtGui import QColor, QFont @@ -129,14 +129,26 @@ class Definition(TypedDict): sseq: list[SenseSequence] vd: str -class Entry(TypedDict): +class EntryX(TypedDict): meta: Meta - hom: str + hom: NotRequired[str] hwi: HeadWordInfo - ahws: list[HeadWord] - vrs: list[Variant] + ahws: NotRequired[list[HeadWord]] + vrs: NotRequired[list[Variant]] fl: str def_: list[Definition] +Entry = TypedDict( + 'Entry', + { + 'meta': Meta, + 'hom': NotRequired[str], + 'hwi': HeadWordInfo, + 'ahws': NotRequired[list[HeadWord]], + 'vrs': NotRequired[list[Variant]], + 'fl': NotRequired[str], + 'def': list[Definition], + } +) def fetch(word:str) -> dict[str, Any]: request = QNetworkRequest() @@ -213,30 +225,55 @@ def do_prs(prs: list[Pronunciation]) -> list[Fragment]: def getDef(definition: list[Entry]) -> list[Line]: lines = [] + # + # Pull the fonts for ease of use + # headerFont = trycast(QFont, Word._resources['fonts']['header']) assert headerFont is not None textFont = trycast(QFont, Word._resources['fonts']['text']) assert textFont is not None labelFont = trycast(QFont, Word._resources['fonts']['label']) assert labelFont is not None - + # + # Pull the colors for ease of use + # baseColor = trycast(QColor, Word._resources['colors']['base']) assert baseColor is not None linkColor = trycast(QColor, Word._resources['colors']['link']) assert linkColor is not None subduedColor = trycast(QColor, Word._resources['colors']['subdued']) assert subduedColor is not None - entries = len(definition) + + # + # No need to figure it out each time it is used + # + entries = 0 + id = definition[0]['meta']['id'] + id = ':'.split(id)[0].lower() + for entry in definition: + if entry['meta']['id'].lower() == id: + entries += 1 for count, entry in enumerate(definition): + if entry['meta']['id'].lower() != id: + continue # - # Create the First line from the hwi and fl + # Create the First line from the hwi, [ahws] and fl # line = Line() hwi = trycast(HeadWordInfo, entry['hwi']) assert hwi is not None hw = re.sub(r'\*', '', hwi['hw']) - line.addFragment(Fragment(hw + ' ', headerFont, color=baseColor)) - frag = Fragment(f"{count} of {entries} ", textFont, color=linkColor) + line.addFragment(Fragment(hw, headerFont, color=baseColor)) + if 'ahws' in entry: + ahws = trycast(list[HeadWord], entry['ahws']) + assert ahws is not None + for ahw in ahws: + hw = re.sub(r'\*', '', ahw['hw']) + line.addFragment(Fragment(', ' + hw, headerFont, color=baseColor)) + if 'hom' in entry: + + if 'fl' in entry: + frag = Fragment(f"{count} of {entries} ", textFont, color= frag.setBackground(QColor(Qt.GlobalColor.gray)) line.addFragment(frag) line.addFragment(Fragment(entry['fl'], labelFont, color=baseColor)) @@ -244,10 +281,14 @@ def getDef(definition: list[Entry]) -> list[Line]: # # Next is the pronunciation. + # While 'prs' is optional, the headword is not. This gets us what we want. # line = Line() hw = re.sub(r'\*', '\u00b7', hwi['hw']) line.addFragment(Fragment(hw + ' ', textFont, color=subduedColor)) for frag in do_prs(hwi['prs']): line.addFragment(frag) + + # + # Try for return [Line()]