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