diff --git a/deftest.py b/deftest.py index 396632e..18f7f85 100755 --- a/deftest.py +++ b/deftest.py @@ -5,7 +5,7 @@ import signal import sys from typing import Any, cast -from PyQt6.QtCore import QResource, QSettings, Qt +from PyQt6.QtCore import QResource, QSettings, Qt, pyqtSlot from PyQt6.QtGui import QFontDatabase from PyQt6.QtSql import QSqlDatabase, QSqlQuery from PyQt6.QtWidgets import QApplication, QScrollArea @@ -18,12 +18,20 @@ from lib.words import Definition class DefinitionArea(QScrollArea): def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None: super(DefinitionArea, self).__init__(*args, *kwargs) - d = Definition(w) - self.setWidget(d) + self.definition = Definition(w) + self.setWidget(self.definition) self.setWidgetResizable(True) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.definition.newWord.connect(self.newWord) return + @pyqtSlot(str) + def newWord(self, word:str) -> None: + print(f"newWord: {word}") + w = Word(word) + self.definition.setWord(w) + return + def closeEvent(self, event): settings = QSettings("Troglodite", "esl_reader") settings.setValue("geometry", self.saveGeometry()) diff --git a/lib/definition.py b/lib/definition.py index 557de55..deb1ac6 100644 --- a/lib/definition.py +++ b/lib/definition.py @@ -16,10 +16,16 @@ from PyQt6.QtGui import ( QTextLayout, QTextOption, ) +from lib.sounds import SoundOff from PyQt6.QtWidgets import QWidget -from trycast import trycast +class MyPointF(QPointF): + def __repr__(self): + return f"({self.x()}, {self.y()})" + def __str__(self): + return self.__repr__() + class Fragment: """A fragment of text to be displayed""" @@ -117,12 +123,17 @@ class Fragment: painter.brush().setColor(Qt.GlobalColor.green) for fmt in self._layout.formats(): if fmt.format.isAnchor(): - #text = self._layout.text()[fmt.start:fmt.start+fmt.length] runs = self._layout.glyphRuns(fmt.start, fmt.length) bb = runs[0].boundingRect() bb.moveTo(bb.topLeft() + self._layout.position()) - painter.drawRect(bb) - #print(f"({bb.left()}-{bb.right()}, {bb.top()}-{bb.bottom()}): {text}") + url = QUrl(fmt.format.anchorHref()) + if url.scheme() == 'audio': + painter.setPen(QColor('red')) + radius = (bb.topLeft() - bb.bottomLeft()).manhattanLength()/4 + painter.drawRoundedRect(bb, radius,radius) + else: + painter.setPen(QColor("blue")) + #painter.drawRect(bb) painter.restore() return QSize(int(size.width()), int(size.height())) @@ -434,12 +445,16 @@ class Clickable(TypedDict): fmt: QTextCharFormat class Definition(QWidget): - pronounce = pyqtSignal(str) - + pronounce = pyqtSignal(QUrl) + alert = pyqtSignal() + newWord = pyqtSignal(str) def __init__( self, word: Optional[Any] = None, *args: Any, **kwargs: Any ) -> None: super(Definition, self).__init__(*args, **kwargs) + self._sound = SoundOff() + self.pronounce.connect(self._sound.playSound) + self.alert.connect(self._sound.alert) self._word = word if word is not None: self.setWord(word) @@ -450,7 +465,6 @@ class Definition(QWidget): lines: list[Line] = word.get_def() assert lines is not None self._lines = lines - self._buttons: list[Clickable] = [] base = 0 for line in self._lines: @@ -474,12 +488,23 @@ class Definition(QWidget): def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: if not event: return super().mousePressEvent(event) - print(f"mousePressEvent: {event.position()}") - for clk in self._buttons: - if clk["bb"].contains(event.position()): - print("inside") - self._downClickable = clk - return + position = MyPointF(event.position()) + print(f"mousePressEvent: {position}") + for line in self._lines: + for frag in line.getLine(): + layout = frag.layout() + for fmtRng in layout.formats(): + if fmtRng.format.isAnchor(): + runs = layout.glyphRuns(fmtRng.start, fmtRng.length) + assert len(runs) == 1 + bb = runs[0].boundingRect() + bb.moveTo(bb.topLeft() + layout.position()) + if bb.contains(event.position()): + self._downClickable = { + 'bb': bb, + 'fmt': fmtRng.format, + 'frag': frag, + } return super().mousePressEvent(event) def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None: @@ -490,9 +515,15 @@ class Definition(QWidget): ): print(f"mousePressPseudoEvent: {event.position()}") clk = self._downClickable - bb = clk['bb'] - print(f"({bb.left()}-{bb.right()}, {bb.top()}-{bb.bottom()})", clk["fmt"].anchorHref(),) - #self.pronounce.emit(audio) + url = QUrl(clk['fmt'].anchorHref()) + if url.scheme() == 'audio': + url.setScheme('https') + self.pronounce.emit(url) + elif url.scheme() == 'word': + self.newWord.emit(url.path()) + else: + print(f"{clk['fmt'].anchorHref()}: {url.scheme()}") + self.alert.emit() self._downClickable = None return self._downClickable = None @@ -510,29 +541,7 @@ class Definition(QWidget): # # All text on this line needs to be on the same baseline # - buildButtons = (len(self._buttons) < 1) - assert self._lines is not None for line in self._lines: line.paintEvent(painter) - if not buildButtons: - continue - for frag in line.getLine(): - for fmtRng in frag.layout().formats(): - if fmtRng.format.isAnchor(): - runs = frag.layout().glyphRuns(fmtRng.start,fmtRng.start+fmtRng.length) - run = runs[0] - bb = run.boundingRect() - bb.moveTo(bb.topLeft() + frag.layout().position()) - self._buttons.append( - { - 'bb': bb, - 'frag': frag, - 'fmt': fmtRng.format, - } - ) - painter.setPen(QColor("cyan")) - for click in self._buttons: - if click['fmt'].isAnchor(): - painter.drawRect(click['bb']) return diff --git a/lib/sounds.py b/lib/sounds.py index f19c6c0..db887d3 100644 --- a/lib/sounds.py +++ b/lib/sounds.py @@ -22,6 +22,7 @@ from PyQt6.QtNetwork import ( QNetworkReply, QNetworkRequest, ) +from trycast import trycast # from PyQt6.QtWidgets import QWidget @@ -90,7 +91,6 @@ class SoundOff(QObject): self.virtualPlayer.errorOccurred.connect(self.mediaError) self.virtualPlayer.mediaStatusChanged.connect(self.mediaStatus) self.virtualPlayer.playbackStateChanged.connect(self.playbackState) - self.nam.finished.connect(self.finished) return @pyqtSlot(QMediaPlayer.Error, str) @@ -101,11 +101,13 @@ class SoundOff(QObject): @pyqtSlot(QMediaPlayer.MediaStatus) def mediaStatus(self, status: QMediaPlayer.MediaStatus) -> None: - # print(f"mediaStatus: {status}") if status == QMediaPlayer.MediaStatus.LoadedMedia: - player: Optional[QMediaPlayer] = cast(QMediaPlayer, self.sender()) + player = trycast(QMediaPlayer, self.sender()) + if player is None: + player = trycast(QMediaPlayer, self.lastSender) assert player is not None player.play() + self.lastSender = self.sender() return @pyqtSlot(QMediaPlayer.PlaybackState) @@ -122,6 +124,7 @@ class SoundOff(QObject): return @pyqtSlot(str) + @pyqtSlot(QUrl) def playSound(self, url: str | QUrl) -> None: if isinstance(url, str): url = QUrl(url) @@ -131,8 +134,10 @@ class SoundOff(QObject): self.virtualPlayer.setAudioOutput(self.virtualOutput) if url != self._lastUrl: request = QNetworkRequest(url) - self.nam.get(request) + reply = self.nam.get(request) + assert reply is not None self._lastUrl = url + reply.finished.connect(self.finished) return for player in [self.localPlayer, self.virtualPlayer]: if not player: @@ -154,23 +159,25 @@ class SoundOff(QObject): _buffer: dict[QMediaPlayer, QBuffer] = {} _lastUrl = QUrl() - @pyqtSlot(QNetworkReply) - def finished(self, reply: QNetworkReply) -> None: - storage = reply.readAll() + @pyqtSlot() + def finished(self) -> None: + reply = trycast(QNetworkReply, self.sender()) + assert reply is not None + code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + print(f"HttpStatusCodeAttribute: {code}, error: {reply.error()}") + self._reply = reply.readAll() + url = reply.request().url() reply.close() - crypto = QCryptographicHash(QCryptographicHash.Algorithm.Sha256) + if self._reply.isEmpty() or self._reply.isNull(): + return for player in [self.localPlayer, self.virtualPlayer]: if not player: continue - self._storage[player] = QByteArray(storage) - crypto.addData(self._storage[player]) - # print(player, crypto.result().toHex()) - crypto.reset() + self._storage[player] = QByteArray(self._reply) self._buffer[player] = QBuffer(self._storage[player]) - url = reply.request().url() player.setSourceDevice(self._buffer[player], url) player.setPosition(0) if player.mediaStatus() == QMediaPlayer.MediaStatus.LoadedMedia: player.play() - # print("play") + print("play") return diff --git a/plugins/merriam-webster.py b/plugins/merriam-webster.py index b562eaf..c0d2ae3 100644 --- a/plugins/merriam-webster.py +++ b/plugins/merriam-webster.py @@ -151,6 +151,17 @@ class DefinitionSection(TypedDict): sls: NotRequired[list[str]] sseq: Any # list[list[Pair]] +class UndefinedRunOn(TypedDict): + ure: str + fl: str + utxt: NotRequired[list[list[Pair]]] + ins: NotRequired[list[Inflection]] + lbs: NotRequired[list[str]] + prs: NotRequired[list[Pronunciation]] + sls: NotRequired[list[str]] + vrs: NotRequired[list[Variant]] + + DefinedRunOn = TypedDict( "DefinedRunOn", { @@ -287,7 +298,7 @@ def fetch(word: str) -> WordType: def soundUrl(sound: Sound, fmt="ogg") -> QUrl: """Create a URL from a PRS structure.""" - base = f"https://media.merriam-webster.com/audio/prons/en/us/{fmt}" + base = f"audio://media.merriam-webster.com/audio/prons/en/us/{fmt}" audio = sound["audio"] m = re.match(r"(bix|gg|[a-zA-Z])", audio) if m: @@ -345,10 +356,26 @@ def do_prs(frag: Fragment, prs: list[Pronunciation] | None) -> None: return -def do_aq(aq: AttributionOfQuote | None) -> list[Line]: +def do_aq(aq: AttributionOfQuote | None) -> Line: assert aq is not None - raise NotImplementedError("aq") - return [] + r = Resources() + frag = Fragment() + if 'auth' in aq: + frag.addText(aq['auth']+', ', r.subduedFormat) + if 'source' in aq: + frag.addText(aq['source'], r.subduedFormat) + if 'aqdate' in aq: + frag.addText(', '+aq['aqdate'], r.subduedFormat) + if 'subsource' in aq: + ss = trycast(SubSource, aq['subsource']) + assert ss is not None + if 'source' in ss: + frag.addText(', '+ss['source'], r.subduedFormat) + if 'aqdate' in ss: + frag.addText(', '+ss['aqdate'], r.subduedFormat) + line = Line() + line.addFragment(frag) + return line def do_vis(vis: list[VerbalIllustration] | None, indent=0) -> list[Line]: @@ -364,7 +391,7 @@ def do_vis(vis: list[VerbalIllustration] | None, indent=0) -> list[Line]: line.addFragment(frag) lines.append(line) if "aq" in vi: - lines += do_aq(trycast(AttributionOfQuote, vi["aq"])) + lines.append(do_aq(trycast(AttributionOfQuote, vi["aq"]))) return lines @@ -688,7 +715,32 @@ def do_dros(dros: list[DefinedRunOn]|None) -> list[Line]: raise NotImplementedError(f"Key of {k}") return lines - +def do_uros(uros: list[UndefinedRunOn]|None) -> list[Line]: + assert uros is not None + r = Resources() + lines: list[Line] = [] + for uro in uros: + frag = Fragment() + text = re.sub(r'\*', '', uro['ure']) + frag.addText(text, r.labelFormat) + if 'prs' in uro: + do_prs(frag, uro['prs']) + frag.addText(' '+uro['fl'],r.textFormat) # r.linkFormat + line = Line() + line.addFragment(frag) + lines.append(line) + if 'utxt' in uro: + for entry in uro['utxt']: + for pair in entry: + if pair['objType'] == 'vis': + lines += do_vis(trycast(list[VerbalIllustration], pair['obj'])) + elif pair['objType'] == 'uns': + (newFrags, newLines) = do_uns(trycast(list[list[list[Pair]]],pair['obj']),0) + line = Line() + line.addFragment(newFrags) + lines.append(line) + lines += newLines + return lines def getDef(defines: Any) -> list[Line]: Line.setParseText(parseText) workList = restructure(defines) @@ -786,6 +838,10 @@ def getDef(defines: Any) -> list[Line]: lines += do_def(define) except NotImplementedError: raise + if "uros" in work: + print(json.dumps(work['uros'],indent=2)) + uros = trycast(list[UndefinedRunOn], work['uros']) + lines += do_uros(uros) if "dros" in work: dros = trycast(list[DefinedRunOn], work["dros"]) if len(phrases) < 1: @@ -815,6 +871,7 @@ def getDef(defines: Any) -> list[Line]: "shortdef", "vrs", "dros", + 'uros', ]: raise NotImplementedError(f"Unknown key {k} in work") if len(phrases) > 0: @@ -845,10 +902,10 @@ def replaceCode(code:str) -> tuple[str, QTextCharFormat]: token = fields[0] if token == 'a_link': text = fields[1] - fmt.setAnchorHref(fields[1]) + fmt.setAnchorHref('auto://'+fields[1]) elif token in ['d_link', 'et_link', 'mat', 'sx', 'i_link']: text = fields[1] - pre = 'word://' + pre = 'word:///' if fields[2] == '': fmt.setAnchorHref(pre+fields[1]) else: @@ -859,18 +916,18 @@ def replaceCode(code:str) -> tuple[str, QTextCharFormat]: fmt.setFontCapitalization(QFont.Capitalization.SmallCaps) elif token == 'dxt': if fields[3] == 'illustration': - fmt.setAnchorHref('article://'+fields[2]) + fmt.setAnchorHref('article:///'+fields[2]) elif fields[3] == 'table': - fmt.setAnchorHref('table://'+fields[2]) + fmt.setAnchorHref('table:///'+fields[2]) elif fields[3] != "": - fmt.setAnchorHref('sense://'+fields[3]) + fmt.setAnchorHref('sense:///'+fields[3]) else: - fmt.setAnchorHref('word://'+fields[1]) + fmt.setAnchorHref('word:///'+fields[1]) elif token == 'et_link': if fields[2] != '': - fmt.setAnchorHref('etymology://'+fields[2]) + fmt.setAnchorHref('etymology:///'+fields[2]) else: - fmt.setAnchorHref('etymology://' + fields[1]) + fmt.setAnchorHref('etymology:///' + fields[1]) else: raise NotImplementedError(f"Token {code} not implimented") fmt.setForeground(r.linkColor)